Getting and setting ScrollView scroll offset in SwiftUI

Trying to find a way how to get scroll view's scroll offset using GeometryReader. The view hierarchy I am after is Scrollview with custom view. Custom view (TileCollectionView) defines its size itself (it is custom collection/tile view with fixed number of columns).

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Scoll location")
            ScrollView([.horizontal, .vertical]) {
                GeometryReader { geometry -> TileCollectionView in
                    print(geometry.frame(in: .global))
                    return TileCollectionView()
                }
            }
        }
        
    }
}

struct TileCollectionView: View {
    var body: some View {
        VStack(spacing: 1) {
            ForEach(0..<30) { seriesIndex in
                HStack(spacing: 1) {
                    ForEach(0..<8) { columnIndex in
                        TileView()
                    }
                }
            }
        }
    }
}

struct TileView: View {
    var body: some View {
        Color.blue.frame(width: 128, height: 128, alignment: .center).fixedSize()
    }
}

What happens with this code is GeometryReader making the view size matching with ScollView size and it is not possible to scroll from one edge to another edge of the TileCollectionView. Wondering how to setup the view so that ScrollView's content size matches with TileCollectionView's size and I am able to read the current scroll offset.


In addition, it seems like it is impossible to scroll the ScrollView to the bottom of the document. No API for that (can't find any allowing to change scroll offset programmatically)?


This code cand be tested by just creating SwiftUI template project and replacing ContentView with code shown here.

I've got a MacOS alternative implementation of SwiftUI ScrollView which adds support for a scrollToVisible(_ shouldScroll: Bool) style view modifier that does the trick. I'll post later this evening when I'm back at my machine. It should be reasonably easy to adapt for iOS UIScrollView if that's what you need instead.


The idea behind it is that this NSViewRepresentable observes a ScrollToVisibleRects preference key I define, which is set for subviews wrapped using a ScrollToVisible view modifier.

//
//  ScrollView.swift
//  Components
//
//  Created by Mark Onyschuk on 2019-09-02.
//  Copyright © 2019 Mark Onyschuk. All rights reserved.
//

import SwiftUI

// MARK: - ScrollView
/// A `SwiftUI.ScrollView` replacement with support for a new`View.scrollToVisible(_:)` content modifier.
public struct ScrollView<Content>: NSViewRepresentable where Content: View {
    private var axes: Axis.Set
    private var showsIndicators: Bool
    
    private var content: Content
    
    /// Initializes the ScrollView.
    /// - Parameter axes: scrolling axes
    /// - Parameter showsIndicators: `true` if scrolling indicators should be shown
    /// - Parameter content: scrollable content
    public init(_ axes: Axis.Set = .vertical, showsIndicators: Bool = true, @ViewBuilder content: () -> Content) {
        self.axes = axes
        self.showsIndicators = showsIndicators

        self.content = content()
    }

    // NSViewRepresentable
    public typealias NSViewType = NSScrollView

    public func makeNSView(context: NSViewRepresentableContext<ScrollView>) -> NSScrollView {
        let view = NSScrollView(frame: .zero)
        view.translatesAutoresizingMaskIntoConstraints = false

        view.hasVerticalScroller = axes.contains(.vertical)
        view.hasHorizontalScroller = axes.contains(.horizontal)
        
        if showsIndicators {
            view.autohidesScrollers = true
        } else {
            // stock SwiftUI.ScrollView offers no way to force scrollers invisible
            view.verticalScroller?.alphaValue = 0
            view.horizontalScroller?.alphaValue = 0
        }

        let document = NSHostingView(rootView: self.observingContent(in: view))
        document.translatesAutoresizingMaskIntoConstraints = false

        view.documentView = document
        
        let clipView = view.contentView
        
        NSLayoutConstraint.activate([
            document.topAnchor.constraint(equalTo: clipView.topAnchor),
            document.leadingAnchor.constraint(equalTo: clipView.leadingAnchor),
        ])
        
        // debatable whether to do this - a geometry reader is probably a better bet...
        switch axes {
        case [.vertical]:
            NSLayoutConstraint.activate([
                document.widthAnchor.constraint(greaterThanOrEqualTo: clipView.widthAnchor)
            ])
        case [.horizontal]:
            NSLayoutConstraint.activate([
                document.heightAnchor.constraint(greaterThanOrEqualTo: clipView.heightAnchor)
            ])
        default:
            break
        }
        return view
    }
    
    public func updateNSView(_ view: NSScrollView, context: NSViewRepresentableContext<ScrollView>) {
        guard let document = view.documentView as? NSHostingView<AnyView> else {
            return
        }
        document.rootView = self.observingContent(in: view)
        print(document.fittingSize)
    }

    /// Returns content modified to observe and reveal views marked to be scrolled to visible.
    /// - Parameter view: the enclosing scroll view
    private func observingContent(in view: NSScrollView) -> AnyView {
        var prevRect = CGRect.zero
        return AnyView(content
            .coordinateSpace(name: ENCLOSING_SCROLLVIEW)
            .onPreferenceChange(ScrollToVisibleKey.self) {
                [weak view] rects in
                
                let nextRect = rects.reduce(CGRect.null, { $0.union($1) })
                
                if nextRect != prevRect, nextRect != .null {
                    prevRect = nextRect
                    view?.documentView?.scrollToVisible(nextRect)
                }
        })
    }
}

// MARK: - ScrollToVisible Key
private struct ScrollToVisibleKey: PreferenceKey {
    typealias Value = [CGRect]
        
    static var defaultValue: [CGRect] = []
    static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) {
        value += nextValue()
    }
}

