From db3a62712da5d4610a44de5ca1104cefb19b12b5 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 9 Nov 2026 22:12:58 +0800 Subject: [PATCH] feat(pager-view): add pill tab bar variant and centered content band (OK-66512) - nativeTabBar.style.variant "pill" on iOS, Android and Web: one rounded background behind the selected item, taken from current item frames. Web reads live button boxes on every sync, so items that only move when the list changes never reuse another item's width. - style.indicatorAnimationEnabled (pill, default true) snaps the pill and label colors to the selection when false. - style.contentMaxWidth centers the tab row and toolbar slot. - style.itemPaddingHorizontal and Web hover / pressed item colors. - New options travel in nativeTabBarConfig JSON; codegen props unchanged. - SPEC: "Pill tabs and centered content band". --- .../CollapsiblePagerNativeHeaders.kt | 100 ++++++++++++--- .../CollapsiblePagerViewManager.kt | 2 +- .../react-native-pager-view/docs/SPEC.md | 44 +++++++ .../RNCCollapsiblePagerViewComponentView.mm | 73 ++++++++++- .../src/CollapsiblePagerView.tsx | 31 ++++- .../src/CollapsiblePagerView.web.tsx | 119 ++++++++++++++++-- 6 files changed, 338 insertions(+), 31 deletions(-) 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() {