ScrollView Advanced APIs: Geometry Changes, Positions, and Transitions in SwiftUI
You have spent years wrestling with UIScrollViewDelegate offsets, juggling contentOffset in scrollViewDidScroll,
and fighting coordinate-space math just to build a collapsible header or a snapping carousel. SwiftUI’s early
ScrollView was deliberately simple — too simple for most production layouts. Starting with iOS 17 and expanding
significantly in iOS 18, Apple shipped a suite of scroll APIs that finally close the gap.
This post covers the four major advanced scroll APIs — ScrollPosition, onScrollGeometryChange,
scrollTargetBehavior, and scrollTransition — with production-ready patterns and the trade-offs you need to know. We
will not cover basic ScrollView or List usage; if you need a refresher, start with
Lists and Navigation in SwiftUI.
Contents
- The Problem
- Scroll Position Binding with ScrollPosition
- Reacting to Scroll Geometry with onScrollGeometryChange
- Snapping and Paging with scrollTargetBehavior
- View Transitions with scrollTransition
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Consider a movie gallery screen where you need three common behaviors: scroll to a specific movie programmatically, show or hide a floating header based on scroll offset, and snap each movie card into place. Before iOS 17, this meant dropping into UIKit.
// The UIKit approach — coordinate-space math and delegate callbacks
final class MovieGalleryViewController:
UIViewController, UIScrollViewDelegate
{
private let scrollView = UIScrollView()
private var headerHeightConstraint: NSLayoutConstraint?
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offset = scrollView.contentOffset.y
let headerVisible = offset < 120
UIView.animate(withDuration: 0.25) {
self.headerHeightConstraint?.constant =
headerVisible ? 200 : 0
self.view.layoutIfNeeded()
}
}
func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>
) {
// Manual snapping logic...
let cardWidth: CGFloat = 300
let index = round(
targetContentOffset.pointee.x / cardWidth
)
targetContentOffset.pointee.x = index * cardWidth
}
}
This works, but it forces you out of SwiftUI’s declarative model. You lose the composability of view modifiers, you manage state imperatively, and you end up bridging two paradigms in the same screen. The new scroll APIs let you stay in SwiftUI for all of this.
Scroll Position Binding with ScrollPosition
The ScrollPosition type, introduced in iOS 17 and
refined in iOS 18, gives you a two-way binding between your state and the scroll view’s current position. You can read
which item is visible, scroll to a specific item programmatically, or even scroll to an exact pixel offset.
struct PixarMovieGallery: View {
let movies = PixarMovie.catalog
@State private var scrollPosition = ScrollPosition(id: 0)
var body: some View {
VStack {
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(movies) { movie in
MovieCard(movie: movie)
.containerRelativeFrame(.horizontal)
}
}
.scrollTargetLayout()
}
.scrollPosition($scrollPosition)
// Programmatic scroll — jump to any movie by ID
Button("Jump to Ratatouille") {
scrollPosition.scrollTo(
id: movies.first {
$0.title == "Ratatouille"
}?.id
)
}
}
}
}
The key detail is .scrollTargetLayout() on the inner stack. Without it, ScrollPosition has no anchoring targets and
cannot resolve which child is “current.” This is the most common mistake when adopting this API.
Reading the Current Position
ScrollPosition exposes the currently visible item through its viewID property. You can use this to drive dependent
UI — like updating a page indicator or highlighting a navigation item.
struct MovieCarousel: View {
let movies: [PixarMovie]
@State private var scrollPosition = ScrollPosition(id: 0)
var body: some View {
VStack {
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(movies) { movie in
MoviePosterView(movie: movie)
.containerRelativeFrame(.horizontal)
}
}
.scrollTargetLayout()
}
.scrollPosition($scrollPosition)
// Page indicator driven by scroll position
Text(currentTitle)
.font(.headline)
.animation(
.easeInOut,
value: scrollPosition.viewID(type: Int.self)
)
}
}
private var currentTitle: String {
guard let id = scrollPosition.viewID(type: Int.self),
let movie = movies.first(where: { $0.id == id })
else {
return ""
}
return movie.title
}
}
Scrolling to an Edge or Offset
Beyond item-based scrolling, ScrollPosition supports edge-based and point-based scrolling. This is useful for “scroll
to top” buttons or restoring an exact scroll position after a view reappears.
// Scroll to the top edge
scrollPosition.scrollTo(edge: .top)
// Scroll to an exact point
scrollPosition.scrollTo(point: CGPoint(x: 0, y: 240))
// Scroll to a point on a specific axis only
scrollPosition.scrollTo(x: 320)
Note: Point-based scrolling bypasses the target layout system. If you combine it with
scrollTargetBehavior(.viewAligned), the scroll view will not re-snap after a point-based scroll. Stick to ID-based scrolling when you need snapping.
Reacting to Scroll Geometry with onScrollGeometryChange
onScrollGeometryChange
arrived in iOS 18 and is the declarative replacement for scrollViewDidScroll. It lets you observe specific geometric
properties — content offset, content size, visible bounds, container size — and react only when a derived value changes.
The modifier uses a two-closure design: the first transforms raw ScrollGeometry into a value you care about, and the
second fires only when that value changes. This is deliberate — it prevents you from doing expensive work on every pixel
of scroll.
struct MovieListWithHeader: View {
@State private var isHeaderCollapsed = false
@State private var scrolledPastThreshold = false
var body: some View {
ScrollView {
LazyVStack(spacing: 12) {
MovieHeaderView(isCollapsed: isHeaderCollapsed)
ForEach(PixarMovie.catalog) { movie in
MovieRowView(movie: movie)
}
}
}
.onScrollGeometryChange(for: Bool.self) { geometry in
// Transform: extract the piece of data you need
geometry.contentOffset.y > 120
} action: { _, isPastThreshold in
// Action: only called when the Bool changes
withAnimation(.easeInOut(duration: 0.25)) {
isHeaderCollapsed = isPastThreshold
}
}
}
}
The transform closure receives a ScrollGeometry
value that exposes everything you typically need.
Working with ScrollGeometry Properties
ScrollGeometry packs several useful properties, each corresponding to something you once pulled from UIScrollView:
.onScrollGeometryChange(for: ScrollMetrics.self) { geometry in
ScrollMetrics(
contentOffset: geometry.contentOffset,
contentSize: geometry.contentSize,
containerSize: geometry.containerSize,
contentInsets: geometry.contentInsets
)
} action: { oldValue, newValue in
// React to whichever property changed
}
A practical pattern is computing a “scroll progress” value between 0 and 1 to drive animations — for example, fading a background image as the user scrolls through a movie detail screen.
struct MovieDetailView: View {
let movie: PixarMovie
@State private var scrollProgress: CGFloat = 0
var body: some View {
ScrollView {
VStack(spacing: 0) {
MovieHeroImage(movie: movie)
.opacity(1 - scrollProgress)
.frame(height: 300)
MovieDescriptionView(movie: movie)
.padding()
}
}
.onScrollGeometryChange(for: CGFloat.self) { geometry in
let maxOffset: CGFloat = 300
return min(
max(geometry.contentOffset.y / maxOffset, 0),
1
)
} action: { _, progress in
scrollProgress = progress
}
}
}
Tip: Keep the transform closure cheap. It runs on every frame during a scroll. The action closure, by contrast, only fires when the transformed value changes — that is where your state mutations and animations belong.
Snapping and Paging with scrollTargetBehavior
scrollTargetBehavior controls how
a scroll view settles after the user lifts their finger. iOS 17 introduced two built-in behaviors, and iOS 18 added the
ability to write custom ones.
Built-in Behaviors
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(PixarMovie.catalog) { movie in
MovieCard(movie: movie)
.containerRelativeFrame(.horizontal)
}
}
.scrollTargetLayout()
}
// Paging: one full "page" at a time (container-width snaps)
.scrollTargetBehavior(.paging)
.paging snaps in increments of the scroll view’s visible size — exactly like UIScrollView.isPagingEnabled.
.viewAligned snaps to the leading edge of the nearest child view inside the .scrollTargetLayout(). Use .paging for
full-screen carousels and .viewAligned for card-based layouts where cards are narrower than the screen.
ScrollView(.horizontal) {
LazyHStack(spacing: 16) {
ForEach(PixarMovie.catalog) { movie in
MovieCard(movie: movie)
.frame(width: 280, height: 400)
}
}
.scrollTargetLayout()
}
// View-aligned: snaps to the nearest child view edge
.scrollTargetBehavior(.viewAligned)
.contentMargins(.horizontal, 40)
Apple Docs:
ScrollTargetBehavior— SwiftUI
Custom Scroll Target Behavior
When neither built-in option fits — say you want snapping every 200 points regardless of child size — you can conform to
ScrollTargetBehavior and implement your own settling logic.
struct FixedIntervalSnapping: ScrollTargetBehavior {
let interval: CGFloat
func updateTarget(
_ target: inout ScrollTarget,
context: TargetContext
) {
// Round the proposed target to the nearest interval
let proposedX = target.rect.origin.x
let snappedX = round(proposedX / interval) * interval
target.rect.origin.x = snappedX
}
}
// Usage
ScrollView(.horizontal) {
movieContent
}
.scrollTargetBehavior(FixedIntervalSnapping(interval: 200))
The updateTarget method receives the scroll view’s proposed resting position and lets you mutate it. The context
parameter provides viewport size and velocity if you need physics-aware settling.
View Transitions with scrollTransition
scrollTransition
applies visual transformations to individual views as they enter and leave the visible region of a scroll view. Think of
it as the declarative version of computing per-cell transforms in
UICollectionViewFlowLayout.layoutAttributesForElements(in:).
ScrollView(.horizontal) {
LazyHStack(spacing: 20) {
ForEach(PixarMovie.catalog) { movie in
MoviePosterView(movie: movie)
.containerRelativeFrame(.horizontal)
.scrollTransition(.animated) { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.3)
.scaleEffect(
phase.isIdentity ? 1 : 0.85
)
.rotationEffect(
.degrees(
phase.isIdentity
? 0
: phase.value * 10
)
)
}
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
The phase parameter tells you where the view sits relative to the visible region. phase.isIdentity is true when
the view is fully visible. As a view scrolls toward an edge, phase.value transitions continuously from 0 toward -1
(leading edge) or +1 (trailing edge). You use this to drive any visual property — opacity, scale, blur, rotation,
offset.
Controlling the Transition Threshold
By default, the transition begins as soon as a view enters or exits the visible bounds. You can adjust this with the
topLeading and bottomTrailing threshold parameters to delay the effect or make it more aggressive.
.scrollTransition(
topLeading: .interactive(timingCurve: .easeInOut),
bottomTrailing: .interactive(timingCurve: .easeInOut)
) { content, phase in
content
.opacity(phase.isIdentity ? 1 : 0.5)
.offset(y: phase.isIdentity ? 0 : phase.value * 30)
}
Tip: Combine
scrollTransitionwithscrollTargetBehavior(.viewAligned)to get a polished carousel where only the centered card is fully visible and surrounding cards are dimmed and scaled down — like the iTunes Store album view.
Advanced Usage
Combining Position, Geometry, and Transitions
The real power emerges when you layer these APIs. Here is a production-grade movie browser that uses all four scroll
APIs together: ScrollPosition for programmatic navigation, onScrollGeometryChange for a collapsing navigation bar,
scrollTargetBehavior for card snapping, and scrollTransition for visual polish.
struct PixarMovieBrowser: View {
let movies = PixarMovie.catalog
@State private var scrollPosition = ScrollPosition(id: 0)
@State private var showMiniTitle = false
var body: some View {
NavigationStack {
ScrollView(.horizontal) {
LazyHStack(spacing: 20) {
ForEach(movies) { movie in
MovieFeatureCard(movie: movie)
.containerRelativeFrame(.horizontal)
.scrollTransition(.animated) {
content, phase in
content
.opacity(
phase.isIdentity ? 1 : 0.4
)
.scaleEffect(
phase.isIdentity ? 1 : 0.9
)
}
}
}
.scrollTargetLayout()
}
.scrollPosition($scrollPosition)
.scrollTargetBehavior(.viewAligned)
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.x > 50
} action: { _, scrolled in
withAnimation { showMiniTitle = scrolled }
}
.navigationTitle(
showMiniTitle
? currentMovieTitle
: "Pixar Collection"
)
.toolbar {
ToolbarItem(placement: .bottomBar) {
ScrollPositionControls(
position: $scrollPosition,
movies: movies
)
}
}
}
}
private var currentMovieTitle: String {
guard let id = scrollPosition.viewID(type: Int.self),
let movie = movies.first(where: { $0.id == id })
else {
return "Pixar Collection"
}
return movie.title
}
}
Scroll Position Persistence
If your screen disappears and reappears — for example, inside a TabView — you likely want to restore the scroll
position. Because ScrollPosition is a plain state value, it survives view identity changes automatically. For
longer-lived persistence (across app launches), store the item ID in @AppStorage or @SceneStorage and reconstruct
the ScrollPosition on appear.
@SceneStorage("selectedMovieID") private var savedMovieID: Int?
@State private var scrollPosition = ScrollPosition(id: 0)
var body: some View {
ScrollView {
// content...
}
.scrollPosition($scrollPosition)
.onAppear {
if let savedID = savedMovieID {
scrollPosition.scrollTo(id: savedID)
}
}
.onChange(of: scrollPosition.viewID(type: Int.self)) {
_, newID in
savedMovieID = newID
}
}
Scroll Visibility with onScrollVisibilityChange
Introduced alongside onScrollGeometryChange in iOS 18,
onScrollVisibilityChange
fires when a view crosses a visibility threshold inside a scroll view. This is useful for analytics tracking, lazy media
loading, or triggering animations when a view appears on screen.
ForEach(movies) { movie in
MovieRowView(movie: movie)
.onScrollVisibilityChange(threshold: 0.5) { isVisible in
if isVisible {
analyticsService.trackImpression(movie.id)
}
}
}
The threshold parameter (0.0 to 1.0) controls how much of the view must be visible before the callback fires. A
threshold of 0.5 means the callback triggers when at least half the view is visible.
Performance Considerations
These APIs are built on the scroll view’s layout engine and are designed to be efficient, but there are patterns that can hurt performance.
onScrollGeometryChange transform closures run per frame. Keep them pure and fast — no heap allocations, no string
formatting, no accessing @State properties other than through the closure’s capture. The action closure only fires on
value changes, so move all heavy work there.
scrollTransition modifiers apply to every visible view on every frame. If you apply complex transformations to
dozens of views in a LazyVStack, you will notice frame drops. Profile with Instruments’ SwiftUI template (see
SwiftUI Performance) if your transition affects more than 5-6 simultaneously
visible views.
LazyHStack and LazyVStack are critical. If you use HStack or VStack instead, the scroll view loads all
children at once. With scrollTransition, this means computing visual effects for off-screen views — wasted GPU time.
Always use lazy stacks when combining scroll APIs with large data sets.
scrollTargetLayout has a cost. It forces the scroll view to measure child positions for snapping. This is
negligible for a carousel of 10-20 items, but if you apply it to a LazyVStack with thousands of rows, the layout pass
becomes expensive. In that case, consider whether you actually need snapping, or if free-scrolling with
onScrollGeometryChange is sufficient.
Apple Docs:
ScrollView— SwiftUI
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Programmatic “scroll to item” | Use ScrollPosition with .scrollPosition(_:). |
| Collapsible headers, parallax | Use onScrollGeometryChange. |
| Full-screen carousels | Use .scrollTargetBehavior(.paging). |
| Card carousels (partial views) | Use .scrollTargetBehavior(.viewAligned). |
| Per-item enter/exit animations | Use .scrollTransition. |
| Infinite scroll / pagination | Prefer onScrollVisibilityChange on a sentinel view. |
| Custom snapping intervals | Conform to ScrollTargetBehavior. |
| Simple lists with selection | Stick with List and NavigationStack. |
| Custom refresh controls | Drop to UIScrollView via UIViewRepresentable. |
Summary
ScrollPositionprovides a two-way binding for programmatic scrolling — by item ID, by edge, or by exact point offset. Pair it with.scrollTargetLayout()for item-level tracking.onScrollGeometryChangeis the declarative replacement forscrollViewDidScroll. Its two-closure design separates per-frame geometry reading from state mutation, keeping your scroll handling efficient.scrollTargetBehaviorgives you paging and view-aligned snapping out of the box, with a protocol for custom snapping logic when neither built-in option fits.scrollTransitionapplies per-view visual effects driven by scroll position — opacity, scale, rotation, offset — turning basic scroll views into polished carousels without UIKit interop.- These APIs compose well together, but each adds a layout or rendering cost. Profile with Instruments and use lazy stacks to keep frame rates smooth.
For animating the transitions and effects you build with these scroll APIs, see SwiftUI Animations for a deep dive into implicit, explicit, and keyframe-based animation techniques.