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. |
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_towins — both drive the same list state.top/.leading, matching Compose'sscrollToItem)Two choices worth calling out:
Clamping rather than ignoring an out-of-range index. PHP publishes the index and the children in the same frame, but a list still filling in (paginated history, a streamed response) 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 appears; dropping it would leave the list parked wherever it was.
Keying the effect on the resolved index, not the raw prop. This is 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. It also makes a clamped target re-fire on its own once the list grows past it — the resolved value moves even though the prop didn't.
Horizontal scroll views are covered too — the iOS one gains a
ScrollViewReaderand the AndroidLazyRowan explicit list state, neither of which it had. 2D (axis="both") is deliberately excluded and commented as such: children there are layered at their own frames rather than sequenced, so an index has no position to scroll to.Testing
Everything below ran in Docker.
Repo CI, at parity
pest— 217 passed (735 assertions)pint --test— 104 files passphp -loversrc/— 86 files cleanswiftc -parseover all 46resources/ios/*.swift— clean (Swift 6.1)Beyond CI — the resolution logic, executed. The resolver was extracted verbatim from the committed files into standalone programs and run, so the shipped expressions are what get exercised. Both platforms were given the same 13-case 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 publish-sequence test then replays realistic frame sequences through the real resolver and counts the scrolls the list would perform:
Kotlin type-check against real Compose. The new functions plus the exact call-site wiring were compiled against real
androidx.compose.*artifacts (Compose Multiplatform 1.7.3 + Compose compiler plugin, Kotlin 2.1.0): zero errors, zero warnings from frontend analysis. Full-file compilation ofContainerRenderers.ktproduces the same four error classes before and after this change (all classpath-absence artefacts), so nothing structural was introduced.Negative controls, because a green check that can't fail proves nothing. Injected bugs were each caught: a wrong import, a wrong argument type, a wrong parameter name, and a suspend call outside a coroutine — plus mutation of the clamp and the negative-guard, which produced 5, 4 and 6 failing assertions respectively.
What I could not test: actual on-device scroll behaviour. There is no simulator or emulator here, and full compilation needs the consuming Xcode/Gradle project. The SwiftUI and Compose wiring is reviewed and type-checked, not run — worth a look on a device before merge, particularly the interaction with
scroll-anchor="bottom".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, so index 9999 clamps to the single child. That call site needs restructuring for the benchmark to measure what it claims to.