ScrollView takes content in a ViewBuilder closure, with parameters for the axis and the
indicator visibility. ScrollViewReader (iOS 14+) adds programmatic scrolling, and a
PreferenceKey plus GeometryReader recovers the content offset the API does not expose.
Scroll to a Position
ScrollViewReader finds the first scroll view among its children and hands its closure a
ScrollViewProxy. Call scrollTo(_:anchor:) with the id of any child to jump there.
ScrollViewReader { scrollView in
ScrollView {
Button("Scroll to bottom") {
withAnimation {
scrollView.scrollTo(99, anchor: .center)
}
}
ForEach(0..<100) { index in
Text(String(index))
.id(index)
}
}
}
Each target view needs an explicit .id(...); the proxy matches on that value. The anchor
aligns the found view within the viewport. scrollTo animates when wrapped in
withAnimation. ScrollViewReader also works with List.
Read the Content Offset
The scroll view does not report its offset, so publish it yourself through a preference. A
zero-sized GeometryReader at the top of the content reads its own origin in the scroll
view's named coordinate space, which is the content offset.
private struct ScrollOffsetPreferenceKey: PreferenceKey {
static var defaultValue: CGPoint = .zero
static func reduce(value: inout CGPoint, nextValue: () -> CGPoint) {}
}
ScrollView(axes, showsIndicators: showsIndicators) {
GeometryReader { geometry in
Color.clear.preference(
key: ScrollOffsetPreferenceKey.self,
value: geometry.frame(in: .named("scrollView")).origin
)
}
.frame(width: 0, height: 0)
content
}
.coordinateSpace(name: "scrollView")
.onPreferenceChange(ScrollOffsetPreferenceKey.self, perform: offsetChanged)
Wrap this in your own ScrollView<Content> type that mirrors the system initializer and adds
an offsetChanged: (CGPoint) -> Void closure. Callers keep the familiar API and gain the
offset stream for sticky headers, parallax, or scroll-linked effects.
Gotchas
- The
GeometryReaderprobe must sit inside the scroll content, before it, and be pinned to.frame(width: 0, height: 0)so it does not disturb the layout. - The frame must be read in the named coordinate space, not
.global, or the value drifts with the scroll view's own position on screen. - On iOS 17+, prefer the native
scrollPositionandonScrollGeometryChangeAPIs; this preference-key recipe is the back-deployable version.