Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions resources/android/ContainerRenderers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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
Expand Down
100 changes: 93 additions & 7 deletions resources/ios/NativeUIScrollViewRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down