← 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.

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 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 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.

swiftuiios18scrolling

Related shorts