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>
57 lines
2.5 KiB
Markdown
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.
|