Duncan Groenewald
Duncan Groenewald

Reputation: 8988

How to create synced ScrollViews in SwiftUI

I am trying to create two synced ScrollViews in SwiftUI such that scrolling in one will result in the same scrolling in the other.

I am using the ScrollViewOffset class shown at the bottom for getting a scrollView offset value but having trouble figuring out how to scroll the other view.

I seem to be able to 'hack' it by preventing scrolling in one view and setting the content position() on the other - is there any way to actually scroll the scrollView content to a position - I know ScrollViewReader seems to allow scrolling to display content items but I can't seem to find anything that will scroll the contents to an offset position.

The problem with using position() is that it does not actually change the ScrollViews scroller positions - there seems to be no ScrollView.scrollContentsTo(point: CGPoint).

 @State private var scrollOffset1: CGPoint = .zero 

 HStack {
   ScrollViewOffset(onOffsetChange: { offset in
                    scrollOffset1 = offset
                    print("New ScrollView1 offset: \(offset)")
                }, content: {
                    
                    VStack {
                        
                        ImageView(filteredImageProvider: self.provider)
                            .frame(width: imageWidth, height: imageHeight)
                    }
                    .frame(width: imageWidth + (geometry.size.width - 20) * 2, height: imageHeight + (geometry.size.height - 20) * 2)
                    .border(Color.white)
                    .id(0)
   })

   ScrollView([]) {
                
                VStack {
                    
                    ImageView(filteredImageProvider: self.provider, showEdits: false)
                        .frame(width: imageWidth, height: imageHeight)
                }
                .frame(width: imageWidth + (geometry.size.width - 20) * 2, height: imageHeight + (geometry.size.height - 20) * 2)
                .border(Color.white)
                .id(0)
                .position(x: scrollOffset1.x, y: scrollOffset1.y + (imageHeight + (geometry.size.height - 20) * 2)/2)
                
     }

}


//
//  ScrollViewOffset.swift
//  ZoomView
//
//

import Foundation
import SwiftUI

struct ScrollViewOffset<Content: View>: View {
    let onOffsetChange: (CGPoint) -> Void
    let content: () -> Content
    
    init(
        onOffsetChange: @escaping (CGPoint) -> Void,
        @ViewBuilder content: @escaping () -> Content
    ) {
        self.onOffsetChange = onOffsetChange
        self.content = content
    }
    
    var body: some View {
        ScrollView([.horizontal, .vertical]) {
            offsetReader
            content()
                .padding(.top, -8)
        }
        .coordinateSpace(name: "frameLayer")
        .onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: onOffsetChange)
    }
    
    var offsetReader: some View {
        GeometryReader { proxy in
            Color.clear
                .preference(
                    key: ScrollOffsetPreferenceKey.self,
                    value: proxy.frame(in: .named("frameLayer")).origin
                )
        }
        .frame(width: 0, height: 0)
    }
}

private struct ScrollOffsetPreferenceKey: PreferenceKey {
    static var defaultValue: CGPoint = .zero
    static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}

Upvotes: 5

Views: 3484

Answers (2)

WoodyM
WoodyM

Reputation: 177

With the current API of ScrollView, this is not possible. While you can get the contentOffset of the scrollView using methods that are widely available on the internet, the ScrollViewReader that is used to programmatically scroll a ScrollView only allows you to scroll to specific views, instead of to a contentOffset. To achieve this functionality, you are going to have to wrap UIScrollView. Here is an implementation, although it isn't 100% stable, and is missing a good amount of scrollView functionality:

import SwiftUI
import UIKit

public struct ScrollableView<Content: View>: UIViewControllerRepresentable {

    @Binding var offset: CGPoint
    var content: () -> Content

    public init(_ offset: Binding<CGPoint>, @ViewBuilder content: @escaping () -> Content) {
        self._offset = offset
        self.content = content
    }

