SwiftUI ScrollView with content frame update closure
Asked Answered
P

2

1

I want to have a ScrollView where you can be aware of the content frame changes as the user scrolls (similar to didScroll delegate in UIKit UIScrollView).

With this, you can then perform layout changes based on the scroll behavior.

Parapet answered 16/10, 2019 at 14:55 Comment(0)
P
6

I managed to come with a nice solution for this problem by making use of View Preferences as a method to notify layout information upstream in the View Hierarchy.

For a very detail explanation of how View Preferences work, I will suggest reading this 3 articles series on the topic by kontiki

For my solution, I implemented two ViewModifiers: one to make a view report changes on its layout using anchor preferences, and the second to allow a View to handle updates to frames on views on its subtree.

To do this, we first define a Struct to carry the identifiable frame information upstream:

/// Represents the `frame` of an identifiable view as an `Anchor`
struct ViewFrame: Equatable {

    /// A given identifier for the View to faciliate processing
    /// of frame updates
    let viewId : String


    /// An `Anchor` representation of the View
    let frameAnchor: Anchor<CGRect>

    // Conformace to Equatable is required for supporting
    // view udpates via `PreferenceKey`
    static func == (lhs: ViewFrame, rhs: ViewFrame) -> Bool {
        // Since we can currently not compare `Anchor<CGRect>` values
        // without a Geometry reader, we return here `false` so that on
        // every change on bounds an update is issued.
        return false
    }
}

and we define a Struct conforming to PreferenceKey protocol to hold the view tree preference changes:

/// A `PreferenceKey` to provide View frame updates in a View tree
struct FramePreferenceKey: PreferenceKey {
    typealias Value = [ViewFrame] // The list of view frame changes in a View tree.

    static var defaultValue: [ViewFrame] = []

    /// When traversing the view tree, Swift UI will use this function to collect all view frame changes.
    static func reduce(value: inout [ViewFrame], nextValue: () -> [ViewFrame]) {
        value.append(contentsOf: nextValue())
    }
}

Now we can define the ViewModifiers I mentioned:

Make a view report changes on its layout:

This just adds a transformAnchorPreference modifier to the View with a handler that simply constructs a ViewFrame instance with current frame Anchor value and appends it to the current value of the FramePreferenceKey:

/// Adds an Anchor preference to notify of frame changes
struct ProvideFrameChanges: ViewModifier {
    var viewId : String

    func body(content: Content) -> some View {
        content
            .transformAnchorPreference(key: FramePreferenceKey.self, value: .bounds) {
                $0.append(ViewFrame(viewId: self.viewId, frameAnchor: $1))
            }
    }
}

extension View {

    /// Adds an Anchor preference to notify of frame changes
    /// - Parameter viewId: A `String` identifying the View
    func provideFrameChanges(viewId : String) -> some View {
        ModifiedContent(content: self, modifier: ProvideFrameChanges(viewId: viewId))
    }
}

Provide an update handler to a view for frame changes on its subtree:

This adds a onPreferenceChange modifier to the View, where the list of frame Anchors changes are transformed into frames(CGRect) on the view's coordinate space and reported as a dictionary of frame updates keyed by the view ids:

typealias ViewTreeFrameChanges = [String : CGRect]

/// Provides a block to handle internal View tree frame changes
/// for views using the `ProvideFrameChanges` in own coordinate space.
struct HandleViewTreeFrameChanges: ViewModifier {
    /// The handler to process Frame changes on this views subtree.
    /// `ViewTreeFrameChanges` is a dictionary where keys are string view ids
    /// and values are the updated view frame (`CGRect`)
    var handler : (ViewTreeFrameChanges)->Void

    func body(content: Content) -> some View {
        GeometryReader { contentGeometry in
            content
                .onPreferenceChange(FramePreferenceKey.self) {
                    self._updateViewTreeLayoutChanges($0, in: contentGeometry)
                }
        }
    }

    private func _updateViewTreeLayoutChanges(_ changes : [ViewFrame], in geometry : GeometryProxy) {
        let pairs = changes.map({ ($0.viewId, geometry[$0.frameAnchor]) })
        handler(Dictionary(uniqueKeysWithValues: pairs))
    }
}

extension View {
    /// Adds an Anchor preference to notify of frame changes
    /// - Parameter viewId: A `String` identifying the View
    func handleViewTreeFrameChanges(_ handler : @escaping (ViewTreeFrameChanges)->Void) -> some View {
        ModifiedContent(content: self, modifier: HandleViewTreeFrameChanges(handler: handler))
    }
}

LET'S USE IT:

I will illustrate the usage with an example:

Here I will get notifications of a Header View frame changes inside a ScrollView. Since this Header View is on the top of the ScrollView content, the reported frame changes on the frame origin are equivalent to the contentOffset changes of the ScrollView

enum TestEnum : String, CaseIterable, Identifiable {
    case one, two, three, four, five, six, seven, eight, nine, ten

    var id: String {
        rawValue
    }
}

struct TestView: View {
    private let _listHeaderViewId = "testView_ListHeader"

    var body: some View {
        ScrollView {
            // Header View
            Text("This is some Header")
                .provideFrameChanges(viewId: self._listHeaderViewId)

            // List of test values
            ForEach(TestEnum.allCases) {
                Text($0.rawValue)
                    .padding(60)
            }
        }
            .handleViewTreeFrameChanges {
                self._updateViewTreeLayoutChanges($0)
            }
    }

    private func _updateViewTreeLayoutChanges(_ changes : ViewTreeFrameChanges) {
        print(changes)
    }
}
Parapet answered 16/10, 2019 at 14:55 Comment(2)
It's great. I have a question: if multiple views have provideFrameChanges, how handleViewTreeFrameChanges callback could identify change sources?Canonical
@LiangWang, sorry for late answer. Basically the ViewFrame Struct that is passed in the callback has a viewId which is a String you can set to whatever you want.Parapet
S
0

There is an elegant solution to this problem, Soroush Khanlou already posted a Gist of it so I won't copy-paste it. You can find it here and yeah...Shame that it isn't a part of the framework yet!

Stung answered 28/9, 2020 at 16:26 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.