A UI test went red in the same run that adopted three new APIs. The one touching that screen was reverted; the test still failed. A modifier order was corrected; still failed. Simulator data was wiped; still failed. Running it against a clean checkout — which should have been the first move, not the fourth — it failed there too, with none of the work present. The scrolling entry had already been rewritten to explain the fault in confident detail. That write-up was fiction, so it now says what actually happened: the API was convicted on circumstance and remains unproven here, and the one claim that survives is about its two-way binding costing invalidations. xcode-practice gets the general lesson, since it will outlive this particular test: stash and rerun before reasoning about which edit did it, and a note recording a false cause is worse than no note, because the next person believes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2.5 KiB
Programmatic scrolling
ScrollPosition instead of ScrollViewReader
What it is. A ScrollPosition value held in @State and attached with
.scrollPosition($position) replaces wrapping content in a
ScrollViewReader to get at a proxy. Scrolling is then a method call on
the state — position.scrollTo(id:) — and, unlike the proxy, the value
can also be read, so the position is available both ways.
@State private var position = ScrollPosition()
ScrollView(.horizontal) {
HStack { ForEach(items) { ItemView(item: $0).id($0.id) } }
}
.scrollPosition($position)
Related, same family: .defaultScrollAnchor(.bottom) for where a view
starts out, and onScrollGeometryChange(for:of:action:) (iOS 18) for
following the offset while the user drags.
What it replaces. A ScrollViewReader wrapped around content purely
to reach proxy.scrollTo from a .task. In our card list that is the
filter chip strip, which scrolls the active chip into view.
Step forward? Unproven — we backed it out. Swapping ScrollViewReader
for .scrollPosition(id:) coincided with the UI test for that strip
failing, so it looked like the culprit and was reverted. It was not:
the same test fails on a completely clean tree, with none of the change
present. Something in the environment broke that test, and the swap was
convicted on circumstance.
The revert stands anyway, because it was never worth much here — this strip only ever writes a scroll target, which is exactly what the proxy already does. But the reasoning above is worth keeping, because being wrong about it taught the real lesson: when a change and a failure arrive together, check the failure against an unchanged tree before concluding. Two rounds of edits went into explaining a fault that was not there.
One thing that does hold, independent of all that:
scrollPositionis two-way, so it costs invalidations. It writes to its binding on every scroll, where the proxy only takes commands. It therefore belongs in a small view. Ours would have sat in a screen whose body derives a sorted, grouped card list — every drag of the strip would have recomputed all of it. Extracting the strip into its own view was worth keeping regardless of which scrolling API ends up inside it.
Available since. iOS 17, so nothing blocks it at our deployment
target. onScrollGeometryChange needs iOS 18.
Source. Natalia Panferova, "Modern SwiftUI APIs for programmatic scrolling", nilcoalescing.com, 19 May 2026. Read 16 Aug 2026.