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

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 scrollTransition with scrollTargetBehavior(.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)

ScenarioRecommendation
Programmatic “scroll to item”Use ScrollPosition with .scrollPosition(_:).
Collapsible headers, parallaxUse onScrollGeometryChange.
Full-screen carouselsUse .scrollTargetBehavior(.paging).
Card carousels (partial views)Use .scrollTargetBehavior(.viewAligned).
Per-item enter/exit animationsUse .scrollTransition.
Infinite scroll / paginationPrefer onScrollVisibilityChange on a sentinel view.
Custom snapping intervalsConform to ScrollTargetBehavior.
Simple lists with selectionStick with List and NavigationStack.
Custom refresh controlsDrop to UIScrollView via UIViewRepresentable.

Summary

  • ScrollPosition provides 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.
  • onScrollGeometryChange is the declarative replacement for scrollViewDidScroll. Its two-closure design separates per-frame geometry reading from state mutation, keeping your scroll handling efficient.
  • scrollTargetBehavior gives you paging and view-aligned snapping out of the box, with a protocol for custom snapping logic when neither built-in option fits.
  • scrollTransition applies 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.