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)'); +});