diff --git a/resources/android/ContainerRenderers.kt b/resources/android/ContainerRenderers.kt index 8d47156..7754103 100644 --- a/resources/android/ContainerRenderers.kt +++ b/resources/android/ContainerRenderers.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -336,6 +337,57 @@ private fun totalDescendants(node: NativeUINode): Int { return count } +/** + * Resolves `ScrollView::autoScrollTo($index)` into a concrete child index, or + * `-1` for "no target". + * + * The prop names a DIRECT child of the scroll view. An absent or negative + * value means the author isn't driving the scroll position at all. + * + * An index past the end is CLAMPED rather than dropped: PHP publishes the + * index and the children in the same frame, but a list that is 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. + */ +private fun resolveAutoScrollTarget(node: NativeUINode): Int { + val requested = node.props.getInt("auto_scroll_to", -1) + if (requested < 0 || node.children.isEmpty()) return -1 + + return requested.coerceAtMost(node.children.size - 1) +} + +/** + * Drives a lazy list from the resolved `auto_scroll_to` target. + * + * Keyed on the RESOLVED index, so the scroll fires when the author's intent + * actually changes — not on every re-publish. A screen that re-renders for an + * unrelated reason (a tick, a toggle elsewhere) carries the same index and + * leaves a reader who has scrolled away exactly where they were. It also means + * a clamped target re-fires on its own once the list grows past it: the + * resolved value moves even though the prop didn't. + * + * First application jumps, later ones animate — matching `scroll-anchor`: + * a screen that opens already scrolled shouldn't visibly fly down from the + * top, but a later move is a state change the user should see happen. + */ +@Composable +private fun AutoScrollToEffect(targetIndex: Int, listState: LazyListState) { + val didInitialScroll = remember { mutableStateOf(false) } + + LaunchedEffect(targetIndex) { + if (targetIndex < 0) return@LaunchedEffect + + if (!didInitialScroll.value) { + didInitialScroll.value = true + listState.scrollToItem(targetIndex) + } else { + listState.animateScrollToItem(targetIndex) + } + } +} + object ScrollViewRenderer { @Composable fun Render(node: NativeUINode, modifier: Modifier) { @@ -345,8 +397,14 @@ object ScrollViewRenderer { detectVerticalDragGestures(onDragStart = { keyboardController?.hide() }) { _, _ -> } } + val autoScrollTarget = resolveAutoScrollTarget(node) + if (horizontal) { - LazyRow(modifier = modifier) { + val rowState = rememberLazyListState() + + AutoScrollToEffect(autoScrollTarget, rowState) + + LazyRow(modifier = modifier, state = rowState) { items(node.children, key = { it.id }) { child -> NodeView(node = child) } @@ -360,7 +418,12 @@ object ScrollViewRenderer { // item with a max offset lands at the very bottom regardless of how // the content is nested. Hooks are called unconditionally to satisfy // Compose's rules; the work is gated on the prop. - val stickBottom = node.props.getString("scroll_anchor", "") == "bottom" + // An explicit `auto_scroll_to` wins over `scroll-anchor="bottom"`. + // Both drive the same LazyListState, so letting them run together + // would have two effects fighting over the same list — the author + // named a specific child, which is the more specific instruction. + val stickBottom = autoScrollTarget < 0 && + node.props.getString("scroll_anchor", "") == "bottom" val listState = rememberLazyListState() val didInitialScroll = remember { mutableStateOf(false) } val contentSignal = if (stickBottom) totalDescendants(node) else 0 @@ -377,6 +440,8 @@ object ScrollViewRenderer { } } + AutoScrollToEffect(autoScrollTarget, listState) + // A `fill` / `h-full` DIRECT child asked to be at least as tall as // the VIEWPORT — the "short screen centred, still scrolls when the // keyboard appears" pattern. A LazyColumn measures its items with diff --git a/resources/ios/NativeUIScrollViewRenderer.swift b/resources/ios/NativeUIScrollViewRenderer.swift index b1d01de..620efa4 100644 --- a/resources/ios/NativeUIScrollViewRenderer.swift +++ b/resources/ios/NativeUIScrollViewRenderer.swift @@ -11,12 +11,23 @@ struct NativeUIScrollViewRenderer: View { /// bottom-anchored list opens at the bottom. @State private var atBottom: Bool = true + /// Whether `auto_scroll_to` has been applied once already. The first + /// application jumps (a screen that opens already scrolled shouldn't fly + /// down from the top); later ones animate, so a move the user didn't + /// initiate is visible rather than teleporting the content. + @State private var didInitialAutoScroll: Bool = false + var body: some View { let horizontal = node.props.getBool("horizontal") let showsIndicators = node.props.getBool("shows_indicators", default: true) let spacing = CGFloat(node.layout?.gap ?? 0) let axis = node.props.getString("axis", default: "") - let stickBottom = node.props.getString("scroll_anchor", default: "") == "bottom" + // An explicit `auto_scroll_to` wins over `scroll-anchor="bottom"`. + // Both drive the same ScrollViewReader, so letting them run together + // would have two handlers fighting over the same list — the author + // named a specific child, which is the more specific instruction. + let stickBottom = autoScrollIndex == nil + && node.props.getString("scroll_anchor", default: "") == "bottom" let messageSignal = stickBottom ? Self.descendantCount(node) : 0 // 2D mode. Bypass the Lazy stacks (which force 1D layout) and use a @@ -34,6 +45,10 @@ struct NativeUIScrollViewRenderer: View { // own `.frame(...)` (set by NodeLayoutModifier from `w-[N]` / // `h-[N]` classes) drives the scrollable size. // + // `auto_scroll_to` is deliberately not honoured here: children in + // 2D mode are layered at their own frames rather than sequenced, + // so "the child at index N" has no position to scroll to. + // // Multi-child 2D scrolls are rare (typical use is one large // image / canvas). For multiple children we layer them in a // ZStack pinned via `.fixedSize` and accept that NavigationStack @@ -53,15 +68,24 @@ struct NativeUIScrollViewRenderer: View { } .scrollDismissesKeyboard(.interactively) } else if horizontal { - ScrollView(.horizontal, showsIndicators: showsIndicators) { - LazyHStack(alignment: .top, spacing: spacing) { - ForEach(node.children) { child in - NodeView(node: child) - .equatable() + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: showsIndicators) { + LazyHStack(alignment: .top, spacing: spacing) { + ForEach(node.children) { child in + NodeView(node: child) + .equatable() + } } } + .scrollDismissesKeyboard(.interactively) + // `.leading`, so a horizontal auto-scroll parks the target at + // the left edge — the same place Android's `scrollToItem` + // puts it. + .onAppear { applyAutoScroll(proxy: proxy, anchor: .leading, animated: false) } + .onChange(of: autoScrollIndex) { _ in + applyAutoScroll(proxy: proxy, anchor: .leading, animated: true) + } } - .scrollDismissesKeyboard(.interactively) } else if hasFillHeightChild { // A `fill` / `h-full` child asked to be at least as tall as the // VIEWPORT — the "short screen centred, still scrolls when the @@ -184,6 +208,13 @@ struct NativeUIScrollViewRenderer: View { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } + // `.top`, so the named child parks at the top of the viewport — + // matching Android's `scrollToItem`, which puts the item at the + // start of the list. + .onAppear { applyAutoScroll(proxy: proxy, anchor: .top, animated: false) } + .onChange(of: autoScrollIndex) { _ in + applyAutoScroll(proxy: proxy, anchor: .top, animated: true) + } // The keyboard resizes the scroll viewport in BOTH directions — // it shrinks on the way in (the screen shifts up for keyboard // avoidance) and grows back on the way out. Re-pin on each, so @@ -215,6 +246,61 @@ struct NativeUIScrollViewRenderer: View { } } + /// Resolves `ScrollView::autoScrollTo($index)` into a concrete child + /// index, or `nil` for "no target". + /// + /// The prop names a DIRECT child of the scroll view. An absent or negative + /// value means the author isn't driving the scroll position at all. + /// + /// An index past the end is CLAMPED rather than dropped: PHP publishes the + /// index and the children in the same frame, but a list that is 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. + /// + /// Driving `.onChange` off the RESOLVED index (rather than the raw prop) + /// is what keeps 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. + private var autoScrollIndex: Int? { + let requested = node.props.getInt("auto_scroll_to", default: -1) + guard requested >= 0, !node.children.isEmpty else { return nil } + + return min(requested, node.children.count - 1) + } + + /// Brings the `auto_scroll_to` child into view. + /// + /// Targets the child's node id, which is the identity `ForEach` already + /// assigns to each row (`NativeUINode: Identifiable`), so no extra `.id()` + /// is needed on the row's modifier chain. + /// + /// `animated` is decided by the caller's context, but the FIRST successful + /// application always jumps regardless — an `.onChange` can be the first + /// thing to fire when the prop arrives after the initial layout. + private func applyAutoScroll(proxy: ScrollViewProxy, anchor: UnitPoint, animated: Bool) { + guard let index = autoScrollIndex else { return } + + let targetID = node.children[index].id + let shouldAnimate = animated && didInitialAutoScroll + didInitialAutoScroll = true + + // Defer past first layout — lazy content isn't measured yet inside + // `onAppear`, so an immediate `scrollTo` no-ops. Same reason the + // bottom-anchor pin defers. + DispatchQueue.main.async { + if shouldAnimate { + withAnimation(.easeOut(duration: 0.25)) { + proxy.scrollTo(targetID, anchor: anchor) + } + } else { + proxy.scrollTo(targetID, anchor: anchor) + } + } + } + /// Scroll the bottom anchor back into view, in step with the keyboard. /// /// Shared by the show and hide observers so the two transitions animate