← 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 drives a scroll view with proxy.scrollTo. It never tells you where that scroll view currently is.

That one-way street was the problem. Want a "you are here" indicator, or two lists scrolling in sync? You bolted a separate GeometryReader/PreferenceKey rig on top to get the position back out.

#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 pushes a position. It 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?> covers both directions: the write path you used to get from scrollTo, and the read path you used to build by hand.
  • Assign to scrolledID and the scroll animates there. Read it back and you know which photo the user landed on.
  • Any Identifiable id type works, integers included. Feed it the UUIDs off your real models.
  • Pairs naturally with .scrollTargetBehavior(.viewAligned) for "which card is centered" style UI.

#Gotcha

Tracking only happens if .scrollTargetLayout() sits on the LazyVStack/LazyHStack being scrolled.

Drop that modifier and the write path still works. Set scrolledID to 15 and the view scrolls to photo 15, exactly as before. The read path is the half that dies, and it dies quietly: drag with your finger and the binding never updates, so it holds whatever you last assigned, or nil. Easy to miss in testing. You tap "Jump to Photo 15", watch it work, and never touch the path that's broken.

swiftuiios17scrolling

Related shorts