Replace Scroll Geometry Hacks With SwiftUI's iOS 18 Scroll APIs
There used to be one standard answer when a SwiftUI screen needed scroll offset: hide a GeometryReader somewhere in the content, write the frame into a PreferenceKey, read the preference at the parent, and hope the values did not jitter too much when the layout changed.
That technique still works, but it was always a workaround. The scroll view knew its content offset, visible rect, container size, and content size. We just did not have a direct SwiftUI API for reading the parts of that state we actually cared about.
iOS 18 changes that. SwiftUI now has onScrollGeometryChange, onScrollVisibilityChange, onScrollTargetVisibilityChange, and the richer ScrollPosition type. Together they cover most of the places where I used to reach for preference keys: hiding toolbar chrome, showing “back to top” controls, starting pagination near the bottom of a feed, and reacting when a row moves on or off screen.
These APIs are available on iOS 18, iPadOS 18, macOS 15, tvOS 18, watchOS 11, and visionOS 2. If your app supports older OS versions, keep the old preference-key or ScrollViewReader code behind an availability boundary and let the iOS 18 path be simpler.
Read the value you need
onScrollGeometryChange gives you a ScrollGeometry, but it does not ask you to store the whole thing. Instead, you transform the geometry into an Equatable value, and SwiftUI calls your action only when that value changes.
That shape is important. Scroll views can update many times during a drag. If all you need is “has the user scrolled far enough to show a button?”, turn the geometry into a Bool.
import SwiftUI
struct ArticleFeedView: View {
let articles: [Article]
@State private var showBackToTop = false
@State private var position = ScrollPosition(edge: .top)
var body: some View {
ScrollView {
LazyVStack(spacing: 16) {
ForEach(articles) { article in
ArticleCard(article: article)
.id(article.id)
}
}
.scrollTargetLayout()
.padding()
}
.scrollPosition($position)
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.visibleRect.minY > 240
} action: { _, isPastThreshold in
withAnimation(.snappy) {
showBackToTop = isPastThreshold
}
}
.overlay(alignment: .bottomTrailing) {
if showBackToTop {
Button("Top", systemImage: "arrow.up") {
position.scrollTo(edge: .top)
}
.buttonStyle(.borderedProminent)
.padding()
}
}
}
}
The old preference-key version usually reported a raw CGFloat every time the offset moved. This version reports only the semantic state that the UI needs. The button appears after the user scrolls past 240 points, and the ScrollPosition binding gives the button a direct way to move the scroll view back to the top.
You can still return a richer value when the feature needs it. Keep that value small and Equatable.
struct FeedScrollState: Equatable {
var isAwayFromTop: Bool
var isNearBottom: Bool
}
struct PagedFeedView: View {
@StateObject private var model = FeedModel()
@State private var showJumpButton = false
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(model.items) { item in
FeedRow(item: item)
}
}
.padding(.vertical)
}
.onScrollGeometryChange(for: FeedScrollState.self) { geometry in
let preloadLine = geometry.contentSize.height - 500
return FeedScrollState(
isAwayFromTop: geometry.visibleRect.minY > 160,
isNearBottom: geometry.visibleRect.maxY > preloadLine
)
} action: { oldValue, newValue in
showJumpButton = newValue.isAwayFromTop
if newValue.isNearBottom && !oldValue.isNearBottom {
Task {
await model.loadNextPageIfNeeded()
}
}
}
}
}
That oldValue check matters. Without it, you may start the same pagination request repeatedly while the user hovers near the bottom of the feed. A short first page can also be “near bottom” during initial layout, so the model should still defend itself with isLoading and hasMorePages checks.
Prefer visibility for row-level work
If the question is about one child view, do not calculate global scroll geometry yourself. Attach onScrollVisibilityChange to the view that matters.
The modifier reports whether the view has crossed a visibility threshold inside its parent scroll view. The default threshold is 0.5, so the action fires when more than half the view becomes visible, and again when it falls below that threshold. It can also run when the view appears already past the threshold. You can lower the threshold for early work like pagination or video preloading.
struct InfiniteFeedView: View {
@StateObject private var model = FeedModel()
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(model.items) { item in
FeedRow(item: item)
}
if model.hasMorePages {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onScrollVisibilityChange(threshold: 0.1) { isVisible in
guard isVisible else { return }
Task {
await model.loadNextPageIfNeeded()
}
}
}
}
}
}
}
This is much easier to reason about than a sentinel GeometryReader at the bottom of a stack. The sentinel is the ProgressView, and the trigger is “more than 10 percent visible.” The view model can keep isLoading and hasMorePages checks in one place.
The same pattern works for media rows:
VideoPlayer(player: player)
.onScrollVisibilityChange(threshold: 0.3) { isVisible in
if isVisible {
player.play()
} else {
player.pause()
}
}
That code says what the feature means. It does not care where the row is in the global coordinate space, how tall the navigation bar is, or whether the containing screen added safe-area padding.
If you need the set of visible rows instead of one view’s Bool, use onScrollTargetVisibilityChange(idType:threshold:_:) on the scroll view and mark the target layout with scrollTargetLayout().
Use ScrollPosition for programmatic control
ScrollViewReader is still useful, but ScrollPosition is a better fit when scroll position is part of your view state. It can represent a view identity, a concrete point, or an edge. You bind it to the scroll view with scrollPosition(_:anchor:), then mutate the position when the UI needs to move.
For identity-based scrolling, mark the target layout with scrollTargetLayout().
struct MessageListView: View {
let messages: [Message]
@State private var position = ScrollPosition(idType: Message.ID.self)
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(messages) { message in
MessageBubble(message: message)
.id(message.id)
}
}
.scrollTargetLayout()
.padding()
}
.scrollPosition($position, anchor: .bottom)
.toolbar {
Button("Latest", systemImage: "arrow.down") {
if let lastID = messages.last?.id {
position.scrollTo(id: lastID, anchor: .bottom)
}
}
}
}
}
The binding can also tell you which matching target is currently selected by the scroll position:
.onChange(of: position) { _, newPosition in
let visibleMessageID = newPosition.viewID(type: Message.ID.self)
model.visibleMessageID = visibleMessageID
}
That is a nice upgrade for feeds that preserve reading position, sidebars that highlight the current section, or chat screens that want to decide whether new messages should auto-scroll. The anchor influences which target updates the binding and how scrollTo aligns it. You can keep the user’s scroll location as ordinary SwiftUI state instead of asking a proxy to perform one-off commands.
What to keep from the old approach
The main lesson from the preference-key era still applies: do not make scroll offset a global source of truth unless the feature truly needs it. Raw offsets are noisy. They change during layout, rotation, keyboard presentation, safe-area changes, and dynamic type adjustments.
Use onScrollGeometryChange when the parent scroll view needs a derived value like “near the bottom” or “past the header.” Use onScrollVisibilityChange when a row, footer, or media view needs to react to its own visibility. Use onScrollTargetVisibilityChange when the scroll view needs a list of visible target IDs. Use ScrollPosition when the app needs to read or set where the scroll view is positioned.
That split keeps scroll-aware screens small. The code reads like UI state again, not like a measurement system taped to the back of the view hierarchy.
Further reading:
- What’s new in SwiftUI: https://developer.apple.com/videos/play/wwdc2024/10144/
- onScrollGeometryChange documentation: https://developer.apple.com/documentation/swiftui/view/onscrollgeometrychange%28for%3Aof%3Aaction%3A%29
- onScrollVisibilityChange documentation: https://developer.apple.com/documentation/swiftui/view/onscrollvisibilitychange%28threshold%3A_%3A%29
- onScrollTargetVisibilityChange documentation: https://developer.apple.com/documentation/swiftui/view/onscrolltargetvisibilitychange%28idtype%3Athreshold%3A_%3A%29
- ScrollPosition documentation: https://developer.apple.com/documentation/swiftui/scrollposition