    public func makeUIViewController(context: Context) -> UIScrollViewViewController {
        let vc = UIScrollViewViewController()
        vc.hostingController.rootView = AnyView(self.content())
        vc.scrollView.setContentOffset(offset, animated: false)
        vc.delegate = context.coordinator
        return vc
    }

    public func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context) {
        viewController.hostingController.rootView = AnyView(self.content())

        // Allow for deaceleration to be done by the scrollView
        if !viewController.scrollView.isDecelerating {
            viewController.scrollView.setContentOffset(offset, animated: false)
        }
    }


    public func makeCoordinator() -> Coordinator {
        Coordinator(contentOffset: _offset)
    }

    public class Coordinator: NSObject, UIScrollViewDelegate {
        let contentOffset: Binding<CGPoint>

        init(contentOffset: Binding<CGPoint>) {
            self.contentOffset = contentOffset
        }

        public func scrollViewDidScroll(_ scrollView: UIScrollView) {
            contentOffset.wrappedValue = scrollView.contentOffset
        }
    }
}

public class UIScrollViewViewController: UIViewController {

    lazy var scrollView: UIScrollView = UIScrollView()

    var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(EmptyView()))

    weak var delegate: UIScrollViewDelegate?

    public override func viewDidLoad() {
        super.viewDidLoad()
        self.scrollView.delegate = delegate
        self.view.addSubview(self.scrollView)
        self.pinEdges(of: self.scrollView, to: self.view)

        self.hostingController.willMove(toParent: self)
        self.scrollView.addSubview(self.hostingController.view)
        self.pinEdges(of: self.hostingController.view, to: self.scrollView)
        self.hostingController.didMove(toParent: self)

    }

    func pinEdges(of viewA: UIView, to viewB: UIView) {
        viewA.translatesAutoresizingMaskIntoConstraints = false
        viewB.addConstraints([
            viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
            viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
            viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
            viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
        ])
    }

}
struct ScrollableView_Previews: PreviewProvider {
    static var previews: some View {
        Wrapper()
    }

    struct Wrapper: View {
        @State var offset: CGPoint = .init(x: 0, y: 50)
        var body: some View {
            HStack {
                ScrollableView($offset, content: {
                    ForEach(0...100, id: \.self) { id in
                        Text("\(id)")
                    }
                })

                ScrollableView($offset, content: {
                    ForEach(0...100, id: \.self) { id in
                        Text("\(id)")
                    }
                })



                VStack {
                    Text("x: \(offset.x) y: \(offset.y)")
                    Button("Top", action: {
                        offset = .zero
                    })
                    .buttonStyle(.borderedProminent)
                }
                .frame(width: 200)
                .padding()

            }

        }
    }
}

Upvotes: 1

tihnchoi
tihnchoi

Reputation: 119

Synced scroll views.

In this example, you can scroll the LHS scrollview and the RHS scrollview will be synchronised to the same position. In this example, the scrollview on the RHS is disabled, and the position is simply synchronised by using an offset.

But using the same logic and code, you can make both the LHS and RHS scrollviews synced when either of them are scrolled.

import SwiftUI

struct ContentView: View {
    
    @State private var offset = CGFloat.zero
    
    var body: some View {
        
        HStack(alignment: .top) {
            
            // MainScrollView
            ScrollView {
                VStack {
                    ForEach(0..<100) { i in
                        Text("Item \(i)").padding()
                    }
                }
                .background( GeometryReader {
                    Color.clear.preference(key: ViewOffsetKey.self,
                                           value: -$0.frame(in: .named("scroll")).origin.y)
                })
                .onPreferenceChange(ViewOffsetKey.self) { value in
                    print("offset >> \(value)")
                    offset = value
                }
            }
            .coordinateSpace(name: "scroll")
            
            
            // Synchronised with ScrollView above
            ScrollView {
                VStack {
                    ForEach(0..<100) { i in
                        Text("Item \(i)").padding()
                    }
                }
                .offset(y: -offset)
            }
            .disabled(true)
        }
    }
}

struct ViewOffsetKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat.zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}

Upvotes: 11

Related Questions