Implement ScrollView::autoScrollTo() in both renderers - #67
Conversation
`ScrollView::autoScrollTo($index)` has been a dead API: the PHP builder sets an `auto_scroll_to` prop, it serializes and crosses the wire intact, and then neither renderer ever reads it. Nothing scrolls. Read the prop on both platforms and drive the list from it. Semantics, matched across iOS and Android: - The index names a DIRECT child, as the docs describe. - Absent or negative means "no target" — the author isn't driving the scroll position at all. - An index past the end is CLAMPED, not dropped. A list that is still filling in can legitimately be shorter than the index for a frame or two; clamping lands on the last child now and re-fires as the real target arrives, rather than leaving the list parked. - The scroll fires when the RESOLVED index changes, not on every publish. A re-render carrying the same index leaves a reader who has scrolled away exactly where they were. A clamped target still re-fires on its own once the list grows past it. - First application jumps, later ones animate — the same rule `scroll-anchor="bottom"` already follows. - An explicit `auto_scroll_to` takes precedence over `scroll-anchor="bottom"`; both drive the same list state, and the author naming a specific child is the more specific instruction. - The target parks at the start of the viewport on both platforms (`.top` / `.leading` to match Compose's `scrollToItem`). Horizontal scroll views are covered too: the iOS one gains a `ScrollViewReader` and the Android `LazyRow` an explicit list state, neither of which it had. 2D (`axis="both"`) is deliberately excluded — children there are layered at their own frames rather than sequenced, so an index has no position to scroll to.
|
Nice work tracing this one end to end, and thanks for being upfront about not having a device. I put the branch on an iPhone 17 Pro simulator and an API 36 emulator to close that gap. The index maths is right, and on Android everything behaves the way your table says. Three things behave differently on a real screen. The first two have fixes I've built and run, the third is a design call I'd rather make with you. How I read the results, so the numbers below mean something: every row in the test screen is a solid block of colour that encodes its own number, so a screenshot tells you exactly which row is at the top and how far into it the list has scrolled. 1. On iOS the list goes where you told it last timeChange the index and nothing else, over and over: It is always one behind, on three separate builds, and the same on a horizontal list. If a screen opens with no index and gets one later, it never scrolls at all, because that first change is measured against nothing. The cause is The two-parameter version reads the current view, and the app targets 18.2 so it's available: .onChange(of: autoScrollIndex) { _, _ in
applyAutoScroll(proxy: proxy, anchor: .top, animated: true)
}Both handlers need it, the Two things worth knowing. Passing the new index into 2. Adding or removing rows moves the readerTying the scroll to the row it settles on is the right instinct, but that row moves when the content changes, not just when the author changes their mind. Removing rows: index 25 of 30, I scrolled back to the top by hand, then the list dropped to 20 rows with the index untouched. The reader gets pulled down to the new last row. Adding rows: index 20 held fixed while the list filled 5, 10, 15, 21. It scrolls twice on the way before landing, so a list that streams in stutters after the reader instead of arriving once. Both come from clamping an index that's past the end. If you ignore it instead, you keep the good part, the list still jumps to the row the moment it exists, and both symptoms go: private var autoScrollIndex: Int? {
let requested = node.props.getInt("auto_scroll_to", default: -1)
guard requested >= 0, requested < node.children.count else { return nil }
return requested
}private fun resolveAutoScrollTarget(node: NativeUINode): Int {
val requested = node.props.getInt("auto_scroll_to", -1)
if (requested < 0 || requested >= node.children.size) return -1
return requested
}I built that and ran it on both platforms:
Two knock-on effects, better decided than discovered. An index past the end now does nothing at all, so 3. Coming back to a list starts it overThis is the one I'd most like your thinking on. It needs no unusual layout, and the fix in point 1 doesn't touch it. A horizontal row of cards inside a normal vertical feed. The row opened on index 10, I swiped it across to 15, scrolled the feed down past it and back up, and it was sitting on 10 again. Twice. On iOS the same thing happens when the row comes back into view. So wherever the reader had dragged that row to is thrown away every time it leaves the screen and returns. It shows up again when you leave a screen and come back, and when you close and reopen a panel, where it isn't even consistent between panels:
Sheets and modals build their contents fresh each time they open. The sheet pane builds once at its tallest size and only slides, so it never starts over. Underneath it's the same thing in each case: the flag that remembers "I've already scrolled this list" belongs to the on-screen view, so it dies when that view goes away and is back to zero when it returns. For it to survive, it has to belong to the list itself rather than to the view showing it. On Android One more for while you're in there: on iOS the one-behind problem is worse inside a panel, because the index it remembers is the one the panel opened with. So a sheet that's meant to open a list and scroll to a particular message shows nothing at all rather than being one step off. Point 1 fixes that too, I checked. Smaller things
The benchmark still won't move, on either platform. With the rows wrapped in a single Two I couldn't reach: the drawer wouldn't open for me without touching the screen, and accordions, carousels and tab bodies all throw their contents away when hidden, so I'd expect them to behave like the sheet row above without having watched it happen. Everything else behavesJumping to a row once it exists, doing nothing for a negative or missing index, beating Requesting changes for points 1 and 2, both small and both verified on a device. Point 3 is the one worth settling together before this lands. |
|
One more, from wondering what a search field would do to this. Asking for the same row twice never fires, so a list can't be sent back to the top once its results are replaced. The screen is a search where the results are the scroll view's children and the author wants the top of the results for every query: public function render(): Element
{
return ScrollView::make(...$this->resultsFor($this->query))
->autoScrollTo(0)
->fillWidth()
->flexGrow(1);
}
Driven on Android:
Index 0 to index 0 isn't a change, so nothing fires, and there's no way for the author to ask again. I drove the queries from a probe that swaps the row set on command rather than a real text field, but the tree the renderer receives is what a search screen would publish. Keying on the identity of the row at that index doesn't help. Element ids are positional, so two different result sets hand the renderer the same ids: I built that version and it changes nothing on a device. It also survives the earlier suggestion about ignoring out-of-range indices, since 0 stays 0 either way. Something that does workLet the author re-arm it with a token the renderer watches alongside the index. On Android that's reading the prop, threading it into val autoScrollToken = node.props.getString("auto_scroll_token", "")
// ... passed through to
LaunchedEffect(targetIndex, token) {On iOS it's one computed property: private var autoScrollTrigger: String? {
guard let index = autoScrollIndex else { return nil }
return "\(index)|" + node.props.getString("auto_scroll_token", default: "")
}watched by ScrollView::make(...$this->resultsFor($this->query))
->autoScrollTo(0, token: $this->query)I built this and ran it on both platforms. Android, index held at 0 throughout: each new query with a new token puts the list at the top of the new results, including from two thirds of the way down. Then with the token unchanged, 15 re-renders and a content growth left the reader exactly where they were. iOS, index held at 5, starting from a list too short to scroll so that firing and not firing land in different places: with the token changed the list moved to row 5 of the new results, with the token unchanged it stayed at the top. Same behaviour on both. The builder argument is in mobile-air rather than here, so this is only half a suggestion. But the renderer half is small, and it's the only shape I found that fixes the search case without also re-scrolling on content changes the author didn't ask about. Other things I checkedTwo things I checked that turned out fine: rows inserted above the list while a sheet is closed shift which row the index names, which is the prop behaving as documented, and rotating the device keeps the reader where they were rather than snapping back. |
|
Looks to partially duplicate #27, but this may be a better piece in isolation... |
…ader Two bugs found on an iPhone 17 Pro simulator and an API 36 emulator. iOS scrolled to the PREVIOUS target. `.onChange(of:perform:)` hands the closure the new value but runs it against the previous version of the view, so `applyAutoScroll` re-read `autoScrollIndex` and `node.children` from a stale `self` and always landed one step behind. A screen that opened with no index and gained one later never scrolled at all, because that first change was measured against nothing. Both handlers now use the two-parameter form, which reads the current view; the deployment target is 18.2, and 25 of the 27 `.onChange` sites in this repo already use it. Clamping an out-of-range index tied the scroll to a row that moves with the CONTENT rather than with the author's intent. Removing rows dragged a reader who had scrolled away down to the new last row; a list streaming in scrolled repeatedly on its way to a target it hadn't reached. An index past the end is now ignored, which keeps the useful half — nothing happens until the named child exists, then the list goes there once. Precedence over `scroll-anchor="bottom"` now keys on the author's REQUEST rather than the resolved target. Gating on the resolved target would hand control back to the anchor whenever the index was out of reach, so a list filling in would sit at the bottom and then jump. Splitting the two ideas keeps the renderer predictable when an author sets both: - `autoScrollRequest` — did the author name a child? Content-independent. - `autoScrollIndex` — does it exist yet? Drives the scroll.
|
Thank you — putting this on a simulator and an emulator is exactly the gap I couldn't close, and both bugs are real. I've taken both fixes. Points 3 and 4 are decisions rather than defects, so my thinking is below rather than a patch. 1. The one-behind
|
| iOS | Android | |
|---|---|---|
| Resolution truth table | 17/17 | 17/17 |
| Publish sequences | — | 8/8 |
| Parse / type-check | swiftc -parse, 46/46 files clean |
Compose type-check, 0 errors 0 warnings |
The two platforms agree on every case in the shared table, including the ones your review changed: one-past-end, far-past-end, and the benchmark's 1-child/index-9999 shape all now resolve to "nothing", and the request stays content-independent so precedence can't flicker.
Negative controls, since a check that can't fail proves nothing — I mutated the shipped logic back to both bugs you found and confirmed the tests catch them:
reintroduce the clamp iOS 5 failing assertions, Android 7
drop the negative guard iOS 6 failing assertions, Android 2
The Android 7 includes both of your device scenarios, which is the check I actually wanted: if anyone reintroduces clamping, the removal and growth cases fail rather than going quiet.
Repo CI at parity: Pint 104 files pass, php -l 86 files clean. Pest is 214 passed / 3 failed, and the 3 are environmental — my usual Debian PHP image was evicted mid-session and Alpine's musl PHP doesn't define GLOB_BRACE, which src/Fonts/GoogleFonts.php:480 uses. Same three pass on glibc, this branch touches no PHP at all, and CI runs on ubuntu, so they'll be green there. Flagging it rather than quietly reporting 214.
Still not device-tested. Everything above is reasoning, parsing and type-checking — and both bugs you found lived in exactly the gap that leaves: one in SwiftUI's event wiring, one in behaviour over time that only shows up with real content changing under a real finger. If you have the rig up, the changed paths worth a pass are the one-behind sequence, removal and growth, and an author setting both autoScrollTo and scroll-anchor="bottom" — that last one is now my behaviour rather than the one you observed, so it's the most likely place for me to have chosen wrong.
Fixes the dead API reported in NativePHP/mobile-air#366.
The problem
ScrollView::autoScrollTo($index)builds a prop, the prop crosses the wire intact, and then nothing reads it. Confirmed end to end:auto_scroll_toappears zero times in this repo — neitherNativeUIScrollViewRenderer.swiftnorScrollViewRendererinContainerRenderers.ktlooks at it.scroll_anchorright next to it is read on both platforms; this one was just never wired up. The docs describe it, and mobile-air's ownBenchmarkComponentdrives its "Large List 10k FPS" scenario with it — so that benchmark has been measuring a list that never moves.The fix
Read the prop on both platforms and drive the list from it. Semantics are matched across iOS and Android:
scroll-anchorfollows)scroll-anchor="bottom"auto_scroll_torequest wins.top/.leading, matching Compose'sscrollToItem)Three choices worth calling out:
An out-of-range index is ignored, not clamped. Clamping to the last child looks like a kindness, but it ties the scroll to a row that moves whenever the content does rather than when the author's intent does. On device that meant removing rows dragged the reader down to the new end, and a list streaming in scrolled repeatedly on its way to a target it hadn't reached yet. Ignoring keeps the useful half: nothing happens until the named child exists, then the list goes there once.
Precedence keys on the request, not the resolved target. These are deliberately two ideas —
autoScrollRequest(did the author name a child at all? content-independent) andautoScrollIndex(does it exist yet? drives the scroll).scroll-anchor="bottom"loses to the request, so an author who sets both gets a list that stays put until the row exists and then goes to it, rather than sitting at the bottom and jumping once it appears.The effect keys on the resolved target. That's what stops a re-publish from yanking the reader: a screen that re-renders for an unrelated reason carries the same index and nothing fires.
Horizontal scroll views are covered too — the iOS one gains a
ScrollViewReaderand the AndroidLazyRowan explicit list state, neither of which it had.axis="both"is skipped on iOS, where children are layered at their own frames rather than sequenced, so an index has no position to scroll to. Android has no 2D path to skip: it never readsaxis, andboth()sets onlyaxisand neverhorizontal, so such a view already falls through to the vertical list and auto-scroll applies to it. That Android gap predates this PR and wants its own issue rather than a guard here.Testing
Everything below ran in Docker.
Repo CI, at parity —
pest(217 passed, 735 assertions),pint --test(104 files),php -loversrc/(86 files),swiftc -parseover all 46resources/ios/*.swift.The resolution logic, executed. Both resolvers are extracted verbatim from the committed files into standalone programs and run, so the shipped expressions are what get exercised. iOS and Android are given the same truth table — in range, boundary, one-past-end, far-past-end, negative, empty children, single child, and the benchmark's own 1-child/index-9999 shape — and agree on every case. A second table checks that the request stays content-independent, which is what makes
scroll-anchorprecedence stable.Publish-sequence tests, replaying realistic frame sequences through the real resolver and counting the scrolls the list would perform:
Including regressions for both bugs found on device:
And one that documents a known gap rather than asserting good behaviour — a search screen holding
autoScrollTo(0)across new result sets fires only once, because 0 → 0 isn't a change. That needs an author-supplied re-arm token, which needs a builder argument that doesn't exist yet; discussed in the review thread.Kotlin type-check against real Compose. The new functions plus the exact call-site wiring compile against real
androidx.compose.*artifacts (Compose Multiplatform 1.7.3 + Compose compiler plugin, Kotlin 2.1.0) with zero errors and zero warnings from frontend analysis.Negative controls, because a green check that can't fail proves nothing: injected bugs are each caught — wrong import, wrong argument type, wrong parameter name, suspend call outside a coroutine — and mutating the clamp or the negative guard produces failing assertions rather than silence.
What I could not test: on-device behaviour. No simulator or emulator here. @gwleuverink covered this round on an iPhone 17 Pro simulator and an API 36 emulator, which is how both bugs surfaced — notably,
swiftc -parseperforms no semantic analysis and so emits no deprecation diagnostics, and a pure-logic test can't reach SwiftUI's event wiring. Another pass would be welcome on the changed paths.Not included
Two follow-ups belong in mobile-air, not here:
auto-scroll-toBlade attribute, so<native:scroll-view>still can't express this — only the PHP builder can.scroll-anchoris mapped inNativeElementCollector; this isn't.BenchmarkComponent::renderLargeListFpsScreen()callsautoScrollTo($itemCount - 1)on a scroll view whose only direct child is a wrappingColumn. Index 9999 against one child now resolves to nothing at all, so that benchmark scenario still does not scroll and this PR does not fix it — it needs restructuring so the rows are direct children.Related: #27 implements this alongside the Android
gapfix and chrome-collapse work, and is blocked on NativePHP/mobile-air#241. This PR is the isolatedauto_scroll_topiece; happy to defer to #27 or have it drop that section, whichever the maintainers prefer.