← All shorts
10iOS 18+2 min read

onScrollGeometryChange: Scroll Offset Without GeometryReader Hacks

Observe raw scroll offset directly on the ScrollView, no PreferenceKey plumbing required.

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 PreferenceKey boilerplate.
  • ScrollGeometry exposes contentOffset, contentSize, contentInsets, and visibleRect together — one value instead of several ad-hoc preferences.
  • The action closure gets both oldValue and newValue, 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.

swiftuiios18scrolling

Related shorts