Files
claude-plugins/pdat-swift-practice/skills/apple-findings/references/scrolling.md
T
TomUviandClaude Opus 5 542dd925d0 Record being wrong about which change broke a test
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>
2026-08-17 01:04:24 +02:00

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:

  • scrollPosition is 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.