From 691a10b8b711291800fe05f2bee6303f8f692c40 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 01:59:28 +0100 Subject: [PATCH] Draw a pager's tabs natively, following the swipe A screen that pages between tabs has had to draw the tab row in PHP. That row can only change when PHP sends a frame, so it lags the swipe, and sending a frame mid-swipe is what makes the swipe stutter. tabs="[...]" draws the row natively above the pages, one label per page. The row reads the pager's scroll position every frame. The indicator slides between the labels either side of it, and the highlight moves to the nearest page, so it changes as the swipe passes halfway. Tapping a tab scrolls the pager there and the page is reported when it settles, as for a swipe. Tapping the tab already showing calls on-tab-reselect with its index instead, so the page can go back to its top. By default the tabs share the row equally. tabs-scrollable makes them as wide as their labels in a row that scrolls to keep the current one in the middle. Colours default to the theme (on-surface, on-surface-variant, primary, outline-variant). The tab-* attributes override the colours, font, indicator thickness and padding per pager, and a theme- colour sends its dark shade too. iOS keeps the position in an observable that only the row reads, so a swipe redraws the row and not the pages. Android draws the indicator from currentPage plus currentPageOffsetFraction in the draw phase. The tabs are tabs to VoiceOver and TalkBack, with the selected one marked. Co-Authored-By: Claude Opus 5.5 --- resources/android/PagerRenderer.kt | 269 ++++++++++++++++++++-- resources/ios/NativeUIPagerRenderer.swift | 260 ++++++++++++++++++++- src/Elements/Pager.php | 240 +++++++++++++++++++ tests/PagerTabsTest.php | 174 ++++++++++++++ 4 files changed, 927 insertions(+), 16 deletions(-) create mode 100644 tests/PagerTabsTest.php diff --git a/resources/android/PagerRenderer.kt b/resources/android/PagerRenderer.kt index 2c1907d..d1378d9 100644 --- a/resources/android/PagerRenderer.kt +++ b/resources/android/PagerRenderer.kt @@ -1,33 +1,69 @@ package com.nativephp.plugins.native_ui.ui +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.isSystemInDarkTheme 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.VerticalPager import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +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.LaunchedEffect 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.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +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.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +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 coil3.compose.AsyncImage import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nativephp.mobile.ui.nativerender.GenericProps +import com.nativephp.mobile.ui.nativerender.KeyboardFocusPolicy import com.nativephp.mobile.ui.nativerender.LocalAvailableHeight 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 kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlin.math.floor +import kotlin.math.min +import kotlin.math.roundToInt /** * Full-screen snap pager (`pager`). Compose's `VerticalPager` / @@ -45,6 +81,12 @@ import kotlinx.coroutines.flow.distinctUntilChanged * the `page` prop only moves it when PHP sends an index that differs from * the one native last reported (a programmatic jump). * + * With `tabs`, a row of tabs sits above the pages ([PagerTabStrip]). It + * reads `currentPage` and `currentPageOffsetFraction` as the pager moves, + * so its indicator follows the swipe and its highlight moves as the swipe + * passes halfway, without PHP. A tap scrolls the pager there; a tap on the + * tab already showing goes to PHP as `on_tab_reselect`. + * * See `src/Elements/Pager.php` for the matching element class. */ object PagerRenderer { @@ -64,6 +106,8 @@ object PagerRenderer { 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) @@ -142,21 +186,177 @@ object PagerRenderer { } } - if (horizontal) { - HorizontalPager( - state = pagerState, - modifier = sized, - beyondViewportPageCount = 1, - key = { it }, - ) { index -> page(index) } + val pages: @Composable (Modifier) -> Unit = { pagesModifier -> + if (horizontal) { + HorizontalPager( + state = pagerState, + modifier = pagesModifier, + beyondViewportPageCount = 1, + key = { it }, + ) { index -> page(index) } + } else { + VerticalPager( + state = pagerState, + modifier = pagesModifier, + beyondViewportPageCount = 1, + key = { it }, + ) { index -> page(index) } + } + } + + if (tabs == null) { + pages(sized) + } 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)) + } + } + } + } + + /** + * The row of tabs above a pager's pages. The indicator is drawn from the + * pager's position (`currentPage + currentPageOffsetFraction`) between + * the labels either side of it, in the draw phase, so a swipe redraws it + * without recomposing. The highlight is `currentPage`, the nearest page, + * so it moves as the swipe passes halfway. A scrollable row also scrolls + * itself to keep the indicator in view. Nothing here waits for PHP. + */ + @Composable + private 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 + val last = tabs.labels.size - 1 + + // Each label's left edge and width, and the row's left edge, all in + // root coordinates, so they can be measured in any order. + val labelBounds = remember { mutableStateMapOf>() } + var rowLeft by remember { mutableFloatStateOf(0f) } + var viewportWidth by remember { mutableIntStateOf(0) } + val scrollState = rememberScrollState() + + // The indicator's left edge in the row and its width: between the + // labels of the pages either side of the pager's position, in + // proportion to how far it is from one to the other. + fun indicator(): Pair? { + val position = (pagerState.currentPage + pagerState.currentPageOffsetFraction).coerceIn(0f, last.toFloat()) + val from = floor(position).toInt() + val to = min(from + 1, last) + val a = labelBounds[from] ?: return null + val b = labelBounds[to] ?: return null + val t = position - from + + return (a.first + (b.first - a.first) * t - rowLeft) to (a.second + (b.second - a.second) * t) + } + + if (tabs.scrollable) { + // Keeps the indicator in the middle of the row, as far as the + // row's ends allow. + LaunchedEffect(pagerState, tabs.edgePadding) { + val edge = with(density) { tabs.edgePadding.dp.toPx() } + snapshotFlow { indicator() to viewportWidth } + .collect { (frame, width) -> + if (frame != null && width > 0) { + val target = edge + frame.first + frame.second / 2 - width / 2f + scrollState.scrollTo(target.roundToInt().coerceIn(0, scrollState.maxValue)) + } + } + } + } + + val active = pagerState.currentPage.coerceIn(0, last) + val fontFamily = nuiNodeFontFamily(tabs.font) + val fontSize = tabs.fontSize?.sp ?: theme.fontMd + val activeColor = tabs.activeColor.resolve(isDark, theme.onSurface) + val inactiveColor = tabs.color.resolve(isDark, theme.onSurfaceVariant) + val indicatorColor = tabs.indicatorColor.resolve(isDark, theme.primary) + val indicatorHeight = tabs.indicatorHeight.dp + + val row: @Composable () -> Unit = { + Row( + modifier = Modifier + .then(if (tabs.scrollable) Modifier.padding(horizontal = tabs.edgePadding.dp) else Modifier.fillMaxWidth()) + .onGloballyPositioned { rowLeft = it.positionInRoot().x } + .drawBehind { + val (left, width) = indicator() ?: return@drawBehind + val height = indicatorHeight.toPx() + drawRoundRect( + color = indicatorColor, + topLeft = Offset(left, size.height - height), + size = Size(width, height), + cornerRadius = CornerRadius(height / 2), + ) + } + .selectableGroup(), + ) { + tabs.labels.forEachIndexed { index, label -> + val a11yLabel = tabs.a11yLabels.getOrNull(index).orEmpty() + + Box( + modifier = (if (tabs.scrollable) Modifier else Modifier.weight(1f)) + .heightIn(min = 48.dp) + .selectable(selected = index == active, role = Role.Tab, onClick = { onSelect(index) }) + .then(if (a11yLabel.isNotEmpty()) Modifier.semantics { contentDescription = a11yLabel } else Modifier) + .padding( + start = tabs.paddingX.dp, + end = tabs.paddingX.dp, + top = tabs.paddingTop.dp, + bottom = tabs.paddingBottom.dp + indicatorHeight, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = if (index == active) activeColor else inactiveColor, + fontSize = fontSize, + fontFamily = fontFamily, + // A bundled font file is one weight already. + fontWeight = if (fontFamily == null) FontWeight.SemiBold else null, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.onGloballyPositioned { + labelBounds[index] = it.positionInRoot().x to it.size.width.toFloat() + }, + ) + } + } + } + } + + Column(modifier = Modifier.fillMaxWidth()) { + if (tabs.scrollable) { + Box( + modifier = Modifier + .fillMaxWidth() + .onSizeChanged { viewportWidth = it.width } + .horizontalScroll(scrollState), + ) { row() } } else { - VerticalPager( - state = pagerState, - modifier = sized, - beyondViewportPageCount = 1, - key = { it }, - ) { index -> page(index) } + row() } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(tabs.dividerColor.resolve(isDark, theme.outlineVariant)), + ) } } @@ -189,3 +389,46 @@ 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) { + val labels: List = p.getStringList("tabs") + val a11yLabels: List = p.getStringList("tab_a11y_labels") + val scrollable = p.getBool("tabs_scrollable", false) + val reselectCbId = p.getCallbackId("on_tab_reselect") + val color = PagerTabColor.from(p, "tab_color") + val activeColor = PagerTabColor.from(p, "tab_active_color") + val indicatorColor = PagerTabColor.from(p, "tab_indicator_color") + val dividerColor = PagerTabColor.from(p, "tab_divider_color") + val font = p.getString("tab_font") + val fontSize: Float? = if (p.has("tab_font_size")) p.getFloat("tab_font_size") else null + val indicatorHeight = p.getFloat("tab_indicator_height", 3f) + val paddingX = p.getFloat("tab_padding_x", 12f) + val paddingTop = p.getFloat("tab_padding_top", 14f) + val paddingBottom = p.getFloat("tab_padding_bottom", 11f) + val edgePadding = p.getFloat("tab_edge_padding", 4f) + + companion object { + fun from(p: GenericProps): PagerTabs? = + if (p.getStringList("tabs").isEmpty()) null else PagerTabs(p) + } +} + +/** 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?) { + fun resolve(isDark: Boolean, fallback: Color): Color { + val argb = if (isDark) dark ?: light else light + + return argb?.let { Color(it) } ?: fallback + } + + companion object { + fun from(p: GenericProps, key: String) = PagerTabColor( + if (p.has(key)) p.getColor(key, 0) else null, + if (p.has("dark_$key")) p.getColor("dark_$key", 0) else null, + ) + } +} diff --git a/resources/ios/NativeUIPagerRenderer.swift b/resources/ios/NativeUIPagerRenderer.swift index 1812478..8f79e25 100644 --- a/resources/ios/NativeUIPagerRenderer.swift +++ b/resources/ios/NativeUIPagerRenderer.swift @@ -16,6 +16,12 @@ import SwiftUI /// prop only moves it when PHP sends an index that differs from the one /// native last reported (a programmatic jump). /// +/// With `tabs`, a row of tabs sits above the pages (`PagerTabStrip`). It +/// reads the pager's scroll position every frame, so its indicator follows +/// the swipe and its highlight moves as the swipe passes halfway, without +/// PHP. A tap scrolls the pager there; a tap on the tab already showing +/// goes to PHP as `on_tab_reselect`. +/// /// See `src/Elements/Pager.php` for the matching element class. struct NativeUIPagerRenderer: View { let node: NativeUINode @@ -49,9 +55,10 @@ struct NativeUIPagerRenderer: View { reportOnSettle: p.getBool("report_on_settle"), cbId: p.getCallbackId("on_page_change"), pageByIndex: pageByIndex, - placeholders: p.getStringList("placeholders") + placeholders: p.getStringList("placeholders"), + tabs: PagerTabs(props: p), + a11yLabel: p.getString("a11y_label") ) - .modifier(PagerA11yLabelModifier(label: p.getString("a11y_label"))) } } @@ -71,6 +78,9 @@ private struct PagerBody: View { let pageByIndex: [Int: NativeUINode] /// Image per page index for pages PHP hasn't shipped; "" for none. let placeholders: [String] + /// The tab row above the pages, if the pager has one. + let tabs: PagerTabs? + let a11yLabel: String /// Programmatic-jump binding only. Reading it back is useless here: /// under `.paging` it goes nil the moment the pager moves. @@ -86,6 +96,9 @@ private struct PagerBody: View { /// report the same page a second time, repeating any pagination the /// handler does. @State private var lastReported: Int + /// 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 /// Loaded-but-unshipped pages this far from the current one get their /// placeholder image; the rest stay empty. Wide enough that a fast @@ -93,7 +106,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]) { + init(nodeId: Int, count: Int, loaded: Int, requestedPage: Int, horizontal: Bool, reportOnSettle: Bool, cbId: Int, pageByIndex: [Int: NativeUINode], placeholders: [String], tabs: PagerTabs?, a11yLabel: String) { self.nodeId = nodeId self.count = count self.loaded = loaded @@ -103,13 +116,27 @@ private struct PagerBody: View { self.cbId = cbId self.pageByIndex = pageByIndex self.placeholders = placeholders + self.tabs = tabs + self.a11yLabel = a11yLabel _position = State(initialValue: requestedPage) _leading = State(initialValue: requestedPage) _reported = State(initialValue: [requestedPage]) _lastReported = State(initialValue: requestedPage) + _progress = State(initialValue: PagerProgress(CGFloat(requestedPage))) } var body: some View { + VStack(spacing: 0) { + if let tabs { + PagerTabStrip(tabs: tabs, progress: progress, select: selectTab) + } + pager + .modifier(PagerA11yLabelModifier(label: a11yLabel)) + } + } + + @ViewBuilder + private var pager: some View { let axis: Axis.Set = horizontal ? .horizontal : .vertical // Pages are sized to the pager's measured frame — the visible @@ -148,6 +175,18 @@ private struct PagerBody: View { report(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. + .onScrollGeometryChange(for: CGFloat?.self) { geometry in + let visible = geometry.visibleRect + let length = horizontal ? visible.width : visible.height + guard length > 0 else { return nil } + return (horizontal ? visible.minX : visible.minY) / length + } action: { _, value in + if tabs != nil, let value { + progress.value = value + } + } .onScrollPhaseChange { _, phase in guard phase == .idle else { return } report(leading) @@ -213,6 +252,22 @@ private struct PagerBody: View { } } + /// 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. + private func selectTab(_ index: Int) { + guard index >= 0, index < count else { return } + + if index == leading, abs(progress.value - CGFloat(index)) < 0.01 { + if let cb = tabs?.reselectCbId, cb != 0 { + NativeElementBridge.sendTabChangeEvent(cb, nodeId: nodeId, index: index) + } + return + } + + withAnimation { position = index } + } + private func report(_ index: Int) { guard cbId != 0, lastReported != index else { return } lastReported = index @@ -245,3 +300,202 @@ private struct PagerA11yLabelModifier: ViewModifier { if label.isEmpty { content } else { content.accessibilityLabel(label) } } } + +/// The pager's scroll position in pages: 1.5 is halfway from the second +/// page to the third. Observable so only the views that read it (the tab +/// row) redraw as it changes every frame. +@Observable +private final class PagerProgress { + var value: CGFloat + + init(_ value: CGFloat) { + self.value = value + } +} + +/// 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 { + let labels: [String] + let a11yLabels: [String] + let scrollable: Bool + let reselectCbId: Int + let color: (light: Int?, dark: Int?) + let activeColor: (light: Int?, dark: Int?) + let indicatorColor: (light: Int?, dark: Int?) + let dividerColor: (light: Int?, dark: Int?) + let font: String + let fontSize: CGFloat? + let indicatorHeight: CGFloat + let paddingX: CGFloat + let paddingTop: CGFloat + let paddingBottom: CGFloat + let edgePadding: CGFloat + + init?(props p: GenericProps) { + let labels = p.getStringList("tabs") + guard !labels.isEmpty else { return nil } + + func color(_ key: String) -> (light: Int?, dark: Int?) { + (p.has(key) ? p.getColor(key, default: 0) : nil, + p.has("dark_" + key) ? p.getColor("dark_" + key, default: 0) : nil) + } + + func points(_ key: String, _ fallback: CGFloat) -> CGFloat { + p.has(key) ? CGFloat(p.getFloat(key)) : fallback + } + + self.labels = labels + self.a11yLabels = p.getStringList("tab_a11y_labels") + self.scrollable = p.getBool("tabs_scrollable") + self.reselectCbId = p.getCallbackId("on_tab_reselect") + self.color = color("tab_color") + self.activeColor = color("tab_active_color") + self.indicatorColor = color("tab_indicator_color") + self.dividerColor = color("tab_divider_color") + self.font = p.getString("tab_font") + self.fontSize = p.has("tab_font_size") ? CGFloat(p.getFloat("tab_font_size")) : nil + self.indicatorHeight = points("tab_indicator_height", 3) + self.paddingX = points("tab_padding_x", 12) + self.paddingTop = points("tab_padding_top", 14) + self.paddingBottom = points("tab_padding_bottom", 11) + self.edgePadding = points("tab_edge_padding", 4) + } +} + +/// The row of tabs above a pager's pages. It reads the pager's position +/// every frame: the indicator slides between the labels either side of it, +/// and the highlight is the nearest page, so it moves as the swipe passes +/// halfway. A scrollable row also scrolls itself to keep the indicator in +/// view. Nothing here waits for PHP. +private struct PagerTabStrip: View { + let tabs: PagerTabs + let progress: PagerProgress + let select: (Int) -> Void + + @ObservedObject private var themeStore = NativeUITheme.shared + @Environment(\.colorScheme) private var colorScheme + + /// Each label's frame in the row, by tab index. The indicator goes + /// between these. + @State private var labelFrames: [Int: CGRect] = [:] + @State private var rowWidth: CGFloat = 0 + @State private var viewportWidth: CGFloat = 0 + @State private var rowPosition = ScrollPosition(edge: .leading) + + private static let space = "nativeui-pager-tabs" + + var body: some View { + let theme = themeStore.resolve(for: colorScheme) + + VStack(spacing: 0) { + if tabs.scrollable { + ScrollView(.horizontal) { + row(theme) + .padding(.horizontal, tabs.edgePadding) + } + .scrollIndicators(.hidden) + .scrollPosition($rowPosition) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { viewportWidth = $0 } + .onChange(of: progress.value) { follow() } + .onChange(of: labelFrames) { follow() } + .onChange(of: viewportWidth) { follow() } + } else { + row(theme) + } + + Rectangle() + .fill(resolve(tabs.dividerColor, fallback: theme.outlineVariant)) + .frame(height: 1) + .accessibilityHidden(true) + } + } + + private func row(_ theme: NativeUITokens) -> some View { + let active = activeIndex + let fontSize = tabs.fontSize ?? theme.fontMd + + return HStack(spacing: 0) { + ForEach(tabs.labels.indices, id: \.self) { index in + let label = tabs.labels[index] + let a11yLabel = index < tabs.a11yLabels.count && !tabs.a11yLabels[index].isEmpty ? tabs.a11yLabels[index] : label + let selected = index == active + + Button { + select(index) + } label: { + Text(label) + .nuiScaledFont(size: fontSize, weight: .semibold, fontName: tabs.font.isEmpty ? nil : tabs.font) + .lineLimit(1) + .foregroundStyle(selected + ? resolve(tabs.activeColor, fallback: theme.onSurface) + : resolve(tabs.color, fallback: theme.onSurfaceVariant)) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .named(Self.space)) } action: { labelFrames[index] = $0 } + .padding(.horizontal, tabs.paddingX) + .padding(.top, tabs.paddingTop) + .padding(.bottom, tabs.paddingBottom + tabs.indicatorHeight) + .frame(maxWidth: tabs.scrollable ? nil : .infinity) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(a11yLabel) + .accessibilityAddTraits(selected ? [.isButton, .isSelected] : .isButton) + } + } + // VoiceOver reads each as a tab, "1 of 3". Only the tabs: the + // indicator overlaid below stays out of the group. + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isTabBar) + .coordinateSpace(.named(Self.space)) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { rowWidth = $0 } + .overlay(alignment: .bottomLeading) { + if let frame = indicatorFrame { + Capsule() + .fill(resolve(tabs.indicatorColor, fallback: theme.primary)) + .frame(width: frame.width, height: tabs.indicatorHeight) + .offset(x: frame.minX) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + } + + /// The nearest page's tab, which flips as the swipe passes halfway. + private var activeIndex: Int { + min(max(Int(progress.value.rounded()), 0), tabs.labels.count - 1) + } + + /// The indicator's x and width: between the labels of the pages either + /// side of the pager's position, in proportion to how far it is from one + /// to the other. + private var indicatorFrame: CGRect? { + let last = tabs.labels.count - 1 + let position = min(max(progress.value, 0), CGFloat(last)) + let from = Int(position.rounded(.down)) + let to = min(from + 1, last) + + guard let a = labelFrames[from], let b = labelFrames[to] else { return nil } + + let t = position - CGFloat(from) + + return CGRect(x: a.minX + (b.minX - a.minX) * t, y: 0, width: a.width + (b.width - a.width) * t, height: 0) + } + + /// Keeps the indicator in the middle of a scrollable row, as far as the + /// row's ends allow. + private func follow() { + guard viewportWidth > 0, let frame = indicatorFrame else { return } + + let contentWidth = rowWidth + tabs.edgePadding * 2 + let target = tabs.edgePadding + frame.midX - viewportWidth / 2 + + rowPosition.scrollTo(x: min(max(target, 0), max(contentWidth - viewportWidth, 0))) + } + + private func resolve(_ color: (light: Int?, dark: Int?), fallback: Color) -> Color { + let argb = colorScheme == .dark ? (color.dark ?? color.light) : color.light + + return argb.map { Color(argb: $0) } ?? fallback + } +} diff --git a/src/Elements/Pager.php b/src/Elements/Pager.php index bc28385..6207d24 100644 --- a/src/Elements/Pager.php +++ b/src/Elements/Pager.php @@ -2,8 +2,11 @@ namespace Native\Mobile\UI\Elements; +use Illuminate\Support\Str; use Native\Mobile\Edge\CallbackRegistry; use Native\Mobile\Edge\Element; +use Native\Mobile\UI\Concerns\ResolvesColorValues; +use Native\Mobile\UI\Theme; /** * Full-screen snap pager — the TikTok / Reels / Shorts feed. Each page @@ -46,11 +49,31 @@ * `placeholders` gives every loaded page an image to show before it is * shipped (see placeholders()). * + * `tabs` draws a row of tabs above the pages, one label per page: + * + * + * + * Native owns the row. Its indicator follows the swipe frame by frame, the + * label under it switches as the swipe passes halfway, and a tap scrolls + * the pager to that page, all without PHP. The page change is reported as + * usual once the pager gets there. A tap on the tab already showing calls + * `on-tab-reselect` instead, with the index, so the page can go back to + * its top. `tabs-scrollable` makes the tabs as wide as their labels in a + * row that scrolls, keeping the current one in view; by default they share + * the row equally. Colours default to the theme (`on-surface`, + * `on-surface-variant`, `primary`, `outline-variant`), and the `tab-*` + * attributes override them per pager, since the row sits on whatever the + * pages are (light labels over a video feed, say). + * * The page index rides the tab-change transport, so a single feed is * capped at 32,767 pages per screen. */ class Pager extends Element { + use ResolvesColorValues; + protected string $type = 'pager'; /** @var array */ @@ -58,6 +81,8 @@ class Pager extends Element protected ?string $pageCallback = null; + protected ?string $tabReselectCallback = null; + public static function make(Element ...$children): static { $el = new static; @@ -101,6 +126,8 @@ public function applyAttributes(array $attrs): void $this->onPageChange($cb); } + $this->applyTabAttributes($attrs); + $this->applyA11yAttributes($attrs); } @@ -175,6 +202,214 @@ public function onPageChange(string $method): static return $this; } + /** + * One tab per page, drawn natively above the pages. Each is a label, or + * `['label' => ..., 'a11y-label' => ...]` when the label read out should + * say more than the one shown. + * + * @param array $tabs + */ + public function tabs(array $tabs): static + { + $labels = []; + $a11yLabels = []; + + foreach (array_values($tabs) as $tab) { + if (is_array($tab)) { + $labels[] = (string) ($tab['label'] ?? ''); + $a11yLabels[] = (string) ($tab['a11y-label'] ?? $tab['a11y_label'] ?? $tab['a11yLabel'] ?? ''); + } else { + $labels[] = (string) $tab; + $a11yLabels[] = ''; + } + } + + $this->pagerProps['tabs'] = $labels; + + if (array_filter($a11yLabels) !== []) { + $this->pagerProps['tab_a11y_labels'] = $a11yLabels; + } else { + unset($this->pagerProps['tab_a11y_labels']); + } + + return $this; + } + + /** + * Tabs as wide as their labels, in a row that scrolls sideways and keeps + * the current tab in view. By default the tabs share the row equally. + */ + public function tabsScrollable(bool $value = true): static + { + $this->pagerProps['tabs_scrollable'] = $value; + + return $this; + } + + /** + * Called with `(int $index)` when the tab already showing is tapped, + * the usual cue to scroll that page back to its top. + */ + public function onTabReselect(string $method): static + { + $this->tabReselectCallback = $method; + + return $this; + } + + /** Inactive label colour. Default: `theme.on-surface-variant`. */ + public function tabColor(string $color): static + { + return $this->tabColorProp('tab_color', $color); + } + + /** Active label colour. Default: `theme.on-surface`. */ + public function tabActiveColor(string $color): static + { + return $this->tabColorProp('tab_active_color', $color); + } + + /** Indicator colour. Default: `theme.primary`. */ + public function tabIndicatorColor(string $color): static + { + return $this->tabColorProp('tab_indicator_color', $color); + } + + /** Colour of the hairline under the row; `transparent` hides it. Default: `theme.outline-variant`. */ + public function tabDividerColor(string $color): static + { + return $this->tabColorProp('tab_divider_color', $color); + } + + /** Label font: a bundled font file's basename or a `fonts` alias. Default: the theme's font. */ + public function tabFont(string $font): static + { + $this->pagerProps['tab_font'] = $font; + + return $this; + } + + /** Label size in points. Default: `theme.font-md`. */ + public function tabFontSize(float $size): static + { + $this->pagerProps['tab_font_size'] = max(0.0, $size); + + return $this; + } + + /** Indicator thickness in points. Default: 3. */ + public function tabIndicatorHeight(float $height): static + { + $this->pagerProps['tab_indicator_height'] = max(0.0, $height); + + return $this; + } + + /** + * Space around each label, in points: `$x` either side, `$top` above it + * and `$bottom` between it and the indicator. Defaults: 12, 14 and 11, + * which with the indicator makes a 48pt tab at the default font size. + */ + public function tabPadding(?float $x = null, ?float $top = null, ?float $bottom = null): static + { + foreach (['tab_padding_x' => $x, 'tab_padding_top' => $top, 'tab_padding_bottom' => $bottom] as $prop => $value) { + if ($value !== null) { + $this->pagerProps[$prop] = max(0.0, $value); + } + } + + return $this; + } + + /** Space before the first and after the last tab of a scrollable row, in points. Default: 4. */ + public function tabEdgePadding(float $padding): static + { + $this->pagerProps['tab_edge_padding'] = max(0.0, $padding); + + return $this; + } + + /** + * @param array $attrs + */ + protected function applyTabAttributes(array $attrs): void + { + // Each attribute as written in Blade, in snake case, or in camel case. + $attr = fn (string $name): mixed => $attrs[$name] ?? $attrs[str_replace('-', '_', $name)] ?? $attrs[Str::camel($name)] ?? null; + + if (is_array($tabs = $attr('tabs'))) { + $this->tabs($tabs); + } + if (($scrollable = $attr('tabs-scrollable')) !== null) { + $this->tabsScrollable(filter_var($scrollable, FILTER_VALIDATE_BOOLEAN)); + } + if (($reselect = $attr('on-tab-reselect')) !== null) { + $this->onTabReselect((string) $reselect); + } + if (($color = $attr('tab-color')) !== null) { + $this->tabColor((string) $color); + } + if (($color = $attr('tab-active-color')) !== null) { + $this->tabActiveColor((string) $color); + } + if (($color = $attr('tab-indicator-color')) !== null) { + $this->tabIndicatorColor((string) $color); + } + if (($color = $attr('tab-divider-color')) !== null) { + $this->tabDividerColor((string) $color); + } + if (($font = $attr('tab-font')) !== null) { + $this->tabFont((string) $font); + } + if (($size = $attr('tab-font-size')) !== null) { + $this->tabFontSize((float) $size); + } + if (($height = $attr('tab-indicator-height')) !== null) { + $this->tabIndicatorHeight((float) $height); + } + + $paddingX = $attr('tab-padding-x'); + $paddingTop = $attr('tab-padding-top'); + $paddingBottom = $attr('tab-padding-bottom'); + $this->tabPadding( + $paddingX === null ? null : (float) $paddingX, + $paddingTop === null ? null : (float) $paddingTop, + $paddingBottom === null ? null : (float) $paddingBottom, + ); + + if (($edge = $attr('tab-edge-padding')) !== null) { + $this->tabEdgePadding((float) $edge); + } + } + + /** + * A colour in the theme config's grammar, or `theme-` for one of + * the theme's tokens, which also sends its dark shade. + */ + protected function tabColorProp(string $prop, string $color): static + { + unset($this->pagerProps[$prop], $this->pagerProps['dark_'.$prop]); + + if (! str_starts_with($color, 'theme-')) { + $this->pagerProps[$prop] = $this->resolveColorValue($color); + + return $this; + } + + $token = substr($color, 6); + $light = Theme::get("light.{$token}"); + $dark = Theme::get("dark.{$token}"); + + if (is_string($light)) { + $this->pagerProps[$prop] = $light; + } + if (is_string($dark) && $dark !== $light) { + $this->pagerProps['dark_'.$prop] = $dark; + } + + return $this; + } + protected function resolveProps(CallbackRegistry $registry): array { $props = $this->pagerProps; @@ -189,6 +424,11 @@ protected function resolveProps(CallbackRegistry $registry): array $props['on_page_change'] = $registry->register($this->pageCallback); } + if ($this->tabReselectCallback !== null) { + // Same transport: the handler gets the tapped tab's index. + $props['on_tab_reselect'] = $registry->register($this->tabReselectCallback); + } + return $props; } } diff --git a/tests/PagerTabsTest.php b/tests/PagerTabsTest.php new file mode 100644 index 0000000..175f83b --- /dev/null +++ b/tests/PagerTabsTest.php @@ -0,0 +1,174 @@ +mute(); + Theme::reset(); + TailwindParser::clearCache(); + NativeElementCollector::reset(); + ElementRegistry::reset(); + ElementRegistry::register('pager', Pager::class); +}); + +afterEach(function () { + Theme::reset(); + NativeElementCollector::reset(); + ElementRegistry::reset(); +}); + +function pagerTabProps(array $attrs, ?CallbackRegistry $registry = null): array +{ + NativeElementCollector::open('pager', $attrs); + NativeElementCollector::close(); + + return NativeElementCollector::collect()->toArray($registry ?? new CallbackRegistry)['props']; +} + +it('sends the tab labels, their a11y labels and the reselect callback', function () { + $registry = new CallbackRegistry; + + $props = pagerTabProps([ + 'horizontal' => true, + 'tabs' => ['Following', ['label' => 'Latest', 'a11y-label' => 'Latest, 3 new'], 'Trending'], + 'tabs-scrollable' => true, + 'on-tab-reselect' => 'scrollToTop', + ], $registry); + + expect($props['tabs'])->toBe(['Following', 'Latest', 'Trending']) + ->and($props['tab_a11y_labels'])->toBe(['', 'Latest, 3 new', '']) + ->and($props['tabs_scrollable'])->toBeTrue() + ->and($registry->resolve($props['on_tab_reselect']))->toBe(['method' => 'scrollToTop', 'args' => []]); +}); + +it('leaves out a11y labels when no tab has one', function () { + $props = pagerTabProps(['tabs' => ['All', 'Replies']]); + + expect($props['tabs'])->toBe(['All', 'Replies']) + ->and($props)->not->toHaveKey('tab_a11y_labels') + ->and($props)->not->toHaveKey('tabs_scrollable') + ->and($props)->not->toHaveKey('on_tab_reselect'); +}); + +it('sends no tab props without tabs', function () { + $props = pagerTabProps(['horizontal' => true, 'count' => 3]); + + expect(array_filter(array_keys($props), fn (string $key): bool => str_contains($key, 'tab')))->toBe([]); +}); + +it('resolves tab colours, sending both shades of a theme token', function () { + Theme::load([ + 'light' => ['ink' => '#121417', 'ink-muted' => '#5b616b', 'hairline' => '#e1e4e8'], + 'dark' => ['ink' => '#ececec', 'ink-muted' => '#a1a1a6', 'hairline' => '#2a2a2d'], + ]); + + $props = pagerTabProps([ + 'tabs' => ['A', 'B'], + 'tab-active-color' => 'theme-ink', + 'tab-color' => 'theme-ink-muted', + 'tab-divider-color' => 'transparent', + 'tab-indicator-color' => 'blue-600', + ]); + + expect($props['tab_active_color'])->toBe('#121417') + ->and($props['dark_tab_active_color'])->toBe('#ECECEC') + ->and($props['tab_color'])->toBe('#5B616B') + ->and($props['dark_tab_color'])->toBe('#A1A1A6') + ->and($props['tab_divider_color'])->toBe('#00000000') + ->and($props)->not->toHaveKey('dark_tab_divider_color') + ->and($props['tab_indicator_color'])->toBe('#2563EB'); +}); + +it('leaves an unknown theme token to the native default', function () { + $props = pagerTabProps(['tabs' => ['A'], 'tab-active-color' => 'theme-nope']); + + expect($props)->not->toHaveKey('tab_active_color') + ->and($props)->not->toHaveKey('dark_tab_active_color'); +}); + +it('sends the tab font, sizes and padding', function () { + $props = pagerTabProps([ + 'tabs' => ['A'], + 'tab-font' => 'semibold', + 'tab-font-size' => '15', + 'tab-indicator-height' => 3, + 'tab-padding-x' => 12, + 'tab-padding-top' => 14, + 'tab-padding-bottom' => 11, + 'tab-edge-padding' => -2, + ]); + + expect($props['tab_font'])->toBe('semibold') + ->and($props['tab_font_size'])->toBe(15.0) + ->and($props['tab_indicator_height'])->toBe(3.0) + ->and($props['tab_padding_x'])->toBe(12.0) + ->and($props['tab_padding_top'])->toBe(14.0) + ->and($props['tab_padding_bottom'])->toBe(11.0) + ->and($props['tab_edge_padding'])->toBe(0.0); +}); + +it('builds the same tabs fluently', function () { + $registry = new CallbackRegistry; + + $props = Pager::make() + ->horizontal() + ->tabs(['Following', 'Latest']) + ->tabsScrollable() + ->onTabReselect('scrollToTop') + ->tabActiveColor('red-300') + ->tabFont('semibold') + ->tabPadding(top: 10) + ->toArray($registry)['props']; + + expect($props['tabs'])->toBe(['Following', 'Latest']) + ->and($props['tabs_scrollable'])->toBeTrue() + ->and($registry->resolve($props['on_tab_reselect'])['method'])->toBe('scrollToTop') + ->and($props['tab_active_color'])->toBe('#FCA5A5') + ->and($props['tab_font'])->toBe('semibold') + ->and($props['tab_padding_top'])->toBe(10.0) + ->and($props)->not->toHaveKey('tab_padding_x'); +}); + +it('drives the iOS tab row from the pager position, not PHP', function () { + $source = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIPagerRenderer.swift'); + + expect($source) + // The unrounded position, written every frame into an observable only the row reads. + ->toContain('.onScrollGeometryChange(for: CGFloat?.self)') + ->toContain('progress.value = value') + ->toContain('@Observable') + // Highlight: the nearest page. Indicator: between the labels either side. + ->toContain('Int(progress.value.rounded())') + ->toContain('let from = Int(position.rounded(.down))') + ->toContain('a.minX + (b.minX - a.minX) * t') + // A scrollable row follows the indicator. + ->toContain('rowPosition.scrollTo(x:') + ->toContain('.accessibilityAddTraits(.isTabBar)'); +}); + +it('scrolls the iOS pager on a tab tap and sends a reselect to PHP', function () { + $source = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIPagerRenderer.swift'); + + expect($source) + ->toMatch('/if index == leading, abs\(progress\.value - CGFloat\(index\)\) < 0\.01 \{\s*if let cb = tabs\?\.reselectCbId, cb != 0 \{\s*NativeElementBridge\.sendTabChangeEvent\(cb, nodeId: nodeId, index: index\)/') + ->toContain('withAnimation { position = index }'); +}); + +it('drives the Android tab row from the pager position, not PHP', function () { + $source = file_get_contents(dirname(__DIR__).'/resources/android/PagerRenderer.kt'); + + expect($source) + ->toContain('pagerState.currentPage + pagerState.currentPageOffsetFraction') + ->toContain('val active = pagerState.currentPage.coerceIn(0, last)') + ->toContain('.drawBehind {') + ->toContain('role = Role.Tab') + ->toContain('scope.launch { pagerState.animateScrollToPage(index) }') + ->toContain('NativeElementBridge.sendTabChangeEvent(tabs.reselectCbId, node.id, index)') + ->toContain('scrollState.scrollTo('); +});