diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerNativeHeaders.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerNativeHeaders.kt index 4da19918a..9656229d4 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerNativeHeaders.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerNativeHeaders.kt @@ -86,6 +86,9 @@ private class CollapsiblePagerHorizontalItemsView( private var contentPaddingVerticalPx = 0 private var segmented = false private var textVariant = false + private var pillVariant = false + private var itemPaddingHorizontalDp: Double? = null + private var pillAnimationEnabled = true private var itemSpacingPx = dp(8.0) private var fontSize = if (showsProgressIndicator) 16.0 else 14.0 private var fontFamily: String? = null @@ -177,6 +180,7 @@ private class CollapsiblePagerHorizontalItemsView( val wasSegmented = segmented segmented = showsProgressIndicator && variant == "segmented" textVariant = showsProgressIndicator && variant == "text" + pillVariant = showsProgressIndicator && variant == "pill" itemSpacingPx = max(0, dp(itemSpacing)) this.fontSize = max(1.0, fontSize) this.fontFamily = fontFamily @@ -217,6 +221,25 @@ private class CollapsiblePagerHorizontalItemsView( fun usesTextVariant(): Boolean = textVariant + // Variants whose label colors (and pill) follow controlled pager progress. + fun usesProgressDrivenItems(): Boolean = textVariant || (pillVariant && pillAnimationEnabled) + + fun setPillAnimationEnabled(value: Boolean) { + if (pillAnimationEnabled == value) return + pillAnimationEnabled = value + updatePresentation() + } + + fun pillAnimationDisabled(): Boolean = pillVariant && !pillAnimationEnabled + + fun setItemPaddingHorizontal(value: Double?) { + if (itemPaddingHorizontalDp == value) return + itemPaddingHorizontalDp = value + requestLayout() + } + + private fun itemPaddingPx(): Int = dp(itemPaddingHorizontalDp ?: if (textVariant) 10.0 else if (pillVariant) 14.0 else 8.0) + fun setActiveFontFamily(value: String?) { activeFontFamily = value; requestLayout() } fun setProgress(value: Float, source: Int? = null, destination: Int? = null, fraction: Float = 0f) { @@ -253,7 +276,7 @@ private class CollapsiblePagerHorizontalItemsView( val fontOwner = visualFontOwnerIndex(buttons.indexOfFirst { it.isSelected }) buttons.forEachIndexed { index, button -> button.typeface = if (textVariant && index == fontOwner) selectedTypeface() else typeface - button.setPadding(dp(if (textVariant) 10.0 else 8.0), 0, dp(if (textVariant) 10.0 else 8.0), 0) + button.setPadding(itemPaddingPx(), 0, itemPaddingPx(), 0) button.setTextSize(TypedValue.COMPLEX_UNIT_SP, fontSize.toFloat()) } } @@ -332,11 +355,20 @@ private class CollapsiblePagerHorizontalItemsView( dp(20.0) } val buttonWidth = max(dp(44.0), textWidth + horizontalPadding) - val indicatorWidth = textWidth + val indicatorWidth = if (pillVariant) buttonWidth else textWidth val indicatorX = x + (buttonWidth - indicatorWidth) / 2 itemFrames.add(intArrayOf(x, buttonWidth, indicatorX, indicatorWidth)) - val itemHeight = if (showsProgressIndicator) height else min(dp(32.0), height) - val top = if (showsProgressIndicator) 0 else max(0, (height - itemHeight) / 2) + val pillInset = min(contentPaddingVerticalPx, height / 2) + val itemHeight = when { + pillVariant -> max(0, height - 2 * pillInset) + showsProgressIndicator -> height + else -> min(dp(32.0), height) + } + val top = when { + pillVariant -> pillInset + showsProgressIndicator -> 0 + else -> max(0, (height - itemHeight) / 2) + } button.layoutParams = FrameLayout.LayoutParams(buttonWidth, itemHeight).apply { leftMargin = x topMargin = top @@ -377,19 +409,44 @@ private class CollapsiblePagerHorizontalItemsView( val selectedIndex: Int val scrollTargetCenterX: Float if (showsProgressIndicator) { - val clamped = progress.coerceIn(0f, buttons.lastIndex.toFloat()) + // A pill without animation jumps (with its label colors) to the selection. + val snapToSelection = pillAnimationDisabled() + val snappedIndex = if (snapToSelection) { + (controlledSelectedKey?.let { key -> items.indexOfFirst { it.key == key }.takeIf { it >= 0 } } + ?: progress.roundToInt()).coerceIn(0, buttons.lastIndex) + } else -1 + val clamped = if (snapToSelection) snappedIndex.toFloat() else progress.coerceIn(0f, buttons.lastIndex.toFloat()) val lower = floor(clamped).toInt() val upper = min(lower + 1, buttons.lastIndex) val fraction = clamped - lower val from = itemFrames[lower] val to = itemFrames[upper] - val interpolatedIndicatorX = from[2] + (to[2] - from[2]) * fraction - val interpolatedIndicatorWidth = from[3] + (to[3] - from[3]) * fraction + var interpolatedIndicatorX = from[2] + (to[2] - from[2]) * fraction + var interpolatedIndicatorWidth = from[3] + (to[3] - from[3]) * fraction + if (pillVariant) { + // Leading edge leads and trailing edge lags while moving between tabs. + val leading = 1f - (1f - fraction) * (1f - fraction) + val trailing = fraction * fraction + val forward = to[2] >= from[2] + val leftEdge = from[2] + (to[2] - from[2]) * (if (forward) trailing else leading) + val rightEdge = from[2] + from[3] + (to[2] + to[3] - from[2] - from[3]) * (if (forward) leading else trailing) + interpolatedIndicatorX = leftEdge + interpolatedIndicatorWidth = max(0f, rightEdge - leftEdge) + } val indicatorX = interpolatedIndicatorX.roundToInt() val indicatorWidth = interpolatedIndicatorWidth.roundToInt() val actualPaddingVertical = min(contentPaddingVerticalPx, measuredHeight / 2) - val indicatorHeight = if (segmented) max(0, measuredHeight - 2 * actualPaddingVertical - dp(4.0)) else indicatorHeightPx - val indicatorTop = if (segmented) actualPaddingVertical + dp(2.0) else max(0, measuredHeight - indicatorBottomPx - indicatorHeightPx) + val indicatorHeight = when { + segmented -> max(0, measuredHeight - 2 * actualPaddingVertical - dp(4.0)) + pillVariant -> max(0, measuredHeight - 2 * actualPaddingVertical) + else -> indicatorHeightPx + } + val indicatorTop = when { + segmented -> actualPaddingVertical + dp(2.0) + pillVariant -> actualPaddingVertical + else -> max(0, measuredHeight - indicatorBottomPx - indicatorHeightPx) + } + if (pillVariant) indicatorBackground.cornerRadius = indicatorHeight / 2f indicator.layout( indicatorX, indicatorTop, @@ -401,7 +458,7 @@ private class CollapsiblePagerHorizontalItemsView( selectedIndex = if (controlledSelectedKey != null) items.indexOfFirst { it.key == controlledSelectedKey } else clamped.roundToInt() val fontOwner = visualFontOwnerIndex(selectedIndex) buttons.forEachIndexed { index, button -> - val emphasis = if (mappedSource != null && mappedDestination != null) { + val emphasis = if (!snapToSelection && mappedSource != null && mappedDestination != null) { (if (index == mappedSource) 1f - mappedFraction else 0f) + (if (index == mappedDestination) mappedFraction else 0f) } else 1f - min(1f, kotlin.math.abs(clamped - index)) button.setTextColor(ColorUtils.blendARGB(inactiveTextColor, activeTextColor, emphasis)) @@ -473,6 +530,9 @@ internal class CollapsiblePagerNativeTabBarView(context: Context) : FrameLayout( fun updateConfigJSON(value: String?) { config = runCatching { JSONObject(value ?: "{}") }.getOrDefault(JSONObject()) row.setActiveFontFamily(config.optString("activeFontFamily").takeIf(String::isNotEmpty)) + row.setItemPaddingHorizontal(config.optDouble("itemPaddingHorizontal", Double.NaN).takeIf { it.isFinite() && it >= 0 }) + row.setPillAnimationEnabled(config.optBoolean("indicatorAnimationEnabled", true)) + requestLayout() selectedKey = if (config.optBoolean("controlled")) config.optString("selectedKey") else items.getOrNull(lastNativeProgress.roundToInt())?.key ?: "" row.setControlledSelectedKey(if (config.optBoolean("controlled")) selectedKey else null, controlledPageIndices() != null) if (hasNativeProgress) setProgress(lastNativeProgress) @@ -811,7 +871,17 @@ internal class CollapsiblePagerNativeTabBarView(context: Context) : FrameLayout( } fun usesControlledTextProgress(): Boolean { - return row.usesTextVariant() && config.optBoolean("controlled") && controlledPageIndices() != null + return row.usesProgressDrivenItems() && config.optBoolean("controlled") && controlledPageIndices() != null + } + + // The tab row and toolbar share a centered band no wider than contentMaxWidth; + // the bar background still covers the full width. + private fun contentBand(width: Int): IntArray { + val maxWidth = config.optDouble("contentMaxWidth", Double.NaN) + if (!maxWidth.isFinite() || maxWidth <= 0 || dp(maxWidth) >= width) return intArrayOf(0, width) + val bandWidth = dp(maxWidth) + val start = (width - bandWidth) / 2 + return intArrayOf(start, start + bandWidth) } fun setProgress(progress: Float) { @@ -851,7 +921,8 @@ internal class CollapsiblePagerNativeTabBarView(context: Context) : FrameLayout( val width = MeasureSpec.getSize(widthMeasureSpec) val height = MeasureSpec.getSize(heightMeasureSpec) val reserved = dp(toolbarReservedWidth) - row.measure(MeasureSpec.makeMeasureSpec(max(0, width - reserved), MeasureSpec.EXACTLY), heightMeasureSpec) + val band = contentBand(width) + row.measure(MeasureSpec.makeMeasureSpec(max(0, band[1] - band[0] - reserved), MeasureSpec.EXACTLY), heightMeasureSpec) toolbar.measure(MeasureSpec.makeMeasureSpec(dp(toolbarSize), MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(dp(toolbarSize), MeasureSpec.EXACTLY)) setMeasuredDimension(width, height) } @@ -861,9 +932,10 @@ internal class CollapsiblePagerNativeTabBarView(context: Context) : FrameLayout( val height = bottom - top val reserved = dp(toolbarReservedWidth) val rtl = layoutDirection == View.LAYOUT_DIRECTION_RTL - row.layout(if (rtl) reserved else 0, 0, if (rtl) width else width - reserved, height) + val band = contentBand(width) + row.layout(if (rtl) band[0] + reserved else band[0], 0, if (rtl) band[1] else band[1] - reserved, height) val inset = dp(toolbarTrailingInset) - (dp(toolbarSize) - dp(toolbarContentWidth)) / 2 - val x = if (rtl) inset else width - inset - dp(toolbarSize) + val x = if (rtl) band[0] + inset else band[1] - inset - dp(toolbarSize) toolbar.layout(x, (height - dp(toolbarSize)) / 2, x + dp(toolbarSize), (height + dp(toolbarSize)) / 2) } diff --git a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerViewManager.kt b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerViewManager.kt index df975eb34..c066c1cb0 100644 --- a/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerViewManager.kt +++ b/native-views/react-native-pager-view/android/src/main/java/com/reactnativepagerview/CollapsiblePagerViewManager.kt @@ -254,7 +254,7 @@ class CollapsiblePagerViewManager : ViewGroupManager(), @ReactProp(name = "nativeTabBarVariant") override fun setNativeTabBarVariant(view: CollapsiblePagerHost?, value: String?) { view ?: return - view.nativeTabBarVariant = if (value == "segmented" || value == "text") value else "underline" + view.nativeTabBarVariant = if (value == "segmented" || value == "text" || value == "pill") value else "underline" view.applyNativeHeaderStyle() } diff --git a/native-views/react-native-pager-view/docs/SPEC.md b/native-views/react-native-pager-view/docs/SPEC.md index bd5a130ff..bca7aaf93 100644 --- a/native-views/react-native-pager-view/docs/SPEC.md +++ b/native-views/react-native-pager-view/docs/SPEC.md @@ -1499,3 +1499,47 @@ commit checks passed 9/9; unrelated postinstall noble patch and Web notification external-image warnings remain separately recorded. Alpha.445 failed Pod integration and was not reused. SDK iOS CI ran on iOS 26.5: iOS-27-only incoming lifecycle cases remained skipped; no local SDK UIKit execution is claimed. + +## Pill tabs and centered content band + +`nativeTabBar.style.variant` also accepts `"pill"` on iOS, Android and Web. It +lets wide/desktop consumers replace a JS pill tab bar with the native one, so +every platform draws Home tabs from the same config. + +- Items keep intrinsic-width labels (medium face, `fontSize` default 16) with + `itemPaddingHorizontal` on each side (default 14; text keeps 10, underline and + segmented keep 8). Each item is `height - 2 * contentPaddingVertical` tall and + vertically inset by `contentPaddingVertical`. +- One rounded background (`indicatorColor`, radius = half its height) sits + behind the selected item and follows pager progress. Between two items, the + leading edge eases out and the trailing edge eases in, so the pill stretches + toward the target before it contracts. Label colors blend from + `inactiveTextColor` to `activeTextColor` with the same progress rules as the + text variant. With `controlled: true`, programmatic page changes drive the same + progress animation as the text variant does. +- Pill geometry is always taken from the current item frames (iOS and Android + layout passes; Web reads live button boxes on every sync and observes item and + list resizes). No size is cached per index, so items that only move when the + item list changes still get their own offset and width. +- Web only: unselected items use `itemHoverBackgroundColor` and + `itemPressedBackgroundColor` for hover / pressed feedback. When the pill + changes target without pager progress (Web pages switch instantly), it moves + with a 220 ms CSS transition; it does not animate on first placement. + Native items have no hover or pressed fill. + +`style.contentMaxWidth` (all variants, all three platforms) caps the width of the +tab row together with the toolbar slot and centers that band in the bar. The bar +background still covers the full width. Values that are missing, not positive, +or at least the bar width leave the existing full-width layout unchanged. + +`style.indicatorAnimationEnabled` (pill only, default true) turns the motion +off. When false, the pill sits on the selected item (controlled `selectedKey`, +otherwise the rounded pager position) on every update, label colors switch +between active and inactive without blending, the Web CSS transition is +skipped, and programmatic page changes no longer drive progress frames for the +tab bar. Swiping the pages still moves the pages; only the tab bar snaps. + +`itemPaddingHorizontal`, `itemHoverBackgroundColor`, `itemPressedBackgroundColor`, +`contentMaxWidth` and `indicatorAnimationEnabled` travel to native code inside the existing +`nativeTabBarConfig` JSON (colors through `processColor`), so the codegen props +and native component schema stay the same. Existing variants keep their defaults. diff --git a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm index 1f42f131f..6af48cc04 100644 --- a/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm +++ b/native-views/react-native-pager-view/ios/RNCCollapsiblePagerViewComponentView.mm @@ -492,6 +492,7 @@ @implementation RNCCollapsiblePagerNativeTabBarView { CGFloat _contentPaddingVertical; BOOL _segmented; BOOL _textVariant; + BOOL _pillVariant; NSDictionary *_config; RNCPagerToolbarButton *_toolbarButton; NSString *_toolbarKey; @@ -677,6 +678,7 @@ - (void)updateStyleWithHeight:(CGFloat)height BOOL wasSegmented = _segmented; _segmented = [variant isEqualToString:@"segmented"]; _textVariant = [variant isEqualToString:@"text"]; + _pillVariant = [variant isEqualToString:@"pill"]; _contentPaddingHorizontal = MAX(0, contentPaddingHorizontal); _contentPaddingVertical = MAX(0, contentPaddingVertical); _itemSpacing = MAX(0, itemSpacing); @@ -749,15 +751,39 @@ - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView RNCLogNativeTabScrollBoundary(@"primary", @"end", scrollView, _scrollStartOffsetX); } +- (CGFloat)itemPaddingHorizontal +{ + id value = _config[@"itemPaddingHorizontal"]; + if ([value isKindOfClass:NSNumber.class] && std::isfinite([value doubleValue]) && [value doubleValue] >= 0) { + return [value doubleValue]; + } + return _textVariant ? 10 : (_pillVariant ? 14 : 8); +} + +// The tab row and toolbar share a centered band no wider than contentMaxWidth; +// the bar background still covers the full width. +- (CGRect)contentRowFrame +{ + CGFloat width = CGRectGetWidth(self.bounds); + id value = _config[@"contentMaxWidth"]; + if ([value isKindOfClass:NSNumber.class] && std::isfinite([value doubleValue]) && [value doubleValue] > 0 && + [value doubleValue] < width) { + CGFloat rowWidth = [value doubleValue]; + return CGRectMake(floor((width - rowWidth) / 2), 0, rowWidth, CGRectGetHeight(self.bounds)); + } + return self.bounds; +} + - (void)layoutSubviews { [super layoutSubviews]; CGFloat reserved = _toolbarReservedWidth; + CGRect row = [self contentRowFrame]; [self bringSubviewToFront:_toolbarButton]; - _scrollView.frame = CGRectMake(_leftToRight ? 0 : reserved, 0, - MAX(0, CGRectGetWidth(self.bounds) - reserved), CGRectGetHeight(self.bounds)); + _scrollView.frame = CGRectMake(CGRectGetMinX(row) + (_leftToRight ? 0 : reserved), 0, + MAX(0, CGRectGetWidth(row) - reserved), CGRectGetHeight(self.bounds)); CGFloat inset = _toolbarTrailingInset - (_toolbarSize - _toolbarContentWidth) / 2; - _toolbarButton.frame = CGRectMake(_leftToRight ? CGRectGetWidth(self.bounds) - inset - _toolbarSize : inset, + _toolbarButton.frame = CGRectMake(_leftToRight ? CGRectGetMaxX(row) - inset - _toolbarSize : CGRectGetMinX(row) + inset, (CGRectGetHeight(self.bounds) - _toolbarSize) / 2, _toolbarSize, _toolbarSize); if (_segmented) { CGFloat width = CGRectGetWidth(_scrollView.bounds); @@ -796,7 +822,7 @@ - (void)layoutSubviews UIFont *activeFont = self.activeTabFont; textWidth = MAX(textWidth, ceil([title sizeWithAttributes:@{NSFontAttributeName: activeFont}].width)); } - CGFloat buttonWidth = MAX(44, textWidth + (_textVariant ? 20 : 16)); + CGFloat buttonWidth = MAX(44, textWidth + 2 * self.itemPaddingHorizontal); [widths addObject:@(buttonWidth)]; itemsWidth += buttonWidth; } @@ -812,6 +838,14 @@ - (void)layoutSubviews [_buttons enumerateObjectsUsingBlock:^(UIButton *button, NSUInteger index, BOOL *stop) { CGFloat buttonWidth = widths[index].doubleValue; if (!self->_leftToRight) x -= buttonWidth; + if (self->_pillVariant) { + CGFloat insetY = MIN(self->_contentPaddingVertical, self->_barHeight / 2); + button.frame = CGRectMake(x, insetY, buttonWidth, MAX(0, self->_barHeight - 2 * insetY)); + [self->_indicatorFrames addObject:[NSValue valueWithCGRect:button.frame]]; + if (self->_leftToRight) x += buttonWidth + self->_itemSpacing; + else x -= self->_itemSpacing; + return; + } button.frame = CGRectMake(x, 0, buttonWidth, self->_barHeight); NSString *title = [button titleForState:UIControlStateNormal] ?: @""; CGFloat textWidth = ceil([title sizeWithAttributes:@{NSFontAttributeName: font}].width); @@ -887,7 +921,14 @@ - (void)updateConfigJSON:(NSString *)json { [self updateToolbarForKey:key]; [self setNeedsLayout]; } -- (BOOL)usesControlledTextProgress { return _textVariant && [_config[@"controlled"] boolValue]; } +- (BOOL)pillAnimationDisabled +{ + id value = _config[@"indicatorAnimationEnabled"]; + return _pillVariant && [value isKindOfClass:NSNumber.class] && ![value boolValue]; +} +- (BOOL)usesControlledTextProgress { + return (_textVariant || (_pillVariant && !self.pillAnimationDisabled)) && [_config[@"controlled"] boolValue]; +} - (void)setLegacyToolbarVisible:(BOOL)visible { _toolbarButton.enabled = visible; _toolbarButton.userInteractionEnabled = visible; @@ -1323,6 +1364,16 @@ - (void)updatePresentation NSInteger count = _buttons.count; if (count == 0 || _indicatorFrames.count != count) return; CGFloat progress = RNCClamp(_progress, 0, count - 1); + BOOL snapToSelection = self.pillAnimationDisabled; + if (snapToSelection) { + // The pill and label colors jump to the selection instead of following progress. + NSInteger snapped = (NSInteger)round(progress); + if ([_config[@"controlled"] boolValue]) { + NSUInteger selected = [_items indexOfObjectPassingTest:^BOOL(NSDictionary *item, NSUInteger i, BOOL *stop) { return [item[@"key"] isEqual:self->_config[@"selectedKey"]]; }]; + if (selected != NSNotFound) snapped = (NSInteger)selected; + } + progress = RNCClamp(snapped, 0, count - 1); + } NSInteger lower = (NSInteger)floor(progress); NSInteger upper = MIN(lower + 1, count - 1); CGFloat fraction = progress - lower; @@ -1334,6 +1385,16 @@ - (void)updatePresentation CGRectGetWidth(fromFrame) + (CGRectGetWidth(toFrame) - CGRectGetWidth(fromFrame)) * fraction, _segmented ? CGRectGetHeight(fromFrame) : _indicatorHeight ); + if (_pillVariant) { + // Leading edge leads and trailing edge lags while moving between tabs. + CGFloat leading = 1 - (1 - fraction) * (1 - fraction); + CGFloat trailing = fraction * fraction; + BOOL forward = CGRectGetMinX(toFrame) >= CGRectGetMinX(fromFrame); + CGFloat left = CGRectGetMinX(fromFrame) + (CGRectGetMinX(toFrame) - CGRectGetMinX(fromFrame)) * (forward ? trailing : leading); + CGFloat right = CGRectGetMaxX(fromFrame) + (CGRectGetMaxX(toFrame) - CGRectGetMaxX(fromFrame)) * (forward ? leading : trailing); + indicatorFrame = CGRectMake(left, CGRectGetMinY(fromFrame), MAX(0, right - left), CGRectGetHeight(fromFrame)); + _indicatorView.layer.cornerRadius = CGRectGetHeight(indicatorFrame) / 2; + } _indicatorView.frame = indicatorFrame; _indicatorView.hidden = _textVariant || CGRectGetHeight(indicatorFrame) <= 0; @@ -1347,7 +1408,7 @@ - (void)updatePresentation [self updateToolbarForKey:key]; [_buttons enumerateObjectsUsingBlock:^(UIButton *button, NSUInteger index, BOOL *stop) { CGFloat emphasis = 1 - MIN(1, fabs(progress - index)); - if ([self->_config[@"controlled"] boolValue]) { + if ([self->_config[@"controlled"] boolValue] && !snapToSelection) { emphasis = index == self->_progressSource ? 1 - self->_progressFraction : 0; if (index == self->_progressDestination) emphasis += self->_progressFraction; } diff --git a/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx b/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx index 07f03dda9..fbb2b57bf 100644 --- a/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx +++ b/native-views/react-native-pager-view/src/CollapsiblePagerView.tsx @@ -32,7 +32,11 @@ export type CollapsiblePagerNativeTabBarItem = Readonly<{ }>; export type CollapsiblePagerNativeTabBarStyle = Readonly<{ - variant?: "underline" | "segmented" | "text"; + /** + * `pill` draws a rounded background behind the selected item that follows + * pager progress, with active / inactive label colors. + */ + variant?: "underline" | "segmented" | "text" | "pill"; height?: number; trailingReservedWidth?: number; contentPaddingHorizontal?: number; @@ -47,6 +51,22 @@ export type CollapsiblePagerNativeTabBarStyle = Readonly<{ indicatorColor?: ReactNative.ColorValue; indicatorHeight?: number; indicatorBottom?: number; + /** Horizontal padding inside each item. Defaults: text 10, pill 14, others 8. */ + itemPaddingHorizontal?: number; + /** Pill variant: background of an unselected item under the pointer. */ + itemHoverBackgroundColor?: ReactNative.ColorValue; + /** Pill variant: background of an unselected item while pressed. */ + itemPressedBackgroundColor?: ReactNative.ColorValue; + /** + * Caps the width of the tab row and toolbar and centers them; the bar + * background still spans the full width. + */ + contentMaxWidth?: number; + /** + * Pill variant: when false, the pill and label colors jump to the selected + * item instead of following pager progress or animating. Defaults to true. + */ + indicatorAnimationEnabled?: boolean; }>; export type CollapsiblePagerToolbarItem = Readonly<{ @@ -709,6 +729,15 @@ export class CollapsiblePagerView extends React.PureComponent< })(), toolbar: nativeToolbar(nativeTabBar.toolbar), activeFontFamily: nativeTabBar.style?.activeFontFamily, + itemPaddingHorizontal: nativeTabBar.style?.itemPaddingHorizontal, + itemHoverBackgroundColor: nativeTabBar.style?.itemHoverBackgroundColor === undefined + ? undefined + : processColor(nativeTabBar.style.itemHoverBackgroundColor), + itemPressedBackgroundColor: nativeTabBar.style?.itemPressedBackgroundColor === undefined + ? undefined + : processColor(nativeTabBar.style.itemPressedBackgroundColor), + contentMaxWidth: nativeTabBar.style?.contentMaxWidth, + indicatorAnimationEnabled: nativeTabBar.style?.indicatorAnimationEnabled, }) : undefined} onNativeToolbarPress={this.onNativeToolbarPress} nativeTabBarItems={ diff --git a/native-views/react-native-pager-view/src/CollapsiblePagerView.web.tsx b/native-views/react-native-pager-view/src/CollapsiblePagerView.web.tsx index 12e950ea8..9cda40372 100644 --- a/native-views/react-native-pager-view/src/CollapsiblePagerView.web.tsx +++ b/native-views/react-native-pager-view/src/CollapsiblePagerView.web.tsx @@ -200,6 +200,9 @@ export class CollapsiblePagerView extends React.PureComponent< private tabProgressFrame: number | null = null; private tabProgressGeneration = 0; private tabButtons = new Map(); + private tabList: HTMLElement | null = null; + private tabButtonRefs = new Map void>(); + private tabPill: HTMLElement | null = null; private headerSlot: HTMLElement | null = null; private stickySlot: HTMLElement | null = null; private cssTimelineSupported = false; @@ -435,6 +438,8 @@ export class CollapsiblePagerView extends React.PureComponent< if (this.root) this.chromeObserver.observe(this.root); if (this.headerContent) this.chromeObserver.observe(this.headerContent); if (this.stickyContent) this.chromeObserver.observe(this.stickyContent); + if (this.tabList) this.chromeObserver.observe(this.tabList); + for (const button of this.tabButtons.values()) this.chromeObserver.observe(button); this.scheduleChromeMeasurement(); } @@ -855,7 +860,10 @@ export class CollapsiblePagerView extends React.PureComponent< private tabColorWeights() { const config = this.props.nativeTabBar; - if (!config?.controlled || config.style?.variant !== "text" || this.tabProgress === null) return null; + const variant = config?.style?.variant; + if (!config?.controlled || (variant !== "text" && variant !== "pill") || this.tabProgress === null) return null; + // A pill that does not animate also snaps label colors to the selection. + if (variant === "pill" && config.style?.indicatorAnimationEnabled === false) return null; const keys: string[] = []; React.Children.forEach(this.props.children, child => { if (child !== null && child !== undefined && typeof child !== "boolean") keys.push(pageKey(child, keys.length)); @@ -910,6 +918,83 @@ export class CollapsiblePagerView extends React.PureComponent< if (label) label.style.fontFamily = fontFamily; } else button.style.fontFamily = fontFamily; } + this.syncTabPill(weights, selectedKey); + } + + // Positions the pill from the live button boxes on every sync. Nothing is + // cached per index, so a tab that only moved (same size, new position) still + // gets its own width and offset. + private syncTabPill(weights: Map | null, selectedKey: string | undefined) { + const pill = this.tabPill; + if (!pill) return; + let fromKey = selectedKey; + let toKey = selectedKey; + let fraction = 0; + if (weights && this.tabProgress !== null) { + const keys = Array.from(weights.keys()); + const progress = Math.min(Math.max(0, this.tabProgress), keys.length - 1); + const source = Math.floor(progress); + fromKey = keys[source]; + toKey = keys[Math.min(source + 1, keys.length - 1)]; + fraction = progress - source; + } + const from = fromKey === undefined ? undefined : this.tabButtons.get(fromKey); + const to = toKey === undefined ? undefined : this.tabButtons.get(toKey); + if (!from || !to || !from.isConnected || !to.isConnected || from.offsetWidth <= 0) { + pill.style.visibility = "hidden"; + pill.removeAttribute("data-placed"); + return; + } + // Leading edge leads, trailing edge lags, like the JS pill this replaces. + const leading = 1 - (1 - fraction) * (1 - fraction); + const trailing = fraction * fraction; + const forward = to.offsetLeft >= from.offsetLeft; + const leftFraction = forward ? trailing : leading; + const rightFraction = forward ? leading : trailing; + const fromRight = from.offsetLeft + from.offsetWidth; + const toRight = to.offsetLeft + to.offsetWidth; + const left = from.offsetLeft + (to.offsetLeft - from.offsetLeft) * leftFraction; + const right = fromRight + (toRight - fromRight) * rightFraction; + const animationEnabled = this.props.nativeTabBar?.style?.indicatorAnimationEnabled !== false; + const animate = animationEnabled && pill.hasAttribute("data-placed") && this.tabProgressFrame === null && fraction === 0; + pill.style.transition = animate ? "transform 220ms cubic-bezier(0.2, 0, 0, 1), width 220ms cubic-bezier(0.2, 0, 0, 1)" : "none"; + pill.style.top = `${from.offsetTop}px`; + pill.style.height = `${from.offsetHeight}px`; + pill.style.width = `${Math.max(0, right - left)}px`; + pill.style.transform = `translateX(${left}px)`; + pill.style.visibility = "visible"; + pill.setAttribute("data-placed", ""); + } + + private setTabList = (node: HTMLDivElement | null) => { + if (node === this.tabList) return; + if (this.tabList) this.chromeObserver?.unobserve(this.tabList); + this.tabList = node; + if (node) this.chromeObserver?.observe(node); + }; + + private setTabPill = (node: HTMLDivElement | null) => { + this.tabPill = node; + if (node) this.syncTabColors(); + }; + + private tabButtonRef(key: string) { + let ref = this.tabButtonRefs.get(key); + if (!ref) { + ref = (button: HTMLButtonElement | null) => this.setTabButton(key, button); + this.tabButtonRefs.set(key, ref); + } + return ref; + } + + private setTabButton(key: string, button: HTMLButtonElement | null) { + const previous = this.tabButtons.get(key); + if (previous === button) return; + if (previous) this.chromeObserver?.unobserve(previous); + if (button) { + this.tabButtons.set(key, button); + this.chromeObserver?.observe(button); + } else this.tabButtons.delete(key); } private pageScrollElement(index: number) { @@ -1743,26 +1828,41 @@ export class CollapsiblePagerView extends React.PureComponent< return Math.min(size, Math.max(0, entry.contentWidth ?? 24)) + Math.min(128, Math.max(0, entry.trailingInset ?? 20)); })); const segmented = variant === "segmented"; + const pill = variant === "pill"; const reserveTextFaces = config.controlled && variant === "text"; + const itemPadding = style?.itemPaddingHorizontal ?? (variant === "text" ? 10 : pill ? 14 : 8); + const cssColor = (value: ReactNative.ColorValue | undefined) => value === undefined ? "transparent" : String(value); + for (const key of this.tabButtonRefs.keys()) { + if (!config.items.some(item => item.key === key)) this.tabButtonRefs.delete(key); + } const colorWeights = this.tabColorWeights(); const fontOwnerKey = this.tabFontOwnerKey(colorWeights); return React.createElement("div", { className: "ok-pager-tab-bar", dir: this.props.layoutDirection === "rtl" || ((this.props.layoutDirection ?? "locale") === "locale" && I18nManager.isRTL) ? "rtl" : "ltr", + "data-variant": variant, style: { display: "flex", alignItems: "center", position: "relative", height: style?.height ?? 44, - backgroundColor: String(style?.backgroundColor ?? "transparent"), minWidth: 0 }, + backgroundColor: String(style?.backgroundColor ?? "transparent"), minWidth: 0, + ["--pager-tab-hover" as string]: cssColor(style?.itemHoverBackgroundColor), + ["--pager-tab-pressed" as string]: cssColor(style?.itemPressedBackgroundColor) }, }, - React.createElement("style", null, `.ok-pager-tab{box-sizing:border-box;appearance:none;border:0;background:transparent;cursor:default;white-space:nowrap;flex-shrink:0;line-height:normal}.ok-pager-toolbar{box-sizing:border-box;appearance:none;border:1px solid transparent;cursor:default;display:flex;align-items:center;justify-content:center;background:var(--pager-toolbar-base)}.ok-pager-toolbar:hover:enabled{background:var(--pager-toolbar-hover)}.ok-pager-toolbar:active:enabled{background:var(--pager-toolbar-pressed)}`), - React.createElement("div", { role: "tablist", style: { display: "flex", overflowX: segmented ? "hidden" : "auto", flex: 1, + React.createElement("style", null, `.ok-pager-tab{box-sizing:border-box;appearance:none;border:0;background:transparent;cursor:default;white-space:nowrap;flex-shrink:0;line-height:normal}.ok-pager-tab-bar[data-variant=pill] .ok-pager-tab{position:relative;z-index:1;border-radius:999px}.ok-pager-tab-bar[data-variant=pill] .ok-pager-tab[aria-selected=false]:hover{background:var(--pager-tab-hover)}.ok-pager-tab-bar[data-variant=pill] .ok-pager-tab[aria-selected=false]:active{background:var(--pager-tab-pressed)}.ok-pager-tablist{scrollbar-width:none}.ok-pager-tablist::-webkit-scrollbar{display:none}.ok-pager-toolbar{box-sizing:border-box;appearance:none;border:1px solid transparent;cursor:default;display:flex;align-items:center;justify-content:center;background:var(--pager-toolbar-base)}.ok-pager-toolbar:hover:enabled{background:var(--pager-toolbar-hover)}.ok-pager-toolbar:active:enabled{background:var(--pager-toolbar-pressed)}`), + React.createElement("div", { className: "ok-pager-tab-row", style: { display: "flex", alignItems: "center", height: "100%", flex: 1, minWidth: 0, + maxWidth: style?.contentMaxWidth, margin: style?.contentMaxWidth === undefined ? undefined : "0 auto" } }, + React.createElement("div", { role: "tablist", className: "ok-pager-tablist", ref: this.setTabList, style: { display: "flex", overflowX: segmented ? "hidden" : "auto", flex: 1, + position: "relative", alignItems: "center", padding: `${style?.contentPaddingVertical ?? 0}px ${style?.contentPaddingHorizontal ?? 20}px`, gap: style?.itemSpacing ?? 8 } }, + pill ? React.createElement("div", { key: "ok-pager-tab-pill", ref: this.setTabPill, className: "ok-pager-tab-pill", "aria-hidden": true, + style: { position: "absolute", left: 0, top: 0, width: 0, height: 0, borderRadius: 999, pointerEvents: "none", visibility: "hidden", + backgroundColor: String(style?.indicatorColor ?? "#000000"), willChange: "transform, width" } }) : null, ...config.items.map((item, position) => { const selected = item.key === selectedKey; const fontSelected = item.key === fontOwnerKey; return React.createElement("button", { key: item.key, role: "tab", type: "button", className: "ok-pager-tab", "aria-selected": selected, - ref: (button: HTMLButtonElement | null) => { if (button) this.tabButtons.set(item.key, button); else this.tabButtons.delete(item.key); }, + ref: this.tabButtonRef(item.key), "aria-label": item.accessibilityLabel ?? item.title, "data-testid": item.testID, - style: { padding: variant === "text" ? "6px 10px" : "6px 8px", minWidth: 44, height: (style?.height ?? 44) - 2 * (style?.contentPaddingVertical ?? 0), + style: { padding: `6px ${itemPadding}px`, minWidth: 44, height: (style?.height ?? 44) - 2 * (style?.contentPaddingVertical ?? 0), fontSize: style?.fontSize ?? (variant === "text" ? 18 : 16), fontWeight: variant === "text" && fontSelected ? 600 : 500, fontFamily: reserveTextFaces ? style?.fontFamily : fontSelected ? style?.activeFontFamily ?? style?.fontFamily : style?.fontFamily, @@ -1770,8 +1870,9 @@ export class CollapsiblePagerView extends React.PureComponent< gridTemplateAreas: reserveTextFaces ? '"label"' : undefined, alignItems: reserveTextFaces ? "center" : undefined, color: this.tabColor(selected, colorWeights ? colorWeights.get(item.key) ?? 0 : undefined), - flex: segmented ? 1 : undefined, borderRadius: segmented ? 999 : 0, - backgroundColor: segmented && selected ? String(style?.indicatorColor ?? "transparent") : "transparent", + flex: segmented ? 1 : undefined, borderRadius: segmented || pill ? 999 : 0, + // Pill items leave the background to CSS so hover / pressed apply. + backgroundColor: pill ? undefined : segmented && selected ? String(style?.indicatorColor ?? "transparent") : "transparent", borderBottom: variant === "underline" && selected ? `${style?.indicatorHeight ?? 2}px solid ${String(style?.indicatorColor ?? "#000000")}` : undefined }, onClick: () => { if (!config.controlled) { @@ -1792,7 +1893,7 @@ export class CollapsiblePagerView extends React.PureComponent< }, item.title)), ] : item.title); })), - config.toolbar !== undefined ? React.createElement("div", { ref: this.setToolbarSlot, className: "ok-pager-toolbar-slot", style: { flexShrink: 0, width: reservedWidth, height: "100%", position: "relative" } }) : null); + config.toolbar !== undefined ? React.createElement("div", { ref: this.setToolbarSlot, className: "ok-pager-toolbar-slot", style: { flexShrink: 0, width: reservedWidth, height: "100%", position: "relative" } }) : null)); } render() {