SwiftUI之ScrollView使用

3,415 阅读6分钟

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中提供了 ScrollViewProxyScorllViewReader来设置scrollView滚动。

  • ScrollViewProxy 中提供了一个方法,能够让scrollView滚动到某个id的位置
  • ScrollViewReader 其目的是为了获取scrollViewProxy

看一下ScrollViewProxyScrollViewReader中具体的定义

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

接下来 我们做一个简单的例子来看下ScrollViewProxyScrollViewReader的实际应用。效果如下:

scrollViewTest1.gif

上图直接设置的滚动到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

接下来我们利用上述的原理来封装一个常用的控件,点击的时候自己滚动到屏幕中间,效果如下图。

scrollviewTest2.gif

先抛开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滚动到指定的位置

先来看下效果:

scrollViewOffset.gif

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这种方式来解决。

参考

Hosting+Representable

AlignmentGuides参考

PreferenceKey参考