← All shorts
07iOS 17+2 min read

scrollPosition(id:): Reading and Driving Scroll Position

A two-way binding to the currently scrolled item — jump to content and observe scroll position with one API.

ScrollViewReader lets you drive a scroll view (proxy.scrollTo), but it never tells you where the scroll view currently is. Anything reactive — a "you are here" indicator, syncing two lists — needed a separate GeometryReader/PreferenceKey rig bolted on top.

#The old way

struct OldPhotoScroller: View {
    let photos: [Photo] = (1...20).map { Photo(id: $0, title: "Photo \($0)") }

    var body: some View {
        ScrollViewReader { proxy in
            VStack {
                ScrollView {
                    LazyVStack {
                        ForEach(photos) { photo in
                            Text(photo.title)
                                .id(photo.id)
                        }
                    }
                }
                Button("Jump to Photo 15") {
                    withAnimation {
                        proxy.scrollTo(15, anchor: .center)
                    }
                }
            }
        }
    }
}

proxy can push a position but can't report one back.

#The new way

import SwiftUI

struct Photo: Identifiable {
    let id: Int
    let title: String
}

struct PhotoGridScroller: View {
    let photos: [Photo] = (1...20).map { Photo(id: $0, title: "Photo \($0)") }
    @State private var scrolledID: Int?

    var body: some View {
        VStack {
            ScrollView {
                LazyVStack {
                    ForEach(photos) { photo in
                        Text(photo.title)
                            .frame(maxWidth: .infinity)
                            .padding()
                            .id(photo.id)
                    }
                }
                .scrollTargetLayout()
            }
            .scrollPosition(id: $scrolledID)

            Button("Jump to Photo 15") {
                withAnimation {
                    scrolledID = 15
                }
            }

            if let scrolledID {
                Text("Currently near photo \(scrolledID)")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
        }
    }
}

#Why it matters

  • One Binding<ID?> replaces both the write path (scrollTo) and a read path you'd otherwise have to build yourself.
  • Setting scrolledID programmatically animates the scroll; reading it back tells you what the user scrolled to.
  • Works with any Identifiable id type, not just integers — plug in UUIDs from real models directly.
  • Pairs naturally with .scrollTargetBehavior(.viewAligned) for "which card is centered" style UI.

#Gotcha

The binding only tracks scroll position if .scrollTargetLayout() is applied to the LazyVStack/LazyHStack being scrolled. Without it, writing to scrolledID still visually scrolls the view — the write path works — but the binding never updates as the user scrolls by hand, so it silently stays stuck at whatever you last set it to (or nil). The asymmetry is easy to miss in testing if you only ever tap the "jump to" button and never scroll manually.

swiftuiios17scrolling

Related shorts