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