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

57 lines
2.5 KiB
Markdown

# 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.
```swift
@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.