The onScrollGeometryChange view modifier reads the live geometry of a ScrollView while the
user scrolls, which ScrollPosition cannot do: its readable properties go nil the moment a
gesture takes over. ScrollGeometry carries the content offset, bounds, container size, visible
rect, content insets, and content size.
Tracking the Content Offset
The modifier takes three parts: the type you reduce the geometry to, a transformation closure that extracts it, and an action closure receiving the old and new values.
@State private var offsetY: CGFloat = 0
ScrollView {
ForEach(1..<100, id: \.self) { number in
Text(verbatim: number.formatted())
.id(number)
}
}
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { oldValue, newValue in
if oldValue != newValue {
offsetY = newValue
}
}
The explicit type exists for performance: geometry changes on every frame of a scroll, so SwiftUI re-runs the action only when the transformed value changes, not on every raw geometry change. Extract the smallest value that answers your question.
Combining Properties
To watch more than one property, return one equatable struct from the transformation.
struct ScrollData: Equatable {
let size: CGSize
let visible: CGRect
}
@State private var scrollData = ScrollData(size: .zero, visible: .zero)
ScrollView { /* content */ }
.onScrollGeometryChange(for: ScrollData.self) { geometry in
ScrollData(size: geometry.contentSize, visible: geometry.visibleRect)
} action: { oldValue, newValue in
if oldValue != newValue {
scrollData = newValue
}
}
Division of Labor
Use ScrollPosition to drive the scroll view from code, and onScrollGeometryChange to observe
what the user does with it. The two bind to the same scroll view without conflict.