Collapse a header as the user scrolls. Fade in a "back to top" button. Both need a raw offset, not an item id like scrollPosition(id:) hands you.
Getting that offset used to mean dropping a zero-height GeometryReader inside the scroll content and piping its frame back out through a PreferenceKey.
#The old way
struct ScrollOffsetKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
struct OldOffsetTracker: View {
@State private var offset: CGFloat = 0
var body: some View {
ScrollView {
GeometryReader { geometry in
Color.clear
.preference(
key: ScrollOffsetKey.self,
value: geometry.frame(in: .named("scroll")).minY
)
}
.frame(height: 0)
Text("Content")
}
.coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetKey.self) { offset = $0 }
}
}
#The new way
One modifier, attached to the ScrollView.
import SwiftUI
struct CollapsingHeaderFeed: View {
@State private var headerOpacity: Double = 1
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(1...30, id: \.self) { index in
Text("Row \(index)")
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
}
}
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { oldValue, newValue in
headerOpacity = max(0, 1 - newValue / 120)
}
.safeAreaInset(edge: .top) {
Text("Latest Activity")
.font(.title2.bold())
.padding()
.opacity(headerOpacity)
.background(.bar)
}
}
}
#Why it matters
- No named coordinate space. No zero-height spacer view. No
PreferenceKeyboilerplate. ScrollGeometryexposescontentOffset,contentSize,contentInsets, andvisibleRecttogether. One value instead of several ad-hoc preferences.- The
actionclosure gets botholdValueandnewValue, so deltas like scroll direction or velocity-adjacent logic come for free, with no previous value to stash yourself. - The action only re-fires when the extracted value actually changes. Extract something coarse like a rounded offset and the redundant updates disappear.
#Gotcha
Attach .onScrollGeometryChange to the ScrollView itself. Not to a parent VStack, and not to a container that happens to wrap one. Put it on an ancestor and the modifier compiles fine but never fires. There's no scroll geometry to observe from outside the scroll view's own hierarchy.