This article focuses on Representable Hosting+, but first we’ll look at a feature that’s been added to ScrollView in iOS14.

ScrollViewProxy & ScrollViewReader

In iOS14, ScrollView has a new ScrollViewProxy and ScrollViewReader.

public struct ScrollViewProxy {
    public func scrollTo<ID>(_ id: ID, anchor: UnitPoint? = nil) where ID : Hashable
}
Copy the code
@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
}
Copy the code
  • The ScrollViewReader needs to be written inside the ScrollView to get the ScrollViewProxy
  • ScrollViewProxy has a method that scrolls the ScrollView to a certain ID

If you look closely at the figure above, you can see that clicking the leftmost button can indeed make the ScrollView scrollTo a specified position, scrollTo

(_ ID: ID, anchor: UnitPoint? = nil) where ID: Hashable function, there is a parameter called anchor, when its value is nil, the system will automatically calculate the shortest scrolling distance to this position, when it is not nil, the difference is:

  • leadingScroll to left aligned with ScrollView
  • .centerScroll to the middle of a ScrollView
  • .trailingScroll to the right aligned with ScrollView

It’s pretty simple to use, but it’s worth noting that the button must be in a ScrollView. The code is as follows:

struct Example1: View {
    var body: some View {
        ScrollView(.horizontal) {
            ScrollViewReader { scrollViewProxy in
                
                LazyHStack(spacing: 10) {...ForEach(0..<20) { index in
                        Text("\(index)")
                            .frame(width: 100, height: 240)
                            
                            .background(index == 6 ? Color.green : Color.orange.opacity(0.5))
                            .cornerRadius(3.0)
                    }
                }
                .padding(.horizontal, 10)
            }
        }
        .frame(height: 300, alignment:  .center)
    }
}

Copy the code

ScrollViewRepresentable

The added feature mentioned above is only a very small feature. In UIKit, ScrollView has a very rich set of features. As we know, ScrollViewRepresentable allows us to directly use the View in UIKit, code that looks something like:

struct Example2: View {
    var body: some View {
        ScrollViewRepresentable()
            .frame(width: 200, height: 100)}struct ScrollViewRepresentable: UIViewRepresentable {
        func makeUIView(context: Context) -> UIScrollView {
            let scrollView = UIScrollView()
            scrollView.backgroundColor = UIColor.green
            return scrollView
        }
        
        func updateUIView(_ uiView: UIScrollView, context: Context){}}}Copy the code

In this case, we can only access UIScrollView, but adding the SwiftUI View on top of it won’t do that. So this scheme passes.

Hosting+Representable

The so-called Hosting refers to UIHostingController, whereas Representable refers to UIViewControllerRepresentable, just put the two together, can obtain strong ability.

So what is UIHostingController? Let me show you a picture:

Obviously, it’s a bridge between SwiftUI and UIKit, and the system will translate the View in SwiftUI into the View in UIKit. Our final goal is to achieve the effect of the following two pictures:

  • Listen for the offset currently scrolling
  • Scroll to the specified offset

class MyScrollViewUIHostingController<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) {
        /// make sure that the listener is set once
        if showed {
            return
        }
        showed = true
        
        / / / search a UIScrollView
        sv = findScrollView(in: view)
        
        // set the listenersv? .addObserver(self,
                        forKeyPath: #keyPath(UIScrollView.contentOffset),
                        options: [.old, .new],
                        context: nil)
        
        /// Scroll to the specified position
        scroll(to: offset.wrappedValue, animated: false)
        
        super.viewDidAppear(animated)
    }
    
    func scroll(to position: CGFloat, animated: Bool = true) {
        if let s = sv {
            ifposition ! = (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? {
        ifview? .isKind(of:UIScrollView.self)??false {
            return view as? UIScrollView
        }
        
        for subview inview? .subviews ?? [] {if let sv = findScrollView(in: subview) {
                return sv
            }
        }
        
        return nil}}Copy the code

MyScrollViewUIHostingController, can direct access to the UIScrollView, and send it to the Content is set to the rootView, above this code is very simple.

struct MyScrollViewControllerRepresentable<Content> :UIViewControllerRepresentable where Content: View {
    var offset: Binding<CGFloat>
    let isOffsetX: Bool
    var content: Content
    
    func makeUIViewController(context: Context) -> MyScrollViewUIHostingController<Content> {
        MyScrollViewUIHostingController(offset: offset, isOffsetX:isOffsetX, rootView: content)
    }
    
    func updateUIViewController(_ uiViewController: MyScrollViewUIHostingController<Content>, context: Context) {
        uiViewController.scroll(to: offset.wrappedValue, animated: true)}}Copy the code

MyScrollViewControllerRepresentable UIViewControllerRepresentable agreement is achieved, thus can be created directly in the View of SwiftUI.

extension View {
    func scrollOffsetX(_ offsetX: Binding<CGFloat>) -> some View {
        return MyScrollViewControllerRepresentable(offset: offsetX, isOffsetX: true, content: self)}}extension View {
    func scrollOffsetY(_ offsetY: Binding<CGFloat>) -> some View {
        return MyScrollViewControllerRepresentable(offset: offsetY, isOffsetX: false, content: self)}}Copy the code

Finally, we create two View extensions and use them as follows:

ScrollView(.horizontal) {
    ...
}
.scrollOffsetX(self.$offsetX)
Copy the code
ScrollView(.vertical) {
    ...
.scrollOffsetY(self.$offsetY)
Copy the code

conclusion

When we encounter insufficient functions provided by SwiftUI, we can consider the solution used above, which can provide a good idea for us.

I have isolated this functionality separately, and the repository address is github.com/agelessman/…

This article’s sample code can access here: gist.github.com/agelessman/…

SwiftUI Collection: FuckingSwiftUI