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.
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.
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)
}
}
ViewFrame
Struct that is passed in the callback has a viewId
which is a String
you can set to whatever you want. –
Parapet 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!
© 2022 - 2024 — McMap. All rights reserved.
provideFrameChanges
, howhandleViewTreeFrameChanges
callback could identify change sources? – Canonical