SwiftUI ScrollViewReader and scrollTo
ScrollViewReader exposes a proxy whose scrollTo(_:anchor:) jumps to any child with an id — chat bottoms, section indexes, and back-to-top buttons.
import SwiftUI
struct ChatLog: View {
let messages: [String]
var body: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(Array(messages.enumerated()), id: \.offset) { index, text in
Text(text)
.padding(10)
.background(.teal.opacity(0.15), in: .rect(cornerRadius: 12))
.id(index)
}
}
.padding()
}
.onChange(of: messages.count) {
withAnimation {
proxy.scrollTo(messages.count - 1, anchor: .bottom)
}
}
}
}
}Proxy-driven jumps
ScrollViewReader wraps a scroll view and provides a ScrollViewProxy. Calling proxy.scrollTo(id, anchor:) scrolls until the child carrying that .id() reaches the anchor position. The proxy works inside List too, not just ScrollView.
Anchors
The UnitPoint anchor decides the alignment between target and viewport:
proxy.scrollTo("intro", anchor: .top) // header at top
proxy.scrollTo(lastID, anchor: .bottom) // tail visible
proxy.scrollTo(matchID, anchor: .center) // highlight centered
Omitting the anchor scrolls the minimal distance to make the target visible at all.
Reacting to data
The classic pattern pairs scrollTo with onChange so arrival of data triggers the jump. Be careful in chat UIs to skip auto-scrolling while the user is reading history — track whether they are near the bottom before forcing the jump.
ScrollViewReader vs scrollPosition
scrollPosition(id:) represents the visible item as state you can set, which covers most jump cases and stays testable. The proxy still earns its keep for anchor-precise jumps and for imperative flows like "scroll after this async write completes."
Common mistakes
- Calling scrollTo before the target exists in the hierarchy — defer until data lands.
- Relying on array offsets as ids while the array mutates, which retargets jumps.
- Fighting lazy loading: a far-away lazy target may need an estimated jump first.
Related reference
Modern ScrollView is configured declaratively: scrollTargetBehavior for paging and snapping, scrollPosition for tracking, contentMargins for insets, plus bounce and disable controls.
List renders platform-native rows from data. ForEach adds onDelete and onMove editing, and a selection binding turns rows tappable with single or multi-select.
Lazy stacks create children only as they scroll into view and support section headers that pin to the edge while their section passes.