Collapsing a header as the user scrolls, or fading in a "back to top" button, needs a raw offset — not an item id like scrollPosition(id:) gives you. Getting that offset used to mean dropping a zero-height GeometryReader inside the scroll content and piping its frame 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
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 coordinate space naming, no zero-height spacer view, no
PreferenceKeyboilerplate. ScrollGeometryexposescontentOffset,contentSize,contentInsets, andvisibleRecttogether — one value instead of several ad-hoc preferences.- The
actionclosure gets botholdValueandnewValue, so you can compute deltas (scroll direction, velocity-adjacent logic) without storing a previous value yourself. - Because the transform only re-fires the action when the extracted value actually changes, you can extract something coarse (like a rounded offset) to cut down on redundant updates.
#Gotcha
.onScrollGeometryChange has to be attached directly to the ScrollView itself, not to a parent VStack or container that merely happens to wrap one. Put it on an ancestor view instead of the scroll view, and the modifier compiles fine and simply never fires — there's no scroll geometry to observe from outside the scroll view's own hierarchy.