From 85fd53735a78440f7daf3a949790bf6d2ebff871 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 22 Oct 2026 12:06:04 +0100 Subject: [PATCH] Give a horizontal pager a header that scrolls with the page showing A profile has a header (avatar, bio, counts) above its tabs. Native apps scroll the header up with the page you're on, pin the tabs under the nav bar, and swipe the pages sideways under them while the header stays put. EDGE couldn't do that: the pager's tabs sat above the pages, and anything above the pager never scrolled away. goes inside a horizontal pager beside the pages. The pager hands it to each page, and the page's list draws it as its first row with the tabs as a pinned section header (iOS) or sticky header (Android) under it. So the header scrolls with the list, pull to refresh pulls it down, and a drag that starts on it scrolls. While the pager swipes, the header and tabs are moved against the page so they stay still, and the two halves on screen meet as one. That only works if the pages beside each other are level. The pager keeps every page's list at the same offset while any of the header shows. Once it has gone, each list keeps its own place, and one higher than the header is brought down to where it pins the tabs. A list too short to scroll the header away gets room at its end. iOS moves the lists through their UIScrollView, since SwiftUI can't set a List's offset. hangs something under the tabs over the pages, a "3 new posts" pill say, and follows the tabs as the header scrolls. Every page needs a list for the header to show on it. This re-lands #139. That PR was merged into feat/pager-tabs a minute after #136 had already merged that branch, so it never reached main. Two conflicts, both in the iOS list renderer. #144 replaced the rows' end-reached closures with Self.endReachedAction, and the header's rows use that too. #118 added the list's minimum row height next to where this adds the pager header, and the list keeps both. Co-Authored-By: Claude Opus 5.5 --- nativephp.json | 16 + resources/android/ListRenderer.kt | 44 ++- resources/android/PagerRenderer.kt | 315 ++++++++++++++-- resources/ios/NativeUIListRenderer.swift | 70 +++- resources/ios/NativeUIPagerRenderer.swift | 435 +++++++++++++++++++++- src/Components/PagerHeader.php | 15 + src/Components/PagerOverlay.php | 15 + src/Elements/Pager.php | 7 +- src/Elements/PagerHeader.php | 51 +++ src/Elements/PagerOverlay.php | 39 ++ tests/PagerHeaderTest.php | 121 ++++++ 11 files changed, 1072 insertions(+), 56 deletions(-) create mode 100644 src/Components/PagerHeader.php create mode 100644 src/Components/PagerOverlay.php create mode 100644 src/Elements/PagerHeader.php create mode 100644 src/Elements/PagerOverlay.php create mode 100644 tests/PagerHeaderTest.php diff --git a/nativephp.json b/nativephp.json index 72ff62c..5cdcb12 100644 --- a/nativephp.json +++ b/nativephp.json @@ -413,6 +413,22 @@ "ios_renderer": "NativeUIPagerRenderer", "self_closing": false }, + { + "type": "pager_header", + "element": "Native\\Mobile\\UI\\Elements\\PagerHeader", + "blade": "Native\\Mobile\\UI\\Components\\PagerHeader", + "android_renderer": "com.nativephp.plugins.native_ui.ui.EmptyRenderer", + "ios_renderer": "NativeUIEmptyRenderer", + "self_closing": false + }, + { + "type": "pager_overlay", + "element": "Native\\Mobile\\UI\\Elements\\PagerOverlay", + "blade": "Native\\Mobile\\UI\\Components\\PagerOverlay", + "android_renderer": "com.nativephp.plugins.native_ui.ui.EmptyRenderer", + "ios_renderer": "NativeUIEmptyRenderer", + "self_closing": false + }, { "type": "carousel", "element": "Native\\Mobile\\UI\\Elements\\Carousel", diff --git a/resources/android/ListRenderer.kt b/resources/android/ListRenderer.kt index 0a9d200..e3ed0ef 100644 --- a/resources/android/ListRenderer.kt +++ b/resources/android/ListRenderer.kt @@ -10,6 +10,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -62,6 +64,9 @@ object ListRenderer { val endReachedBuffer = node.props.getInt("end_reached_buffer", 3).coerceAtLeast(1) val scrollState = rememberLazyListState() + // Set when this list is a page of a pager with a header, which the + // list draws above its rows (see PagerHeader). + val pagerHeader = if (horizontal) null else LocalPagerHeader.current val isRefreshing = remember { mutableStateOf(false) } val endReachedFired = remember { mutableStateOf(false) } @@ -71,7 +76,8 @@ object ListRenderer { snapshotFlow { val info = scrollState.layoutInfo val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: 0 - val total = info.totalItemsCount + // Under a pager header, the room after the rows isn't one. + val total = info.totalItemsCount - if (pagerHeader != null) 1 else 0 total > 0 && lastVisible >= total - endReachedBuffer }.collect { nearEnd -> if (nearEnd && !endReachedFired.value) { @@ -84,6 +90,17 @@ object ListRenderer { } } + if (pagerHeader != null) { + DisposableEffect(pagerHeader, scrollState) { + pagerHeader.scroll.attach(pagerHeader.page, scrollState) + onDispose { pagerHeader.scroll.detach(pagerHeader.page, scrollState) } + } + LaunchedEffect(pagerHeader, scrollState) { + snapshotFlow { scrollState.firstVisibleItemIndex to scrollState.firstVisibleItemScrollOffset } + .collect { pagerHeader.scroll.scrolled(pagerHeader.page, scrollState) } + } + } + val keyboardController = LocalSoftwareKeyboardController.current val dismissKeyboardModifier = Modifier.pointerInput(Unit) { detectVerticalDragGestures(onDragStart = { keyboardController?.hide() }) { _, _ -> } @@ -98,6 +115,26 @@ object ListRenderer { } } } + } else if (pagerHeader != null) { + LazyColumn(modifier = (if (onRefreshCb != 0) Modifier else modifier).then(dismissKeyboardModifier), state = scrollState) { + // The header, then the pager's tabs pinned over every row, + // the rows of any list sections among them. + item(key = PAGER_HEADER_KEY) { PagerHeaderRow(pagerHeader) } + if (pagerHeader.hasTabs) { + stickyHeader(key = PAGER_TABS_KEY) { PagerHeaderStrip(pagerHeader) } + } + val rows = node.children.flatMap { if (it.type == "list_section") it.children else listOf(it) } + rows.forEachIndexed { index, row -> + item(key = row.id) { + // Only this list draws the header, not one in its rows. + CompositionLocalProvider(LocalPagerHeader provides null) { ListRow(row) } + if (separator && index < rows.size - 1) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + item(key = PAGER_ROOM_KEY) { PagerHeaderRoom(pagerHeader, scrollState, PAGER_ROOM_KEY) } + } } else { // A list is "sectioned" when any direct child is a // `list_section`. Sectioned lists adopt the inset-grouped @@ -215,6 +252,11 @@ private fun ListRow(child: NativeUINode) { } } +/** Keys of the items a list adds under a pager header. Row keys are node ids, so these can't clash. */ +private const val PAGER_HEADER_KEY = "nui_pager_header" +private const val PAGER_TABS_KEY = "nui_pager_tabs" +private const val PAGER_ROOM_KEY = "nui_pager_room" + /** Space above a section: a header's top padding, or a bare gap without one. */ private val SECTION_TOP_GAP = 20.dp diff --git a/resources/android/PagerRenderer.kt b/resources/android/PagerRenderer.kt index d1378d9..35d9806 100644 --- a/resources/android/PagerRenderer.kt +++ b/resources/android/PagerRenderer.kt @@ -7,11 +7,14 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.VerticalPager @@ -22,12 +25,16 @@ import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -39,6 +46,7 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged @@ -49,6 +57,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import coil3.compose.AsyncImage import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -59,6 +68,7 @@ import com.nativephp.mobile.ui.nativerender.NativeElementBridge import com.nativephp.mobile.ui.nativerender.NativeUINode import com.nativephp.mobile.ui.nativerender.NodeView import com.nativephp.plugins.native_ui.NativeUITheme +import kotlin.math.abs import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlin.math.floor @@ -93,7 +103,13 @@ object PagerRenderer { @Composable fun Render(node: NativeUINode, modifier: Modifier) { val p = node.props - val count = p.getInt("count", node.children.size).coerceAtLeast(0) + val horizontal = p.getBool("horizontal", false) + // The header and the overlay ride beside the pages; neither is one. + val pages = remember(node) { node.children.filter { it.type != "pager_header" && it.type != "pager_overlay" } } + // Only a horizontal pager's pages scroll up and down under a header. + val header = if (horizontal) node.children.firstOrNull { it.type == "pager_header" } else null + val overlay = node.children.firstOrNull { it.type == "pager_overlay" } + val count = p.getInt("count", pages.size).coerceAtLeast(0) val windowFrom = p.getInt("window_from", 0) // `has_more` appends one loading page after the last loaded item — // a feed has no total, so the user can pull into the tail while @@ -103,15 +119,14 @@ object PagerRenderer { // Image per page index for pages PHP hasn't shipped; "" for none. val placeholders = p.getStringList("placeholders") val requestedPage = p.getInt("page", 0).coerceIn(0, (pageCount - 1).coerceAtLeast(0)) - val horizontal = p.getBool("horizontal", false) val cbId = p.getCallbackId("on_page_change") val reportOnSettle = p.getBool("report_on_settle", false) val tabs = PagerTabs.from(p) val scope = rememberCoroutineScope() val pageByIndex: Map = remember(node, windowFrom) { - val out = HashMap(node.children.size) - node.children.forEachIndexed { offset, child -> out[windowFrom + offset] = child } + val out = HashMap(pages.size) + pages.forEachIndexed { offset, child -> out[windowFrom + offset] = child } out } @@ -162,6 +177,34 @@ object PagerRenderer { val pagerModifier = modifier.nuiA11y(p.getString("a11y_label"), p.getString("a11y_hint")) + val selectTab: (Int) -> Unit = { index -> + // The tab consumes the tap, so drop the keyboard + // here unless the focused field keeps focus. + KeyboardFocusPolicy.dismissForInteractiveTap() + if (index == pagerState.currentPage && !pagerState.isScrollInProgress) { + // The tab already showing: PHP decides, usually + // to take the page back to its top. + if (tabs != null && tabs.reselectCbId != 0) { + NativeElementBridge.sendTabChangeEvent(tabs.reselectCbId, node.id, index) + } + } else if (index in 0 until pageCount) { + // Reported when the pager settles, as for a swipe. + scope.launch { pagerState.animateScrollToPage(index) } + } + } + + // Where the header is, and each page's list, when there's a header. + val headerScroll = remember { PagerHeaderScroll() } + if (header != null) { + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { headerScroll.lead(it) } + } + } + val background = PagerTabColor( + node.style?.bgColor?.takeIf { it != 0 }, + if (p.has("dark_bg_color")) p.getColor("dark_bg_color", 0).takeIf { it != 0 } else null, + ) + // A pager needs a bounded main axis. Inside a lazy parent (the // plain vertical scroll_view is a LazyColumn) the incoming height // is infinite, so fall back to one viewport. @@ -176,7 +219,12 @@ object PagerRenderer { val page: @Composable (Int) -> Unit = { index -> Box(modifier = Modifier.fillMaxSize()) { val child = pageByIndex[index] - if (child != null) { + if (child != null && header != null) { + // The page's list draws the header and the tabs (see PagerHeader). + CompositionLocalProvider(LocalPagerHeader provides headerScroll.header(index, header, tabs, pagerState, selectTab, background)) { + NodeView(node = child) + } + } else if (child != null) { NodeView(node = child) } else if (hasMore && index >= count) { PagerLoadingPage() @@ -204,26 +252,31 @@ object PagerRenderer { } } - if (tabs == null) { - pages(sized) + // The overlay hangs under the tabs: under a header, wherever it + // has scrolled them to. + val withOverlay: @Composable (Modifier, PagerHeaderScroll?) -> Unit = { pagesModifier, scroll -> + if (overlay == null) { + pages(pagesModifier) + } else { + Box(modifier = pagesModifier) { + pages(Modifier.fillMaxSize()) + Box( + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(0, scroll?.stripBottom()?.roundToInt() ?: 0) }, + ) { PagerChildren(overlay) } + } + } + } + + if (header != null) { + withOverlay(sized.onGloballyPositioned { headerScroll.pagerTop = it.positionInRoot().y }, headerScroll) + } else if (tabs == null) { + withOverlay(sized, null) } else { Column(modifier = sized) { - PagerTabStrip(tabs, pagerState) { index -> - // The tab consumes the tap, so drop the keyboard - // here unless the focused field keeps focus. - KeyboardFocusPolicy.dismissForInteractiveTap() - if (index == pagerState.currentPage && !pagerState.isScrollInProgress) { - // The tab already showing: PHP decides, usually - // to take the page back to its top. - if (tabs.reselectCbId != 0) { - NativeElementBridge.sendTabChangeEvent(tabs.reselectCbId, node.id, index) - } - } else if (index in 0 until pageCount) { - // Reported when the pager settles, as for a swipe. - scope.launch { pagerState.animateScrollToPage(index) } - } - } - pages(Modifier.fillMaxWidth().weight(1f)) + PagerTabStrip(tabs, pagerState, selectTab) + withOverlay(Modifier.fillMaxWidth().weight(1f), null) } } } @@ -238,7 +291,7 @@ object PagerRenderer { * itself to keep the indicator in view. Nothing here waits for PHP. */ @Composable - private fun PagerTabStrip(tabs: PagerTabs, pagerState: PagerState, onSelect: (Int) -> Unit) { + internal fun PagerTabStrip(tabs: PagerTabs, pagerState: PagerState, onSelect: (Int) -> Unit) { val isDark = isSystemInDarkTheme() val theme = if (isDark) NativeUITheme.dark else NativeUITheme.light val density = LocalDensity.current @@ -394,7 +447,7 @@ object PagerRenderer { * The `tabs` row and its look, read from the pager's props. Colours are * null where PHP sent none, so the row falls back to the theme. */ -private class PagerTabs(p: GenericProps) { +internal class PagerTabs(p: GenericProps) { val labels: List = p.getStringList("tabs") val a11yLabels: List = p.getStringList("tab_a11y_labels") val scrollable = p.getBool("tabs_scrollable", false) @@ -418,7 +471,7 @@ private class PagerTabs(p: GenericProps) { } /** A tab colour PHP sent, with its dark shade when it came from a theme token. */ -private class PagerTabColor(private val light: Int?, private val dark: Int?) { +internal class PagerTabColor(private val light: Int?, private val dark: Int?) { fun resolve(isDark: Boolean, fallback: Color): Color { val argb = if (isDark) dark ?: light else light @@ -432,3 +485,213 @@ private class PagerTabColor(private val light: Int?, private val dark: Int?) { ) } } + +/** + * A pager's `pager_header`, as one of its pages gets it through + * [LocalPagerHeader]. The page's list draws it ([ListRenderer]): the header + * as its first item, then the pager's tabs as a sticky header over every + * other row, so the tabs pin once the header has scrolled away. Both are + * held still while the pager swipes, so the pages seem to slide under them. + * + * One per page for the life of the pager. A render updates it in place. + */ +@Stable +class PagerHeader internal constructor( + val page: Int, + node: NativeUINode, + internal val scroll: PagerHeaderScroll, + internal val pagerState: PagerState, +) { + var node by mutableStateOf(node) + internal set + internal var tabs: PagerTabs? = null + internal var select: (Int) -> Unit = {} + internal var background = PagerTabColor(null, null) + + val hasTabs: Boolean get() = tabs != null +} + +/** The header a page's list draws, if the page is in a pager with one. */ +val LocalPagerHeader = compositionLocalOf { null } + +/** + * How far the header has scrolled, from the page showing, and each page's + * list, so the pages beside it can be kept level with it. + * + * While any of the header shows, every list scrolls with it: a swipe finds + * the next page's header at the same height, so the two meet seamlessly. + * Once it has gone, each list keeps its own place, and only a list higher + * than the header has gone is brought down to where it pins the tabs. + */ +@Stable +class PagerHeaderScroll { + /** The header's height in pixels, without the tabs. */ + var headerHeight by mutableIntStateOf(0) + internal set + /** How much of the header has scrolled away, 0 to [headerHeight]. */ + var collapse by mutableFloatStateOf(0f) + private set + /** The page the header follows. */ + private var shownPage by mutableIntStateOf(0) + /** Where each page's tabs end on screen, and where the pager starts, in root pixels. */ + internal val stripBottoms = mutableStateMapOf() + internal var pagerTop by mutableFloatStateOf(0f) + + private var leading = 0 + private val lists = HashMap() + private val offsets = HashMap() + private val headers = HashMap() + + internal fun header(page: Int, node: NativeUINode, tabs: PagerTabs?, pagerState: PagerState, select: (Int) -> Unit, background: PagerTabColor): PagerHeader { + val header = headers.getOrPut(page) { PagerHeader(page, node, this, pagerState) } + if (header.node !== node) header.node = node + header.tabs = tabs + header.select = select + header.background = background + return header + } + + /** Where the bottom of the tabs is, below the pager's top edge, in pixels, as the page showing has them on screen. */ + fun stripBottom(): Float = ((stripBottoms[shownPage] ?: pagerTop) - pagerTop).coerceAtLeast(0f) + + /** The page nearest the pager's leading edge, which the header follows. */ + internal fun lead(page: Int) { + if (page == leading) return + leading = page + shownPage = page + follow(offsets[page] ?: 0f) + } + + /** A page's list. A page that isn't showing is brought level with the header before it can be seen. */ + internal fun attach(page: Int, state: LazyListState) { + lists[page] = state + offsets[page] = offsetOf(state) + if (page != leading) align(page) + } + + internal fun detach(page: Int, state: LazyListState) { + if (lists[page] === state) lists.remove(page) + } + + /** A page's list scrolled. */ + internal fun scrolled(page: Int, state: LazyListState) { + val y = offsetOf(state) + offsets[page] = y + if (page == leading) follow(y) + } + + /** A page's list has more room at its end, so it can be brought level now. */ + internal fun realign(page: Int) { + if (page != leading) align(page) + } + + /** How far down its own content a list is: exact while the header shows, past it after. */ + private fun offsetOf(state: LazyListState): Float = + if (state.firstVisibleItemIndex == 0) state.firstVisibleItemScrollOffset.toFloat() else headerHeight + 1f + + private fun follow(y: Float) { + val collapse = y.coerceAtMost(headerHeight.toFloat()) + if (collapse == this.collapse) return + this.collapse = collapse + for (page in lists.keys) { + if (page != leading) align(page) + } + } + + private fun align(page: Int) { + val state = lists[page] ?: return + if (headerHeight <= 0) return + + val own = offsets[page] ?: 0f + val height = headerHeight.toFloat() + val target = if (collapse < height) collapse else maxOf(own, height) + if (abs(target - own) <= 0.5f) return + + offsets[page] = target + state.requestScrollToItem(0, target.roundToInt()) + } +} + +/** The header as a page's list draws it: its first item. */ +@Composable +internal fun PagerHeaderRow(header: PagerHeader) { + Box( + modifier = Modifier + .fillMaxWidth() + .pagerStationary(header) + .onSizeChanged { header.scroll.headerHeight = it.height }, + ) { + CompositionLocalProvider(LocalPagerHeader provides null) { + PagerChildren(header.node) + } + } +} + +/** The pager's tabs as a page's list draws them: a sticky header, pinned once the header has gone. */ +@Composable +internal fun PagerHeaderStrip(header: PagerHeader) { + val tabs = header.tabs ?: return + val isDark = isSystemInDarkTheme() + val theme = if (isDark) NativeUITheme.dark else NativeUITheme.light + + Box( + modifier = Modifier + .fillMaxWidth() + .pagerStationary(header) + .background(header.background.resolve(isDark, theme.surface)) + .onGloballyPositioned { header.scroll.stripBottoms[header.page] = it.positionInRoot().y + it.size.height }, + ) { + PagerRenderer.PagerTabStrip(tabs, header.pagerState, header.select) + } +} + +/** + * Space after a page's last row, so a short list can still scroll the + * header away and keep level with the others. Measured from where it sits + * while the header is on screen; past the header the list is long enough. + */ +@Composable +internal fun PagerHeaderRoom(header: PagerHeader, state: LazyListState, key: Any) { + val density = LocalDensity.current + var room by remember { mutableIntStateOf(0) } + + LaunchedEffect(state, header) { + snapshotFlow { + val info = state.layoutInfo + val first = info.visibleItemsInfo.firstOrNull() + val last = info.visibleItemsInfo.lastOrNull() + if (first?.index == 0 && last?.key == key) { + val viewport = info.viewportEndOffset - info.viewportStartOffset + // Where this item starts in the list's content, without the room it has now. + val roomTop = last.offset - first.offset + (viewport + header.scroll.headerHeight - roomTop).coerceAtLeast(0) + } else { + null + } + }.collect { needed -> + if (needed != null && needed != room) { + room = needed + header.scroll.realign(header.page) + } + } + } + + Spacer(Modifier.fillMaxWidth().height(with(density) { room.toDp() })) +} + +/** What a `pager_header` or `pager_overlay` holds, stacked. Their own renderer draws nothing: the pager places them. */ +@Composable +private fun PagerChildren(node: NativeUINode) { + Column(modifier = Modifier.fillMaxWidth()) { + node.children.forEach { NodeView(node = it) } + } +} + +/** + * Holds a page's header still while the pager swipes, by moving it against + * the page, in the draw phase. The pages beside each other are level, so the + * two headers on screen meet as one. + */ +private fun Modifier.pagerStationary(header: PagerHeader): Modifier = graphicsLayer { + translationX = (header.pagerState.currentPage + header.pagerState.currentPageOffsetFraction - header.page) * size.width +} diff --git a/resources/ios/NativeUIListRenderer.swift b/resources/ios/NativeUIListRenderer.swift index f401d6c..3babc4a 100644 --- a/resources/ios/NativeUIListRenderer.swift +++ b/resources/ios/NativeUIListRenderer.swift @@ -30,6 +30,9 @@ struct NativeUIListRenderer: View { let node: NativeUINode @Environment(\.defaultMinListRowHeight) private var minRowHeight + /// Set when this list is a page of a pager with a header, which the list + /// draws above its rows (see NativeUIPagerHeader). + @Environment(\.nativeUIPagerHeader) private var pagerHeader var body: some View { let horizontal = node.props.getBool("horizontal") @@ -45,7 +48,7 @@ struct NativeUIListRenderer: View { // Sectioned lists adopt the inset-grouped look by default (matching // SwiftUI's grouped style); `->plain()` opts back to plain rows. let hasSections = children.contains { $0.type == "list_section" } - let grouped = hasSections && !node.props.getBool("plain") + let grouped = hasSections && !node.props.getBool("plain") && pagerHeader == nil // Map each leaf row id → its global index across all sections, so // end-reached fires near the true bottom rather than when a section @@ -63,25 +66,48 @@ struct NativeUIListRenderer: View { } } else { List { - ForEach(children) { child in - if child.type == "list_section" { - let header = child.props.getString("header", default: "") - let footer = child.props.getString("footer", default: "") - Section { - ForEach(Array(child.children.enumerated()), id: \.element.id) { index, row in - rowView(row, separator: separator, isLastInSection: index == child.children.count - 1) - .onAppear(perform: Self.endReachedAction(at: leafIndex[row.id], leafCount: leafCount, - buffer: endReachedBuffer, cb: onEndReachedCb, nodeId: nodeId)) + if let pagerHeader { + // The header, then every row in one section under the + // pager's tabs, which pin once the header has gone. The + // rows of any list sections go in it too: a section in a + // section isn't a thing. + NativeUIPagerHeaderRow(header: pagerHeader) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + Section { + ForEach(Self.leafRows(children)) { row in + rowView(row, separator: separator) + .onAppear(perform: Self.endReachedAction(at: leafIndex[row.id], leafCount: leafCount, + buffer: endReachedBuffer, cb: onEndReachedCb, nodeId: nodeId)) + } + } header: { + if pagerHeader.hasTabs { + NativeUIPagerHeaderStrip(header: pagerHeader) + .listRowInsets(EdgeInsets()) + } + } + } else { + ForEach(children) { child in + if child.type == "list_section" { + let header = child.props.getString("header", default: "") + let footer = child.props.getString("footer", default: "") + Section { + ForEach(Array(child.children.enumerated()), id: \.element.id) { index, row in + rowView(row, separator: separator, isLastInSection: index == child.children.count - 1) + .onAppear(perform: Self.endReachedAction(at: leafIndex[row.id], leafCount: leafCount, + buffer: endReachedBuffer, cb: onEndReachedCb, nodeId: nodeId)) + } + } header: { + if !header.isEmpty { Text(header).nuiScaledFont(size: 13) } + } footer: { + if !footer.isEmpty { Text(footer).nuiScaledFont(size: 13) } } - } header: { - if !header.isEmpty { Text(header).nuiScaledFont(size: 13) } - } footer: { - if !footer.isEmpty { Text(footer).nuiScaledFont(size: 13) } + } else { + rowView(child, separator: separator) + .onAppear(perform: Self.endReachedAction(at: leafIndex[child.id], leafCount: leafCount, + buffer: endReachedBuffer, cb: onEndReachedCb, nodeId: nodeId)) } - } else { - rowView(child, separator: separator) - .onAppear(perform: Self.endReachedAction(at: leafIndex[child.id], leafCount: leafCount, - buffer: endReachedBuffer, cb: onEndReachedCb, nodeId: nodeId)) } } } @@ -98,9 +124,17 @@ struct NativeUIListRenderer: View { // static settings/forms where it does nothing. Android's renderer // already gates its PullToRefreshBox the same way. .modifier(ListRefreshModifier(onRefreshCb: onRefreshCb, nodeId: nodeId)) + .modifier(NativeUIPagerHeaderListModifier(header: pagerHeader)) + // Only this list draws the header, not one in its rows. + .environment(\.nativeUIPagerHeader, nil) } } + /// The rows of a list, with each list section's rows in its place. + private static func leafRows(_ children: [NativeUINode]) -> [NativeUINode] { + children.flatMap { $0.type == "list_section" ? $0.children : [$0] } + } + /// One list row: the node plus its leading/trailing swipe actions and /// row-level styling. Shared by both flat rows and section children. @ViewBuilder diff --git a/resources/ios/NativeUIPagerRenderer.swift b/resources/ios/NativeUIPagerRenderer.swift index b3aa069..cfe5a5c 100644 --- a/resources/ios/NativeUIPagerRenderer.swift +++ b/resources/ios/NativeUIPagerRenderer.swift @@ -28,7 +28,10 @@ struct NativeUIPagerRenderer: View { var body: some View { let p = node.props - let count = max(0, p.getInt("count", default: node.children.count)) + let horizontal = p.getBool("horizontal") + // The header and the overlay ride beside the pages; neither is one. + let pages = node.children.filter { $0.type != "pager_header" && $0.type != "pager_overlay" } + let count = max(0, p.getInt("count", default: pages.count)) let windowFrom = p.getInt("window_from") // `has_more` appends one loading page after the last loaded item — // a feed has no total, so the user can pull into the tail while @@ -39,8 +42,8 @@ struct NativeUIPagerRenderer: View { let pageByIndex: [Int: NativeUINode] = { var map: [Int: NativeUINode] = [:] - map.reserveCapacity(node.children.count) - for (offset, child) in node.children.enumerated() { + map.reserveCapacity(pages.count) + for (offset, child) in pages.enumerated() { map[windowFrom + offset] = child } return map @@ -51,12 +54,16 @@ struct NativeUIPagerRenderer: View { count: pageCount, loaded: count, requestedPage: requestedPage, - horizontal: p.getBool("horizontal"), + horizontal: horizontal, reportOnSettle: p.getBool("report_on_settle"), cbId: p.getCallbackId("on_page_change"), pageByIndex: pageByIndex, placeholders: p.getStringList("placeholders"), tabs: PagerTabs(props: p), + // Only a horizontal pager's pages scroll up and down under a header. + header: horizontal ? node.children.first { $0.type == "pager_header" } : nil, + overlay: node.children.first { $0.type == "pager_overlay" }, + background: PagerColor(light: node.style?.bgColor, dark: p.has("dark_bg_color") ? p.getColor("dark_bg_color", default: 0) : nil), a11yLabel: p.getString("a11y_label") ) } @@ -80,6 +87,12 @@ private struct PagerBody: View { let placeholders: [String] /// The tab row above the pages, if the pager has one. let tabs: PagerTabs? + /// The `pager_header`, which each page's list draws above the tabs. + let header: NativeUINode? + /// The `pager_overlay`, laid over the pages under the tabs. + let overlay: NativeUINode? + /// The pager's own background, behind the tabs a header pins. + let background: PagerColor let a11yLabel: String /// Programmatic-jump binding only. Reading it back is useless here: @@ -99,6 +112,8 @@ private struct PagerBody: View { /// Where the pager is, in pages, for the tab row. Held but never read /// here, so a swipe redraws the row and not the pages. @State private var progress: PagerProgress + /// Where the header is, and each page's list, when there's a header. + @State private var headerScroll = PagerHeaderScroll() /// Loaded-but-unshipped pages this far from the current one get their /// placeholder image; the rest stay empty. Wide enough that a fast @@ -106,7 +121,7 @@ private struct PagerBody: View { /// long feed never fetches stills it may never reach. static let placeholderRadius = 4 - init(nodeId: Int, count: Int, loaded: Int, requestedPage: Int, horizontal: Bool, reportOnSettle: Bool, cbId: Int, pageByIndex: [Int: NativeUINode], placeholders: [String], tabs: PagerTabs?, a11yLabel: String) { + init(nodeId: Int, count: Int, loaded: Int, requestedPage: Int, horizontal: Bool, reportOnSettle: Bool, cbId: Int, pageByIndex: [Int: NativeUINode], placeholders: [String], tabs: PagerTabs?, header: NativeUINode?, overlay: NativeUINode?, background: PagerColor, a11yLabel: String) { self.nodeId = nodeId self.count = count self.loaded = loaded @@ -117,6 +132,9 @@ private struct PagerBody: View { self.pageByIndex = pageByIndex self.placeholders = placeholders self.tabs = tabs + self.header = header + self.overlay = overlay + self.background = background self.a11yLabel = a11yLabel _position = State(initialValue: requestedPage) _leading = State(initialValue: requestedPage) @@ -126,12 +144,32 @@ private struct PagerBody: View { } var body: some View { - VStack(spacing: 0) { - if let tabs { - PagerTabStrip(tabs: tabs, progress: progress, select: selectTab) - } + if header != nil { + // The pages' lists draw the header and the tabs themselves, so + // they scroll with the page (see NativeUIPagerHeader). pager + .overlay(alignment: .top) { overlayView } .modifier(PagerA11yLabelModifier(label: a11yLabel)) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in + headerScroll.pageWidth = frame.width + headerScroll.pagerMinY = frame.minY + } + } else { + VStack(spacing: 0) { + if let tabs { + PagerTabStrip(tabs: tabs, progress: progress, select: selectTab) + } + pager + .overlay(alignment: .top) { overlayView } + .modifier(PagerA11yLabelModifier(label: a11yLabel)) + } + } + } + + @ViewBuilder + private var overlayView: some View { + if let overlay { + PagerOverlayView(node: overlay, scroll: header == nil ? nil : headerScroll) } } @@ -191,6 +229,8 @@ private struct PagerBody: View { if !reportOnSettle { report(page) } + // A header follows the page nearest the leading edge. + headerScroll.lead(page) } // The same position, unrounded, for the tab row. Nil until the // pager has a size, so the row doesn't flash the first tab. @@ -258,6 +298,7 @@ private struct PagerBody: View { Group { if let child = pageByIndex[index] { NodeView(node: child).equatable() + .environment(\.nativeUIPagerHeader, header.map { pagerHeader(page: index, node: $0) }) } else if index >= loaded { ProgressView() .controlSize(.large) @@ -277,6 +318,12 @@ private struct PagerBody: View { } } + /// What page `index` gets of the header: the same object for the life + /// of the pager, so a render only redraws the parts of it that changed. + private func pagerHeader(page index: Int, node: NativeUINode) -> NativeUIPagerHeader { + headerScroll.header(page: index, node: node, tabs: tabs, background: background, progress: progress, select: selectTab) + } + /// A tap on a tab: scroll there, and the page is reported when the /// pager settles as for a swipe. The tab already showing goes to PHP /// instead, usually to take its page back to the top. @@ -340,7 +387,7 @@ private final class PagerProgress { /// The `tabs` row and its look, read from the pager's props. Colours are /// ARGB and nil where PHP sent none, so the row falls back to the theme. -private struct PagerTabs { +private struct PagerTabs: Equatable { let labels: [String] let a11yLabels: [String] let scrollable: Bool @@ -386,6 +433,14 @@ private struct PagerTabs { self.paddingBottom = points("tab_padding_bottom", 11) self.edgePadding = points("tab_edge_padding", 4) } + + static func == (lhs: PagerTabs, rhs: PagerTabs) -> Bool { + lhs.labels == rhs.labels && lhs.a11yLabels == rhs.a11yLabels && lhs.scrollable == rhs.scrollable + && lhs.reselectCbId == rhs.reselectCbId && lhs.color == rhs.color && lhs.activeColor == rhs.activeColor + && lhs.indicatorColor == rhs.indicatorColor && lhs.dividerColor == rhs.dividerColor && lhs.font == rhs.font + && lhs.fontSize == rhs.fontSize && lhs.indicatorHeight == rhs.indicatorHeight && lhs.paddingX == rhs.paddingX + && lhs.paddingTop == rhs.paddingTop && lhs.paddingBottom == rhs.paddingBottom && lhs.edgePadding == rhs.edgePadding + } } /// The row of tabs above a pager's pages. It reads the pager's position @@ -524,3 +579,363 @@ private struct PagerTabStrip: View { return argb.map { Color(argb: $0) } ?? fallback } } + +// MARK: - Header + +/// A colour as PHP sent it, light and dark, nil where it sent none. +struct PagerColor: Equatable { + let light: Int? + let dark: Int? + + init(light: Int?, dark: Int?) { + self.light = light == 0 ? nil : light + self.dark = dark == 0 ? nil : dark + } + + func resolve(_ colorScheme: ColorScheme, fallback: Color) -> Color { + let argb = colorScheme == .dark ? (dark ?? light) : light + + return argb.map { Color(argb: $0) } ?? fallback + } +} + +/// A pager's `pager_header`, as one of its pages gets it through the +/// environment. The page's list draws it (NativeUIListRenderer): the header +/// as its first row, then the pager's tabs as the header of a section +/// holding every other row, so the tabs pin once the header has scrolled +/// away. Both are held still while the pager swipes, so the pages seem to +/// slide under them. +/// +/// One per page for the life of the pager. A render changes its properties +/// in place, which redraws only the views that read them. +@Observable +final class NativeUIPagerHeader { + let page: Int + var node: NativeUINode + fileprivate var tabs: PagerTabs? + var background: PagerColor + fileprivate let progress: PagerProgress + @ObservationIgnored fileprivate var select: (Int) -> Void + let scroll: PagerHeaderScroll + + fileprivate init(page: Int, node: NativeUINode, tabs: PagerTabs?, background: PagerColor, progress: PagerProgress, select: @escaping (Int) -> Void, scroll: PagerHeaderScroll) { + self.page = page + self.node = node + self.tabs = tabs + self.background = background + self.progress = progress + self.select = select + self.scroll = scroll + } + + /// The tabs, drawn as the pager would draw them above its pages. + var hasTabs: Bool { tabs != nil } + + fileprivate var strip: some View { + Group { + if let tabs { + PagerTabStrip(tabs: tabs, progress: progress, select: select) + } + } + } + + /// How far to move this page's header so it stays where the pager is: + /// the page is `progress - page` pages off to the left. + fileprivate var stationaryOffset: CGFloat { + (progress.value - CGFloat(page)) * scroll.pageWidth + } +} + +private struct NativeUIPagerHeaderKey: EnvironmentKey { + static let defaultValue: NativeUIPagerHeader? = nil +} + +extension EnvironmentValues { + /// The header the page's list draws, if the page is in a pager with one. + /// A list clears it for its rows, so only the page's own list draws it. + var nativeUIPagerHeader: NativeUIPagerHeader? { + get { self[NativeUIPagerHeaderKey.self] } + set { self[NativeUIPagerHeaderKey.self] = newValue } + } +} + +/// How far the header has scrolled, from the page showing, and each page's +/// list, so the pages beside it can be kept level with it. +/// +/// While any of the header shows, every list scrolls with it: a swipe finds +/// the next page's header at the same height, so the two meet seamlessly. +/// Once it has gone, each list keeps its own place, and only a list higher +/// than the header has gone is brought down to where it pins the tabs. +/// +/// The lists are moved through their UIKit scroll views, found from inside +/// the header row (PagerHeaderScrollViewFinder). SwiftUI gives no way to +/// set a List's offset. +@Observable +final class PagerHeaderScroll { + /// The header's height, without the tabs. + var headerHeight: CGFloat = 0 + /// How much of the header has scrolled away, 0 to `headerHeight`. Below + /// 0 the page showing is being pulled down, and the header with it. + var collapse: CGFloat = 0 + var pageWidth: CGFloat = 0 + /// The page the header follows. + private(set) var shownPage = 0 + /// Where each page's tabs end on screen, and where the pager starts. + var stripMaxY: [Int: CGFloat] = [:] + var pagerMinY: CGFloat = 0 + + @ObservationIgnored private var leading = 0 { + didSet { shownPage = leading } + } + @ObservationIgnored private var offsets: [Int: CGFloat] = [:] + @ObservationIgnored private var scrollViews: [Int: WeakScrollView] = [:] + @ObservationIgnored private var headers: [Int: NativeUIPagerHeader] = [:] + + private struct WeakScrollView { + weak var view: UIScrollView? + } + + fileprivate func header(page: Int, node: NativeUINode, tabs: PagerTabs?, background: PagerColor, progress: PagerProgress, select: @escaping (Int) -> Void) -> NativeUIPagerHeader { + guard let header = headers[page] else { + let header = NativeUIPagerHeader(page: page, node: node, tabs: tabs, background: background, progress: progress, select: select, scroll: self) + headers[page] = header + return header + } + + // Only what changed, so an unchanged header doesn't redraw. + if header.node !== node { header.node = node } + if header.tabs != tabs { header.tabs = tabs } + if header.background != background { header.background = background } + header.select = select + + return header + } + + /// Where the bottom of the tabs is, below the pager's top edge, as the + /// page showing has them on screen. + var stripBottom: CGFloat { + max(0, (stripMaxY[shownPage] ?? pagerMinY) - pagerMinY) + } + + /// The page nearest the pager's leading edge, which the header follows. + func lead(_ page: Int) { + guard page != leading else { return } + + leading = page + follow(offsets[page] ?? 0) + } + + /// A page's list, found or found again in a fresh list. A page that isn't + /// showing is brought level with the header before it can be seen. + func attach(_ scrollView: UIScrollView, page: Int) { + guard scrollViews[page]?.view !== scrollView else { return } + + scrollViews[page] = WeakScrollView(view: scrollView) + offsets[page] = scrollView.contentOffset.y + scrollView.adjustedContentInset.top + + if page != leading { + align(page) + } + } + + /// A page's list scrolled to `y`, its distance from its own top. + func scrolled(page: Int, y: CGFloat) { + offsets[page] = y + + if page == leading { + follow(y) + } + } + + private func follow(_ y: CGFloat) { + let collapse = min(y, headerHeight) + + guard collapse != self.collapse else { return } + + self.collapse = collapse + + for page in scrollViews.keys where page != leading { + align(page) + } + } + + private func align(_ page: Int) { + guard headerHeight > 0, let scrollView = scrollViews[page]?.view else { return } + + let own = offsets[page] ?? 0 + let shown = max(0, collapse) + let target = shown < headerHeight ? shown : max(own, headerHeight) + + guard abs(target - own) > 0.5 else { return } + + offsets[page] = target + scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: target - scrollView.adjustedContentInset.top), animated: false) + } +} + +/// The header as a page's list draws it: its first row. +struct NativeUIPagerHeaderRow: View { + let header: NativeUIPagerHeader + + var body: some View { + PagerChildren(node: header.node) + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in + if header.scroll.headerHeight != height { header.scroll.headerHeight = height } + } + .background(PagerHeaderScrollViewFinder { header.scroll.attach($0, page: header.page) }) + .modifier(PagerStationary(header: header)) + } +} + +/// The pager's tabs as a page's list draws them: the header of the section +/// below the header row, which pins under the nav bar. +struct NativeUIPagerHeaderStrip: View { + let header: NativeUIPagerHeader + + @ObservedObject private var themeStore = NativeUITheme.shared + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + header.strip + .frame(maxWidth: .infinity) + .background(header.background.resolve(colorScheme, fallback: themeStore.resolve(for: colorScheme).surface)) + .onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).maxY } action: { maxY in + if header.scroll.stripMaxY[header.page] != maxY { header.scroll.stripMaxY[header.page] = maxY } + } + .modifier(PagerStationary(header: header)) + } +} + +/// Holds a page's header still while the pager swipes, by moving it against +/// the page. The pages beside each other are level, so the halves of the two +/// headers on screen meet as one. +private struct PagerStationary: ViewModifier { + let header: NativeUIPagerHeader + + func body(content: Content) -> some View { + content.offset(x: header.stationaryOffset) + } +} + +/// What a page's list does under a pager header: it tells the header where +/// it is, and makes room at its end for the header to scroll away. +struct NativeUIPagerHeaderListModifier: ViewModifier { + let header: NativeUIPagerHeader? + + /// Space after the last row, so a short list can still scroll the + /// header away and keep level with the others. + @State private var room: CGFloat = 0 + + func body(content: Content) -> some View { + if let header { + content + // Section headers get a minimum height and space above. + .environment(\.defaultMinListHeaderHeight, 0) + .listSectionSpacing(0) + .contentMargins(.bottom, room, for: .scrollContent) + .onScrollGeometryChange(for: PagerListGeometry.self) { geometry in + PagerListGeometry( + y: geometry.contentOffset.y + geometry.contentInsets.top, + top: geometry.contentInsets.top, + bottom: geometry.contentInsets.bottom, + content: geometry.contentSize.height, + container: geometry.containerSize.height + ) + } action: { _, geometry in + header.scroll.scrolled(page: header.page, y: geometry.y) + + // The room this list needs to scroll the header away, past + // the room it already has. + let needed = max(0, header.scroll.headerHeight + geometry.container - geometry.content - geometry.top - (geometry.bottom - room)) + if abs(needed - room) > 0.5 { + room = needed + } + } + } else { + content + } + } +} + +private struct PagerListGeometry: Equatable { + let y: CGFloat + let top: CGFloat + let bottom: CGFloat + let content: CGFloat + let container: CGFloat +} + +/// The pager's `pager_overlay`, hung under the tabs. Under a header it +/// follows the tabs as the header scrolls. +private struct PagerOverlayView: View { + let node: NativeUINode + let scroll: PagerHeaderScroll? + + var body: some View { + PagerChildren(node: node) + .frame(maxWidth: .infinity) + .offset(y: scroll?.stripBottom ?? 0) + } +} + +/// What a `pager_header` or `pager_overlay` holds, stacked. Their own +/// renderer draws nothing: the pager places them. +private struct PagerChildren: View { + let node: NativeUINode + + var body: some View { + VStack(spacing: 0) { + ForEach(node.children) { child in + NodeView(node: child).equatable() + } + } + } +} + +/// Finds the UIKit scroll view a view is drawn in, here the List's, so the +/// pager can scroll it. +private struct PagerHeaderScrollViewFinder: UIViewRepresentable { + let found: (UIScrollView) -> Void + + func makeUIView(context: Context) -> FinderView { + let view = FinderView() + view.found = found + return view + } + + func updateUIView(_ view: FinderView, context: Context) { + view.found = found + view.find() + } + + final class FinderView: UIView { + var found: ((UIScrollView) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + isHidden = true + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func didMoveToWindow() { + super.didMoveToWindow() + find() + } + + func find() { + guard window != nil else { return } + + var view = superview + while let current = view { + if let scrollView = current as? UIScrollView { + found?(scrollView) + return + } + view = current.superview + } + } + } +} diff --git a/src/Components/PagerHeader.php b/src/Components/PagerHeader.php new file mode 100644 index 0000000..c108658 --- /dev/null +++ b/src/Components/PagerHeader.php @@ -0,0 +1,15 @@ +pagerProps; if (! isset($props['count'])) { - $props['count'] = count($this->children); + $props['count'] = count(array_filter($this->children, fn (Element $child): bool => ! $child instanceof PagerHeader && ! $child instanceof PagerOverlay)); } if ($this->pageCallback !== null) { diff --git a/src/Elements/PagerHeader.php b/src/Elements/PagerHeader.php new file mode 100644 index 0000000..d286e9e --- /dev/null +++ b/src/Elements/PagerHeader.php @@ -0,0 +1,51 @@ + + * ... + * ... + * ... + * + * + * The header scrolls up with the page you're on, and the tabs pin under it + * once it has gone. The pages swipe beneath the tabs, each its own list, + * while the header and the tabs stay where they are. So every page needs a + * list: the header and the tabs are its first rows, and pull to refresh + * pulls them down with it. + * + * The pages scroll together while any of the header shows, so a swipe + * always finds the header at the height it left it. Once the header has + * gone, each page keeps its own place. A page too short to scroll the + * header away gets room at its end to do so. + */ +class PagerHeader extends Element +{ + protected string $type = 'pager_header'; + + public static function make(Element ...$children): static + { + $el = new static; + $el->children = $children; + + return $el; + } + + public function applyAttributes(array $attrs): void + { + $this->applyA11yAttributes($attrs); + } + + protected function resolveProps(CallbackRegistry $registry): array + { + return []; + } +} diff --git a/src/Elements/PagerOverlay.php b/src/Elements/PagerOverlay.php new file mode 100644 index 0000000..bacbec4 --- /dev/null +++ b/src/Elements/PagerOverlay.php @@ -0,0 +1,39 @@ +children = $children; + + return $el; + } + + public function applyAttributes(array $attrs): void + { + $this->applyA11yAttributes($attrs); + } + + protected function resolveProps(CallbackRegistry $registry): array + { + return []; + } +} diff --git a/tests/PagerHeaderTest.php b/tests/PagerHeaderTest.php new file mode 100644 index 0000000..51a43de --- /dev/null +++ b/tests/PagerHeaderTest.php @@ -0,0 +1,121 @@ + true, 'tabs' => ['Posts', 'Replies']]); + NativeElementCollector::open('pager_header', []); + NativeElementCollector::open('text', ['text' => 'Ada']); + NativeElementCollector::close(); + NativeElementCollector::close(); + NativeElementCollector::open('column', []); + NativeElementCollector::close(); + NativeElementCollector::open('column', []); + NativeElementCollector::close(); + NativeElementCollector::open('pager_overlay', []); + NativeElementCollector::close(); + NativeElementCollector::close(); + + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect(array_column($tree['children'], 'type'))->toBe(['pager_header', 'column', 'column', 'pager_overlay']) + ->and($tree['props']['count'])->toBe(2) + ->and($tree['children'][0]['props'] ?? [])->toBe([]) + ->and($tree['children'][0]['children'][0]['type'])->toBe('text'); +}); + +it('builds the same pager fluently', function () { + $tree = Pager::make( + PagerHeader::make(Text::make('Ada')), + Column::make(Text::make('Posts')), + Column::make(Text::make('Replies')), + PagerOverlay::make(Text::make('3 new posts')), + )->horizontal()->tabs(['Posts', 'Replies'])->toArray(new CallbackRegistry); + + expect($tree['props']['count'])->toBe(2) + ->and(array_column($tree['children'], 'type'))->toBe(['pager_header', 'column', 'column', 'pager_overlay']); +}); + +it('registers the header and the overlay for the pager to draw', function () { + $components = collect(json_decode(file_get_contents(dirname(__DIR__).'/nativephp.json'), true)['components'])->keyBy('type'); + + foreach (['pager_header' => 'PagerHeader', 'pager_overlay' => 'PagerOverlay'] as $type => $class) { + expect($components[$type])->toMatchArray([ + 'element' => "Native\\Mobile\\UI\\Elements\\{$class}", + 'blade' => "Native\\Mobile\\UI\\Components\\{$class}", + 'android_renderer' => 'com.nativephp.plugins.native_ui.ui.EmptyRenderer', + 'ios_renderer' => 'NativeUIEmptyRenderer', + 'self_closing' => false, + ]); + } +}); + +it('keeps the header and the overlay out of the pages on both platforms', function () { + expect(swiftSource('NativeUIPagerRenderer.swift')) + ->toContain('let pages = node.children.filter { $0.type != "pager_header" && $0.type != "pager_overlay" }') + ->toContain('header: horizontal ? node.children.first { $0.type == "pager_header" } : nil'); + + expect(kotlinSource('PagerRenderer.kt')) + ->toContain('node.children.filter { it.type != "pager_header" && it.type != "pager_overlay" }') + ->toContain('val header = if (horizontal) node.children.firstOrNull { it.type == "pager_header" } else null'); +}); + +it("draws the header as a page list's first row and pins the tabs under it", function () { + expect(swiftSource('NativeUIListRenderer.swift')) + ->toContain('@Environment(\.nativeUIPagerHeader) private var pagerHeader') + ->toMatch('/NativeUIPagerHeaderRow\(header: pagerHeader\)[\s\S]*?Section \{[\s\S]*?\} header: \{[\s\S]*?NativeUIPagerHeaderStrip\(header: pagerHeader\)/') + ->toContain('.environment(\.nativeUIPagerHeader, nil)'); + + expect(kotlinSource('ListRenderer.kt')) + ->toContain('val pagerHeader = if (horizontal) null else LocalPagerHeader.current') + ->toMatch('/item\(key = PAGER_HEADER_KEY\) \{ PagerHeaderRow\(pagerHeader\) \}\s*if \(pagerHeader.hasTabs\) \{\s*stickyHeader\(key = PAGER_TABS_KEY\)/'); +}); + +it('holds the header still while the pager swipes', function () { + expect(swiftSource('NativeUIPagerRenderer.swift')) + ->toContain('(progress.value - CGFloat(page)) * scroll.pageWidth') + ->toContain('content.offset(x: header.stationaryOffset)'); + + expect(kotlinSource('PagerRenderer.kt')) + ->toContain('translationX = (header.pagerState.currentPage + header.pagerState.currentPageOffsetFraction - header.page) * size.width'); +}); + +it('keeps the pages level while any of the header shows, and lets each keep its place once it has gone', function () { + expect(swiftSource('NativeUIPagerRenderer.swift')) + ->toContain('let target = shown < headerHeight ? shown : max(own, headerHeight)'); + + expect(kotlinSource('PagerRenderer.kt')) + ->toContain('val target = if (collapse < height) collapse else maxOf(own, height)'); +});