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 fromscrollTo, and the read path you used to build by hand. - Assign to
scrolledIDand the scroll animates there. Read it back and you know which photo the user landed on. - Any
Identifiableid 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.