// MARK: - ScrollToVisible Modifier
private struct ScrollToVisible: ViewModifier {
    var shouldScroll: Bool
    
    func body(content: Content) -> some View {
        return GeometryReader {
            geom in
            content.preference(
                key: ScrollToVisibleKey.self,
                value: self.shouldScroll
                    ? [geom.frame(in: .named(ENCLOSING_SCROLLVIEW))]
                    : []
            )
        }
    }
}

extension View {
    /// A modifier that identifies Views which, if displayed within a `ScrollView` should
    /// be scrolled to visible.
    /// - Parameter shouldScroll: `true` if the view should be scrolled to visible in its enclosing scroll view
    public func scrollToVisible(_ shouldScroll: Bool) -> some View {
        return self.modifier(ScrollToVisible(shouldScroll: shouldScroll))
    }
}

// MARK: - Constants
private let ENCLOSING_SCROLLVIEW = "enclosingScrollView"

Experimenting with a CollectionView workalike, I found that my modifier, if wrapping content directly with a geometry reader, may be called repeatedly as layout occurs, eventually leading to CGFloat overflow. The following change to the modifier stabilizes things:


// MARK: - ScrollToVisible Modifier
private struct ScrollToVisible: ViewModifier {
    var shouldScroll: Bool
    
    func body(content: Content) -> some View {
        return content.background(
            GeometryReader {
                geom in
                Color.clear.preference(
                    key: ScrollToVisibleKey.self,
                    value: self.shouldScroll
                        ? [geom.frame(in: .named(ENCLOSING_SCROLLVIEW))]
                        : []
                )
            }
        )
    }
}

I ended up wrapping UIScrollView with UIViewRepresentable. Hopefully next SwiftUI versions will add more capabilities to ScrollView. Thank you for sharing AppKit version.

How did you handle content update in the following function?

func updateUIViewController(_ viewController: UIScrollViewViewController, context: Context)


The content changes frequently as new items come in (in SwiftUI I have a ForEach that iterates through an array in my BindableObject model). Since the content of my UIScrollView is also SwiftUI, I basically have to remove the subview and add the new one back, which leads to very poor performances.


UIScrollViewViewController is a UIViewController that loads the UIScrollView. The content of the UIScrollView is a UIHostingController.

public func updateNSView(_ view: NSScrollView, context: NSViewRepresentableContext<ScrollView>) {  
        guard let document = view.documentView as? NSHostingView<AnyView> else {  
            return  
        }  
        document.rootView = self.observingContent(in: view)  
        print(document.fittingSize)  
    } 


Isn't this leading to very poor performances?

Getting and setting ScrollView scroll offset in SwiftUI
 
 
Q