ScrollView 使用
UIKit中 scrollView有十分丰富的功能,但是在SwiftUI中ScrollView变化很多,在其声明的地方只有一个滚动方向和是否展示进度条的设置
public struct ScrollView<Content> : View where Content : View {
/// 设置子控件
public var content: Content
///滚动方式 默认垂直滚动
public var axes: Axis.Set
/// 展示进度条 默认为yes
public var showsIndicators: Bool
public init(_ axes: Axis.Set = .vertical, showsIndicators: Bool = true, @ViewBuilder content: () -> Content)
public var body: some View { get }
public typealias Body = some View
}
如果我们现在需要scrollview滚动到指定的位置,在SwiftUI中提供了 ScrollViewProxy
和ScorllViewReader
来设置scrollView滚动。
- ScrollViewProxy 中提供了一个方法,能够让scrollView滚动到某个id的位置
- ScrollViewReader 其目的是为了获取scrollViewProxy
看一下ScrollViewProxy
和ScrollViewReader
中具体的定义
public struct ScrollViewProxy {
public func scrollTo<ID>(_ id: ID, anchor: UnitPoint? = nil) where ID : Hashable
}
@frozen public struct ScrollViewReader<Content> : View where Content : View {
public var content: (ScrollViewProxy) -> Content
@inlinable public init(@ViewBuilder content: @escaping (ScrollViewProxy) -> Content)
/// The content and behavior of the view.
public var body: some View { get }
public typealias Body = some View
}
在ScrollViewProxy
提供的方法中需要一个Id,这个Id是控件的唯一标识,在View的extension中我们看到其定义如下:
extension View {
/// Binds a view's identity to the given proxy value.
///
/// When the proxy value specified by the `id` parameter changes, the
/// identity of the view — for example, its state — is reset.
@inlinable public func id<ID>(_ id: ID) -> some View where ID : Hashable
}
例子1
接下来 我们做一个简单的例子来看下ScrollViewProxy
和ScrollViewReader
的实际应用。效果如下:
上图直接设置的滚动到id为6的控件上,但是在最后一个参数anchor
的设置上有区别,当其值为nil的时候系统会自动计算出滚动到该位置的最短距离。不为nil时有如下区别
.leading
滚动到与ScrollView左对齐.center
滚动到ScrollView中间.leading
滚动到与ScrollView右对齐
详细代码如下
struct TestScrollViewScrollFunc: View {
let buttonTitles = [".leading",".center",".trailing"]
var body: some View {
ScrollViewReader { scrollView in
ScrollView(.horizontal,showsIndicators: false) {
HStack{
ForEach(1..<20,id:.self) { index in
Text("(index)")
.font(.system(size:40,weight: .bold))
.foregroundColor(.white)
.frame(width: 100, height: 240)
.background(Color.randomColor())
.cornerRadius(5)
.padding([.leading,.trailing],10)
.id(index)//设置id
}
}
}
HStack{
ForEach(self.buttonTitles,id:.self) { value in
Button(value) {
withAnimation {
scrollView.scrollTo(6, anchor: .leading)
}
}
.frame(width: 80, height: 40)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(5)
}
}
}
.onAppear {
UIDevice.current.setValue(UIInterfaceOrientation.landscapeLeft.rawValue, forKey: "orientation")
}
.onDisappear {
UIDevice.current.setValue(UIInterfaceOrientation.portrait.rawValue, forKey: "orientation")
}
}
}
例子2
接下来我们利用上述的原理来封装一个常用的控件,点击的时候自己滚动到屏幕中间,效果如下图。
先抛开scrollview不谈,实现类似segment的效果我们有两种方式
AlignmentGuides
使用对齐方式实现 常规的可以使用系统的对齐方式, AlignmentGuides参考PreferenceKey
使用preference来操作 需要自定义preferenceKey对象 PreferenceKey参考
在这个基础上使用scrollview包裹一下,就能实现滚动的效果。本文中使用AlignmentGuides和PreferenceKey两种方式进行构建。
AlignmentGuides
1.自定义对齐方式
/// 对齐方式返回view的高度
extension HorizontalAlignment {
private enum HeightAlignment: AlignmentID{
static func defaultValue(in context: ViewDimensions) -> CGFloat {
return context.height
}
}
/// 进行快速访问
static let heightAlignment = HorizontalAlignment(HeightAlignment.self)
}
2.定义segmentItem
segmentView在选中和非选中条件下样式不同,封装一个view单独来进行属性上的设置
struct ZJSegmentLabel: View {
let title: String
let selectedIdx: Int
let index: Int
var normalColor: Color = .primary
var selectColor: Color = .blue
var fontSize: CGFloat = 14
var body: some View {
Text(title)
.font(.system(size: fontSize))
.scaleEffect(selectedIdx == index ? 1.5 : 1.0)
.foregroundColor(selectedIdx == index ? selectColor : normalColor)
.padding(.all,10)
}
}
3.设置对齐方式和约束展示
func simpleScorllViewShow() -> some View {
ScrollViewReader{ scrollView in
ScrollView(.horizontal,showsIndicators: false) {
VStack(alignment: .heightAlignment) {
HStack{
ForEach(self.dataTitle.indices,id:.self) { idx in
if idx == self.selectedIdx {
ZJSegmentLabel(
title: self.dataTitle[idx],
selectedIdx: self.selectedIdx,
index: idx,
normalColor: Color.primary,
selectColor: Color.blue)
.transition(AnyTransition.identity)
.alignmentGuide(.heightAlignment) { d in
return d[HorizontalAlignment.center]
}.id(idx)
}else{
ZJSegmentLabel(
title: self.dataTitle[idx],
selectedIdx: self.selectedIdx,
index: idx,
normalColor: Color.primary,
selectColor: Color.blue
)
.transition(AnyTransition.identity)
.onTapGesture {
withAnimation {
self.selectedIdx = idx
scrollView.scrollTo(idx,anchor: .center)
}
}.id(idx)
}
}
}
//添加下划线
RoundedRectangle(cornerRadius: 3)
.frame(width: 20, height: 6)
.foregroundColor(Color.blue)
.transition(AnyTransition.identity)
.alignmentGuide(.heightAlignment) { d in
return d[HorizontalAlignment.center]
}.offset(x: 0, y: -10)
}
}
}
}
上面我们定义了一个创建segment的方法,返回一个View对象。每次点击segment上的text对象会触发一次滚动的操作,这里要设置anchor的值才会滚动的居中的位置,大家也可以试试 leading 和 trailing的效果。
PreferenceKey
1.定义PreferenceKey和包含的value
struct SegmentViewValue: Equatable {
let idx: Int
let rect: CGRect
}
/// 下面的写法是固定写法,在其中定义好自己的value即可
struct SegmentViewValueKey: PreferenceKey {
typealias Value = [SegmentViewValue]
static var defaultValue: Value = []
static func reduce(value: inout [SegmentViewValue], nextValue: () -> [SegmentViewValue]) {
value.append(contentsOf: nextValue())
}
}
2.创建PreferenceKey相关的segmentItem
struct SegmentPreferenceItem: View {
let title: String
let selectedIdx: Int
let index: Int
var normalColor: Color = .primary
var selectColor: Color = .blue
var fontSize: CGFloat = 14
var body: some View {
Text(title)
.font(.system(size: fontSize))
.scaleEffect(selectedIdx == index ? 1.5 : 1.0)
.foregroundColor(selectedIdx == index ? selectColor : normalColor)
.padding(.all,10)
.background(
GeometryReader(content: { proxy in
AnyView(GeometryReader(){ _ in
EmptyView()
}).preference(key: SegmentViewValueKey.self, value: [SegmentViewValue(idx: index, rect: proxy.frame(in: .named("showBottomLine")))])
})
)
}
}
3.组装控件和关联PreferenceKey
func simplePreferenceKeyScrollViewShow() -> some View {
ScrollViewReader{ scrollView in
ScrollView(.horizontal,showsIndicators: false) {
HStack{
ForEach(self.dataTitle.indices, id:.self) { idx in
SegmentPreferenceItem(
title: self.dataTitle[idx],
selectedIdx: self.selectedIdx,
index: idx,
normalColor: Color.primary,
selectColor: Color.blue)
.onTapGesture {
withAnimation {
self.selectedIdx = idx
scrollView.scrollTo(idx, anchor: .center)
}
}.id(idx)
}
}
.coordinateSpace(name: "showBottomLine")
.overlayPreferenceValue(SegmentViewValueKey.self) { preference in
//设置滚动条样式,可以是任何View 比较灵活
let selectedItem = preference[self.selectedIdx]
RoundedRectangle(cornerRadius: 3)
.fill(.blue)
.frame(width: selectedItem.rect.width/2.0, height: 6)
.position(x: selectedItem.rect.minX+selectedItem.rect.width/2, y: selectedItem.rect.height/2)
.offset(x: 0, y: selectedItem.rect.height/2.0)
}
//该操作是为了底部标识不被裁剪
.padding([.top,.bottom],10)
}
}
}
AlignmentGuides和 PreferenceKey 对比
但看UI上的呈现效果 这两者并没有什么区别,这是在实现上需要自定义对齐方式和 继承PreferenceKey实现自己的类。在后期可拓展性上 PreferenceKey拓展性比较高
1.preferenceKey 可以获取当前View的宽高,滚动条可以动态的调整宽度,而AlignmentGuides中设置的滚动条宽度是固定的。
2.alignmentGuide中是在两个不同层级通过对齐方式进行修改,而PreferenceKey都是在同一层级上做处理
Hosting+Representable
上面两个例子中 我们发现,在UIKit中可以指定scrollview滚动到指定的位置,或者根据scrollview的滚动做一些改变。但是我们在当前的上下文中没有方法可以直接操作scrollview滚动到具体的位置或者获取当前scrollview当前的offset。因此我们引入Hosting和Respresentable
Hosting 是指UIHostingController,它是SwiftUI和UIKit中的一个桥梁,系统会把SwiftUI中的View翻译成UIKit中的View。而Representable指的是UIViewControllerRepresentable,只要把这两个结合起来就能获更丰富的功能。
我们的目的:
- 监听当前ScrollView的滚动偏移量
- 使ScrollView滚动到指定的位置
先来看下效果:
class ScrollViewUIHostingController<Content>: UIHostingController<Content> where Content: View {
var offset: Binding<CGFloat>
let isOffsetX: Bool
var showed = false
var sv: UIScrollView?
init(offset: Binding<CGFloat>, isOffsetX: Bool, rootView: Content) {
self.offset = offset
self.isOffsetX = isOffsetX
super.init(rootView: rootView)
}
@objc dynamic required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidAppear(_ animated: Bool) {
/// 保证设置一次监听
if showed {
return
}
showed = true
/// 查找UIScrollView
sv = findScrollView(in: view)
/// 设置监听
sv?.addObserver(self,
forKeyPath: #keyPath(UIScrollView.contentOffset),
options: [.old, .new],
context: nil)
/// 滚动到指定位置
scroll(to: offset.wrappedValue, animated: false)
super.viewDidAppear(animated)
}
func scroll(to position: CGFloat, animated: Bool = true) {
if let s = sv {
if position != (self.isOffsetX ? s.contentOffset.x : s.contentOffset.y) {
let offset = self.isOffsetX ? CGPoint(x: position, y: 0) : CGPoint(x: 0, y: position)
sv?.setContentOffset(offset, animated: animated)
}
}
}
override func observeValue(forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?) {
if keyPath == #keyPath(UIScrollView.contentOffset) {
if let s = self.sv {
DispatchQueue.main.async {
self.offset.wrappedValue = self.isOffsetX ? s.contentOffset.x : s.contentOffset.y
}
}
}
}
func findScrollView(in view: UIView?) -> UIScrollView? {
if view?.isKind(of: UIScrollView.self) ?? false {
return view as? UIScrollView
}
for subview in view?.subviews ?? [] {
if let sv = findScrollView(in: subview) {
return sv
}
}
return nil
}
}
在上面的MyScrollViewUIHostingController中可以直接访问到UIScrollView,并且把传过来的Content设置为rootView.接下来开始设置Representable,MyScrollViewControllerRepresentable实现了UIViewControllerRepresentable协议,可以直接在SwiftUI的View中创建。
struct ScrollViewControllerRepresentable<Content>: UIViewControllerRepresentable where Content: View {
var offset: Binding<CGFloat>
let isOffsetX: Bool
var content: Content
func makeUIViewController(context: Context) -> ScrollViewUIHostingController<Content> {
ScrollViewUIHostingController(offset: offset, isOffsetX:isOffsetX, rootView: content)
}
func updateUIViewController(_ uiViewController: ScrollViewUIHostingController<Content>, context: Context) {
uiViewController.scroll(to: offset.wrappedValue, animated: true)
}
}
最后我们对View创建extension,跳转到指定的位置(对List也可以使用)
extension View {
/// 水平防线滚动
func scrollOffsetX(_ offsetX: Binding<CGFloat>) -> some View {
return ScrollViewControllerRepresentable(offset: offsetX, isOffsetX: true, content: self)
}
/// 竖直方向滚动
func scrollOffsetY(_ offsetY: Binding<CGFloat>) -> some View {
return ScrollViewControllerRepresentable(offset: offsetY, isOffsetX: false, content: self)
}
}
做好上面的准备工作,接下来就是对上图中例子控件的布局和功能的实现。设置一个全局的State对象来和MyScrollViewControllerRepresentable中的offset进行绑定。
func calculateScrollViewOffset() -> some View {
VStack{
Spacer()
Text("当前滚动距离====(self.offset)")
Spacer()
HStack{
ForEach(0..<4,id:.self) { idx in
Button("offset:(idx*200)") {
withAnimation(.easeIn(duration: 0.1)) {
self.offset = CGFloat(idx * 200);
}
}
.frame(width: 100, height: 40)
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(5)
}
}
Spacer()
ScrollView(.vertical,showsIndicators: false) {
VStack{
ForEach(1..<30,id:.self) { idx in
Text("(idx)")
.foregroundColor(.white)
.font(.system(size: 40))
.frame(width: 200, height: 100)
.background(Color.randomColor())
.cornerRadius(5)
}
}
}
/// 绑定一个 State对象
.scrollOffsetY(self.$offset)
}
}
总结
SwiftUI中ScrollView功能还在完善中,目前只支持滚动到指定的ID。当我们遇到SwiftUI中提供不了对应的功能的时候可以考虑使用Hosting+Representable这种方式来解决。
参考
今天的文章SwiftUI之ScrollView使用分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/20947.html