diff --git a/.changeset/tall-icons-grow.md b/.changeset/tall-icons-grow.md new file mode 100644 index 00000000..3bb3ef65 --- /dev/null +++ b/.changeset/tall-icons-grow.md @@ -0,0 +1,6 @@ +--- +'react-native-bottom-tabs': minor +'@bottom-tabs/react-navigation': minor +--- + +Add a per-screen option for customizing tab image icon size. diff --git a/apps/example/src/Examples/NativeBottomTabsOriginalIcons.tsx b/apps/example/src/Examples/NativeBottomTabsOriginalIcons.tsx index fddf733b..5acc660f 100644 --- a/apps/example/src/Examples/NativeBottomTabsOriginalIcons.tsx +++ b/apps/example/src/Examples/NativeBottomTabsOriginalIcons.tsx @@ -21,6 +21,7 @@ export default function NativeBottomTabsOriginalIcons() { component={Contacts} options={{ tabBarIcon: () => require('../../assets/avatar-1.png'), + tabBarIconSize: 34, tabBarIconRenderingMode: 'original', }} /> diff --git a/artifacts/issue-561/README.md b/artifacts/issue-561/README.md new file mode 100644 index 00000000..8a676542 --- /dev/null +++ b/artifacts/issue-561/README.md @@ -0,0 +1,15 @@ +# Issue 561 device verification + +Captured with `agent-device` 0.20.10 against the example app's **Native Bottom Tabs with original icons** screen. + +- iOS: iPhone 17 Pro simulator, iOS 26.5. The Avatar tab uses `tabBarIconSize: 34`; neighboring image tabs retain the existing 27 pt default. +- Android: Pixel 10 Pro emulator, Android API 37 at 3x density. The final accessibility hierarchy measured the Avatar image at 102x102 px (34 dp) and the default Chat image at 72x72 px (24 dp). + +Artifacts: + +- `ios-tab-icon-size.png` and `ios-tab-icon-size-selected.png` +- `ios-tab-icon-size.mp4` +- `android-tab-icon-size.png` +- `android-tab-icon-size.mp4` +- `android-avatar-hierarchy.txt` and `android-default-hierarchy.txt` +- `*-gesture-telemetry.json` diff --git a/artifacts/issue-561/android-avatar-hierarchy.txt b/artifacts/issue-561/android-avatar-hierarchy.txt new file mode 100644 index 00000000..7e8f9671 --- /dev/null +++ b/artifacts/issue-561/android-avatar-hierarchy.txt @@ -0,0 +1,10 @@ +Page: bottomtabs.example +App: bottomtabs.example +Snapshot: 7 nodes +{"index":0,"type":"android.widget.FrameLayout","label":"Avatar","bundleId":"bottomtabs.example","rect":{"x":320,"y":2613,"width":320,"height":171},"enabled":true,"visibleToUser":true,"hittable":true,"depth":0,"ref":"e1"} +{"index":1,"type":"android.widget.LinearLayout","identifier":"bottomtabs.example:id/navigation_bar_item_content_container","bundleId":"bottomtabs.example","rect":{"x":425,"y":2637,"width":110,"height":147},"enabled":true,"visibleToUser":true,"depth":1,"parentIndex":0,"ref":"e2"} +{"index":2,"type":"android.widget.FrameLayout","identifier":"bottomtabs.example:id/navigation_bar_item_icon_container","bundleId":"bottomtabs.example","rect":{"x":429,"y":2637,"width":102,"height":102},"enabled":true,"visibleToUser":true,"depth":2,"parentIndex":1,"ref":"e3"} +{"index":3,"type":"android.widget.LinearLayout","identifier":"bottomtabs.example:id/navigation_bar_item_inner_content_container","bundleId":"bottomtabs.example","rect":{"x":429,"y":2637,"width":102,"height":102},"enabled":true,"visibleToUser":true,"depth":3,"parentIndex":2,"ref":"e4"} +{"index":4,"type":"android.widget.ImageView","identifier":"bottomtabs.example:id/navigation_bar_item_icon_view","bundleId":"bottomtabs.example","rect":{"x":429,"y":2637,"width":102,"height":102},"enabled":true,"visibleToUser":true,"depth":4,"parentIndex":3,"ref":"e5"} +{"index":5,"type":"android.view.ViewGroup","identifier":"bottomtabs.example:id/navigation_bar_item_labels_group","bundleId":"bottomtabs.example","rect":{"x":425,"y":2742,"width":110,"height":42},"enabled":true,"visibleToUser":true,"depth":2,"parentIndex":1,"ref":"e6"} +{"index":6,"type":"android.widget.TextView","label":"Avatar","value":"Avatar","identifier":"bottomtabs.example:id/navigation_bar_item_small_label_view","bundleId":"bottomtabs.example","rect":{"x":425,"y":2742,"width":109,"height":42},"enabled":true,"visibleToUser":true,"depth":3,"parentIndex":5,"ref":"e7"} diff --git a/artifacts/issue-561/android-default-hierarchy.txt b/artifacts/issue-561/android-default-hierarchy.txt new file mode 100644 index 00000000..5c3ce085 --- /dev/null +++ b/artifacts/issue-561/android-default-hierarchy.txt @@ -0,0 +1,10 @@ +Page: bottomtabs.example +App: bottomtabs.example +Snapshot: 7 nodes +{"index":0,"type":"android.widget.FrameLayout","label":"Chat","bundleId":"bottomtabs.example","rect":{"x":960,"y":2613,"width":320,"height":171},"enabled":true,"visibleToUser":true,"hittable":true,"depth":0,"ref":"e1"} +{"index":1,"type":"android.widget.LinearLayout","identifier":"bottomtabs.example:id/navigation_bar_item_content_container","bundleId":"bottomtabs.example","rect":{"x":1080,"y":2637,"width":79,"height":117},"enabled":true,"visibleToUser":true,"depth":1,"parentIndex":0,"ref":"e2"} +{"index":2,"type":"android.widget.FrameLayout","identifier":"bottomtabs.example:id/navigation_bar_item_icon_container","bundleId":"bottomtabs.example","rect":{"x":1083,"y":2637,"width":72,"height":72},"enabled":true,"visibleToUser":true,"depth":2,"parentIndex":1,"ref":"e3"} +{"index":3,"type":"android.widget.LinearLayout","identifier":"bottomtabs.example:id/navigation_bar_item_inner_content_container","bundleId":"bottomtabs.example","rect":{"x":1083,"y":2637,"width":72,"height":72},"enabled":true,"visibleToUser":true,"depth":3,"parentIndex":2,"ref":"e4"} +{"index":4,"type":"android.widget.ImageView","identifier":"bottomtabs.example:id/navigation_bar_item_icon_view","bundleId":"bottomtabs.example","rect":{"x":1083,"y":2637,"width":72,"height":72},"enabled":true,"visibleToUser":true,"depth":4,"parentIndex":3,"ref":"e5"} +{"index":5,"type":"android.view.ViewGroup","identifier":"bottomtabs.example:id/navigation_bar_item_labels_group","bundleId":"bottomtabs.example","rect":{"x":1080,"y":2712,"width":79,"height":42},"enabled":true,"visibleToUser":true,"depth":2,"parentIndex":1,"ref":"e6"} +{"index":6,"type":"android.widget.TextView","label":"Chat","value":"Chat","identifier":"bottomtabs.example:id/navigation_bar_item_small_label_view","bundleId":"bottomtabs.example","rect":{"x":1080,"y":2712,"width":79,"height":42},"enabled":true,"visibleToUser":true,"depth":3,"parentIndex":5,"ref":"e7"} diff --git a/artifacts/issue-561/android-tab-icon-size.gesture-telemetry.json b/artifacts/issue-561/android-tab-icon-size.gesture-telemetry.json new file mode 100644 index 00000000..b4ed0e92 --- /dev/null +++ b/artifacts/issue-561/android-tab-icon-size.gesture-telemetry.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "generatedAt": "2026-08-24T20:37:38.885Z", + "events": [ + { + "kind": "tap", + "tMs": 638, + "x": 480, + "y": 2699, + "referenceWidth": 1280, + "referenceHeight": 2856 + }, + { + "kind": "tap", + "tMs": 6687, + "x": 1120, + "y": 2699, + "referenceWidth": 1280, + "referenceHeight": 2856 + } + ] +} \ No newline at end of file diff --git a/artifacts/issue-561/android-tab-icon-size.mp4 b/artifacts/issue-561/android-tab-icon-size.mp4 new file mode 100644 index 00000000..4c35f7f2 Binary files /dev/null and b/artifacts/issue-561/android-tab-icon-size.mp4 differ diff --git a/artifacts/issue-561/android-tab-icon-size.png b/artifacts/issue-561/android-tab-icon-size.png new file mode 100644 index 00000000..018aa353 Binary files /dev/null and b/artifacts/issue-561/android-tab-icon-size.png differ diff --git a/artifacts/issue-561/ios-tab-icon-size-selected.png b/artifacts/issue-561/ios-tab-icon-size-selected.png new file mode 100644 index 00000000..ea53ce41 Binary files /dev/null and b/artifacts/issue-561/ios-tab-icon-size-selected.png differ diff --git a/artifacts/issue-561/ios-tab-icon-size.gesture-telemetry.json b/artifacts/issue-561/ios-tab-icon-size.gesture-telemetry.json new file mode 100644 index 00000000..f0415c24 --- /dev/null +++ b/artifacts/issue-561/ios-tab-icon-size.gesture-telemetry.json @@ -0,0 +1,30 @@ +{ + "version": 1, + "generatedAt": "2026-08-24T20:32:29.204Z", + "events": [ + { + "kind": "tap", + "tMs": 4578.965576171875, + "x": 201, + "y": 489, + "referenceWidth": 402, + "referenceHeight": 874 + }, + { + "kind": "tap", + "tMs": 23342.7021484375, + "x": 159, + "y": 822, + "referenceWidth": 402, + "referenceHeight": 874 + }, + { + "kind": "tap", + "tMs": 29424.4873046875, + "x": 328, + "y": 822, + "referenceWidth": 402, + "referenceHeight": 874 + } + ] +} \ No newline at end of file diff --git a/artifacts/issue-561/ios-tab-icon-size.mp4 b/artifacts/issue-561/ios-tab-icon-size.mp4 new file mode 100644 index 00000000..cb51b2ee Binary files /dev/null and b/artifacts/issue-561/ios-tab-icon-size.mp4 differ diff --git a/artifacts/issue-561/ios-tab-icon-size.png b/artifacts/issue-561/ios-tab-icon-size.png new file mode 100644 index 00000000..7d55692f Binary files /dev/null and b/artifacts/issue-561/ios-tab-icon-size.png differ diff --git a/docs/docs/docs/guides/standalone-usage.mdx b/docs/docs/docs/guides/standalone-usage.mdx index d9f02c43..85c75e37 100644 --- a/docs/docs/docs/guides/standalone-usage.mdx +++ b/docs/docs/docs/guides/standalone-usage.mdx @@ -236,6 +236,7 @@ Each route in the `routes` array can have the following properties: - `title`: Display title for the tab - `focusedIcon`: Icon to show when tab is active - `unfocusedIcon`: Icon to show when tab is inactive (optional) +- `iconSize`: Width and height of the image icon in logical pixels. Uses the native default when omitted. - `iconRenderingMode`: Rendering mode for icons. Use `'original'` to preserve multicolor icons instead of applying the native tab tint. - `badge`: Badge text to display on the tab - `activeTintColor`: Custom active tint color for this specific tab @@ -290,6 +291,13 @@ Function to get the rendering mode for an image icon. Use `original` to preserve the original colors of multicolor image icons, such as logos or branded assets, instead of applying the native tab tint. +#### `getIconSize` + +Function to get the width and height of an image icon in logical pixels. + +- Default: Uses `route.iconSize` +- When omitted: Uses the native platform default + #### `getHidden` Function to determine if a tab should be hidden. diff --git a/docs/docs/docs/guides/usage-with-react-navigation.mdx b/docs/docs/docs/guides/usage-with-react-navigation.mdx index 78e49d55..58142959 100644 --- a/docs/docs/docs/guides/usage-with-react-navigation.mdx +++ b/docs/docs/docs/guides/usage-with-react-navigation.mdx @@ -286,6 +286,21 @@ Function that given `{ focused: boolean }` returns `ImageSource` or `AppleIcon` SF Symbols are only supported on Apple platforms. ::: +#### `tabBarIconSize` + +Width and height of the tab image icon in logical pixels. This can be set per screen, and uses the native platform default when omitted. + +```tsx + ({ uri: avatarFileUri }), + tabBarIconSize: 34, + }} +/> +``` + #### `tabBarIconRenderingMode` Rendering mode for image icons. diff --git a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt index 3c3d4bdb..8374e0aa 100644 --- a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt +++ b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabView.kt @@ -37,10 +37,14 @@ import com.facebook.react.common.assets.ReactFontManager import com.facebook.react.modules.core.ReactChoreographer import com.facebook.react.views.text.ReactTypefaceUtils import com.google.android.material.bottomnavigation.BottomNavigationView +import com.google.android.material.navigation.NavigationBarItemView import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_AUTO import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_LABELED import com.google.android.material.navigation.NavigationBarView.LABEL_VISIBILITY_UNLABELED import com.google.android.material.transition.platform.MaterialFadeThrough +import kotlin.math.roundToInt + +private data class DrawableCacheKey(val source: ImageSource, val size: Int) class ExtendedBottomNavigationView(context: Context) : BottomNavigationView(context) { override fun getMaxItemCount(): Int { @@ -59,7 +63,8 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { var disablePageAnimations = false var items: MutableList = mutableListOf() private val iconSources: MutableMap = mutableMapOf() - private val drawableCache: MutableMap = mutableMapOf() + private var iconSizes: List = emptyList() + private val drawableCache: MutableMap = mutableMapOf() private var isLayoutEnqueued = false private var selectedItem: String? = null @@ -239,6 +244,9 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } fun updateItems(items: MutableList) { + val items = items.mapIndexed { index, item -> + item.copy(iconSize = iconSizes.getOrNull(index)?.takeIf { it > 0 }) + }.toMutableList() // If an item got removed, let's re-add all items if (items.size < this.items.size) { bottomNavigation.menu.clear() @@ -253,9 +261,10 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { menuItem.isVisible = !item.hidden updateIconTintMode(menuItem, item) if (iconSources.containsKey(index)) { - getDrawable(iconSources[index]!!) { + getDrawable(iconSources[index]!!, getIconSize(item)) { menuItem.icon = it updateIconTintMode(menuItem, item) + updateIconSize(menuItem, item) } } @@ -286,6 +295,8 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { view.findViewById(com.google.android.material.R.id.navigation_bar_item_content_container) ?.setTabTestID(item.testID) + + updateIconSize(menuItem, item) } } } @@ -296,6 +307,11 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } } + fun setIconSizes(iconSizes: List) { + this.iconSizes = iconSizes + updateItems(items) + } + private fun getOrCreateItem(index: Int, title: String): MenuItem { return bottomNavigation.menu.findItem(index) ?: bottomNavigation.menu.add(0, index, 0, title) } @@ -342,10 +358,12 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { // Update existing item if exists. bottomNavigation.menu.findItem(idx)?.let { menuItem -> - getDrawable(imageSource) { + val item = items.getOrNull(idx) + getDrawable(imageSource, getIconSize(item)) { menuItem.icon = it - items.getOrNull(idx)?.let { item -> + item?.let { updateIconTintMode(menuItem, item) + updateIconSize(menuItem, item) } } } @@ -372,12 +390,16 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { } @SuppressLint("CheckResult") - private fun getDrawable(imageSource: ImageSource, onDrawableReady: (Drawable?) -> Unit) { - drawableCache[imageSource]?.let { + private fun getDrawable( + imageSource: ImageSource, + iconSizePx: Int, + onDrawableReady: (Drawable?) -> Unit + ) { + val cacheKey = DrawableCacheKey(imageSource, iconSizePx) + drawableCache[cacheKey]?.let { onDrawableReady(it) return } - val iconSizePx = bottomNavigation.itemIconSize val request = ImageRequest.Builder(context) .data(imageSource.getUri(context)) .size(CoilSize(iconSizePx, iconSizePx)) @@ -386,7 +408,7 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { .target { drawable -> post { val stateDrawable = drawable.asDrawable(context.resources) - drawableCache[imageSource] = stateDrawable + drawableCache[cacheKey] = stateDrawable onDrawableReady(stateDrawable) } } @@ -400,6 +422,21 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) { imageLoader.enqueue(request) } + private fun getIconSize(item: TabInfo?): Int { + val iconSize = item?.iconSize ?: return bottomNavigation.itemIconSize + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + iconSize.toFloat(), + resources.displayMetrics + ).roundToInt() + } + + private fun updateIconSize(menuItem: MenuItem, item: TabInfo) { + val itemView = bottomNavigation.findViewById(menuItem.itemId) + ?: return + itemView.setIconSize(getIconSize(item)) + } + fun setBarTintColor(color: Int?) { // Set the color, either using the active background color or a default color. val backgroundColor = diff --git a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewManager.kt b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewManager.kt index 7f597216..6c8d7025 100644 --- a/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewManager.kt +++ b/packages/react-native-bottom-tabs/android/src/main/java/com/rcttabview/RCTTabViewManager.kt @@ -24,6 +24,7 @@ data class TabInfo( val badgeBackgroundColor: Int?, val badgeTextColor: Int?, val activeTintColor: Int?, + val iconSize: Double?, val iconRenderingMode: String?, val hidden: Boolean, val testID: String? @@ -105,6 +106,7 @@ class RCTTabViewManager(context: ReactApplicationContext) : badgeBackgroundColor = if (item.hasKey("badgeBackgroundColor")) item.getInt("badgeBackgroundColor") else null, badgeTextColor = if (item.hasKey("badgeTextColor")) item.getInt("badgeTextColor") else null, activeTintColor = if (item.hasKey("activeTintColor")) item.getInt("activeTintColor") else null, + iconSize = null, iconRenderingMode = if (item.hasKey("iconRenderingMode")) item.getString("iconRenderingMode") else null, hidden = if (item.hasKey("hidden")) item.getBoolean("hidden") else false, testID = item.getString("testID") @@ -126,6 +128,16 @@ class RCTTabViewManager(context: ReactApplicationContext) : view.setIcons(value) } + override fun setIconSizes(view: ReactBottomNavigationView?, value: ReadableArray?) { + view?.setIconSizes( + if (value == null) { + emptyList() + } else { + (0 until value.size()).map { value.getDouble(it) } + } + ) + } + override fun setFocusedIcons(view: ReactBottomNavigationView?, value: ReadableArray?) { } diff --git a/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm b/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm index 09ab604b..52cd8a88 100644 --- a/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm +++ b/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm @@ -104,6 +104,14 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & _tabViewProvider.itemsData = convertItemsToArray(newViewProps.items); } + if (oldViewProps.iconSizes != newViewProps.iconSizes) { + NSMutableArray *iconSizes = [NSMutableArray arrayWithCapacity:newViewProps.iconSizes.size()]; + for (const auto iconSize : newViewProps.iconSizes) { + [iconSizes addObject:@(iconSize)]; + } + _tabViewProvider.iconSizes = iconSizes; + } + if (oldViewProps.translucent != newViewProps.translucent) { _tabViewProvider.translucent = newViewProps.translucent; } @@ -210,6 +218,7 @@ - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const & sfSymbol:RCTNSStringFromStringNilIfEmpty(item.sfSymbol) focusedSfSymbol:RCTNSStringFromStringNilIfEmpty(item.focusedSfSymbol) activeTintColor:RCTUIColorFromSharedColor(item.activeTintColor) + iconSize:nil iconRenderingMode:RCTNSStringFromStringNilIfEmpty(item.iconRenderingMode) hidden:item.hidden testID:RCTNSStringFromStringNilIfEmpty(item.testID) diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index 68053e1f..b639c4d1 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -245,6 +245,7 @@ struct TabViewImpl: View { icon: icon, title: tabData.title, color: props.inactiveTintColor, + iconSize: iconSize(for: tabData), preservesOriginalIconColors: preservesOriginalIconColors, props: props ) @@ -252,6 +253,7 @@ struct TabViewImpl: View { icon: selectedIcon, title: tabData.title, color: tabActiveColor, + iconSize: iconSize(for: tabData), preservesOriginalIconColors: preservesOriginalIconColors, props: props ) @@ -343,11 +345,11 @@ struct TabViewImpl: View { icon: UIImage, title: String, color: UIColor?, + iconSize: CGSize, preservesOriginalIconColors: Bool = false, props: TabViewProps ) -> UIImage { let color = color ?? .label - let iconSize = CGSize(width: 27, height: 27) let font = TabBarFontSize.createFontAttributes( size: props.fontSize.map(CGFloat.init) ?? TabBarFontSize.defaultSize, @@ -402,6 +404,14 @@ struct TabViewImpl: View { return image.withRenderingMode(.alwaysOriginal) } + private func iconSize(for tabData: TabInfo) -> CGSize { + guard let value = tabData.iconSize?.doubleValue, value > 0 else { + return CGSize(width: 27, height: 27) + } + + return CGSize(width: value, height: value) + } + private func aspectFitRect(size: CGSize, in rect: CGRect) -> CGRect { guard size.width > 0, size.height > 0 else { return rect diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift index e5663453..f7cea8d0 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -10,6 +10,7 @@ public final class TabInfo: NSObject { public let sfSymbol: String public let focusedSfSymbol: String? public let activeTintColor: PlatformColor? + public var iconSize: NSNumber? public let iconRenderingMode: String? public let hidden: Bool public let testID: String? @@ -23,6 +24,7 @@ public final class TabInfo: NSObject { sfSymbol: String, focusedSfSymbol: String?, activeTintColor: PlatformColor?, + iconSize: NSNumber?, iconRenderingMode: String?, hidden: Bool, testID: String?, @@ -35,6 +37,7 @@ public final class TabInfo: NSObject { self.sfSymbol = sfSymbol self.focusedSfSymbol = focusedSfSymbol self.activeTintColor = activeTintColor + self.iconSize = iconSize self.iconRenderingMode = iconRenderingMode self.hidden = hidden self.testID = testID @@ -57,7 +60,7 @@ public final class TabInfo: NSObject { private var props = TabViewProps() private var hostingController: PlatformHostingController? private var coalescingKey: UInt16 = 0 - private var iconSize = CGSize(width: 27, height: 27) + private let defaultIconSize = CGSize(width: 27, height: 27) @objc var onPageSelected: RCTDirectEventBlock? @@ -180,7 +183,13 @@ public final class TabInfo: NSObject { @objc public var itemsData: [TabInfo] = [] { didSet { - props.items = itemsData + updateItems() + } + } + + @objc public var iconSizes: [NSNumber] = [] { + didSet { + updateItems() } } @@ -280,13 +289,20 @@ public final class TabInfo: NSObject { guard let image else { return } DispatchQueue.main.async { [weak self] in guard let self else { return } - let icon = image.resizeImageTo(size: iconSize) + guard let resizedIcon = image.resizeImageTo(size: iconSize(at: index)) else { + return + } + #if os(iOS) + let icon = alignedIcon(resizedIcon, at: index) + #else + let icon = resizedIcon + #endif #if os(iOS) if props.experimentalBakedTintColors { if focused { - props.focusedIcons[index] = icon?.withRenderingMode(.alwaysTemplate) + props.focusedIcons[index] = icon.withRenderingMode(.alwaysTemplate) } else { - props.icons[index] = icon?.withRenderingMode(.alwaysTemplate) + props.icons[index] = icon.withRenderingMode(.alwaysTemplate) } } else { if focused { @@ -309,4 +325,44 @@ public final class TabInfo: NSObject { } } } + + private func iconSize(at index: Int) -> CGSize { + guard let value = props.items[safe: index]?.iconSize?.doubleValue, value > 0 else { + return defaultIconSize + } + + return CGSize(width: value, height: value) + } + + private func updateItems() { + let previousIconSizes = props.items.map(\.iconSize) + for (index, item) in itemsData.enumerated() { + if let value = iconSizes[safe: index], value.doubleValue > 0 { + item.iconSize = value + } else { + item.iconSize = nil + } + } + props.items = itemsData + if previousIconSizes != itemsData.map(\.iconSize) { + loadIcons(icons, focused: false) + loadIcons(focusedIcons, focused: true) + } + } + + #if os(iOS) + private func alignedIcon(_ image: UIImage, at index: Int) -> UIImage { + let size = iconSize(at: index) + let horizontalInset = (size.width - defaultIconSize.width) / 2 + let verticalInset = (size.height - defaultIconSize.height) / 2 + return image.withAlignmentRectInsets( + UIEdgeInsets( + top: verticalInset, + left: horizontalInset, + bottom: verticalInset, + right: horizontalInset + ) + ) + } + #endif } diff --git a/packages/react-native-bottom-tabs/package.json b/packages/react-native-bottom-tabs/package.json index 683e719f..5c533161 100644 --- a/packages/react-native-bottom-tabs/package.json +++ b/packages/react-native-bottom-tabs/package.json @@ -67,9 +67,12 @@ "@react-native/eslint-config": "0.81.1", "@types/jest": "^29.5.5", "@types/react": "^18.2.44", + "@types/react-test-renderer": "^19.1.0", + "jest": "^29.7.0", "react": "^19.1.0", "react-native": "0.81.1", "react-native-builder-bob": "^0.40.13", + "react-test-renderer": "19.1.0", "typescript": "^5.9.2" }, "peerDependencies": { @@ -103,6 +106,12 @@ "sf-symbols-typescript": "^2.0.0", "use-latest-callback": "^0.2.1" }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/lib/" + ] + }, "codegenConfig": { "name": "RNCTabView", "type": "all", diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index 0acfb842..dafec055 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -146,6 +146,11 @@ interface Props { focused: boolean; }) => ImageSource | AppleIcon | undefined | null; + /** + * Get the size of the tab image icon, uses `route.iconSize` by default. + */ + getIconSize?: (props: { route: Route }) => number | undefined; + /** * Get the rendering mode for the tab icon, uses `route.iconRenderingMode` by default. * @@ -262,6 +267,7 @@ const TabView = ({ getActiveTintColor = ({ route }: { route: Route }) => route.activeTintColor, getTestID = ({ route }: { route: Route }) => route.testID, getRole = ({ route }: { route: Route }) => route.role, + getIconSize = ({ route }: { route: Route }) => route.iconSize, getIconRenderingMode = ({ route }: { route: Route }) => route.iconRenderingMode, getSceneStyle = ({ route }: { route: Route }) => route.style, @@ -383,6 +389,11 @@ const TabView = ({ ] ); + const iconSizes = React.useMemo( + () => trimmedRoutes.map((route) => getIconSize({ route }) ?? 0), + [getIconSize, trimmedRoutes] + ); + const resolvedIconAssets: ImageSource[] = React.useMemo( () => // Pass empty object for icons that are not provided to avoid index mismatch on native side. @@ -458,6 +469,7 @@ const TabView = ({ {...tabLabelStyle} style={styles.fullWidth} items={items} + iconSizes={iconSizes} // When rendering a custom tab bar, icons can be React elements, which will not be properly resolved. icons={renderCustomTabBar ? undefined : resolvedIconAssets} focusedIcons={ diff --git a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts index 50ecf7c4..bdd1ccb9 100644 --- a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts +++ b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts @@ -40,6 +40,7 @@ export type TabViewItems = ReadonlyArray<{ export interface TabViewProps extends ViewProps { items: TabViewItems; + iconSizes?: ReadonlyArray; selectedPage: string; onPageSelected?: DirectEventHandler; onTabLongPress?: DirectEventHandler; diff --git a/packages/react-native-bottom-tabs/src/__tests__/index.test.tsx b/packages/react-native-bottom-tabs/src/__tests__/index.test.tsx index bf84291a..d842ce8e 100644 --- a/packages/react-native-bottom-tabs/src/__tests__/index.test.tsx +++ b/packages/react-native-bottom-tabs/src/__tests__/index.test.tsx @@ -1 +1,57 @@ -it.todo('write a test'); +import React from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import TabView from '../TabView'; + +jest.mock('../TabViewNativeComponent', () => { + const ReactModule = require('react'); + return ReactModule.forwardRef((props: object, ref: React.Ref) => + ReactModule.createElement('NativeTabView', { ...props, ref }) + ); +}); + +const renderTabView = ( + routes: Array<{ key: string; title: string; iconSize?: number }>, + getIconSize?: (props: { + route: (typeof routes)[number]; + }) => number | undefined +) => { + let renderer: TestRenderer.ReactTestRenderer; + + act(() => { + renderer = TestRenderer.create( + null} + onIndexChange={jest.fn()} + getIconSize={getIconSize} + /> + ); + }); + + return renderer!.root.find( + (node) => (node.type as unknown) === 'NativeTabView' + ).props; +}; + +describe('icon size', () => { + it('leaves the size unset to preserve native defaults', () => { + const { iconSizes } = renderTabView([{ key: 'home', title: 'Home' }]); + + expect(iconSizes).toEqual([0]); + }); + + it('passes a per-route override to native', () => { + const { iconSizes } = renderTabView([ + { key: 'profile', title: 'Profile', iconSize: 34 }, + ]); + + expect(iconSizes).toEqual([34]); + }); + + it('supports custom route types through getIconSize', () => { + const routes = [{ key: 'profile', title: 'Profile' }]; + const { iconSizes } = renderTabView(routes, () => 36); + + expect(iconSizes).toEqual([36]); + }); +}); diff --git a/packages/react-native-bottom-tabs/src/types.ts b/packages/react-native-bottom-tabs/src/types.ts index 3f4eda80..88dccf61 100644 --- a/packages/react-native-bottom-tabs/src/types.ts +++ b/packages/react-native-bottom-tabs/src/types.ts @@ -20,6 +20,7 @@ export type BaseRoute = { lazy?: boolean; focusedIcon?: ImageSourcePropType | AppleIcon; unfocusedIcon?: ImageSourcePropType | AppleIcon; + iconSize?: number; iconRenderingMode?: IconRenderingMode; activeTintColor?: string; hidden?: boolean; diff --git a/packages/react-navigation/package.json b/packages/react-navigation/package.json index 31602bc1..7e03d875 100644 --- a/packages/react-navigation/package.json +++ b/packages/react-navigation/package.json @@ -58,11 +58,13 @@ "devDependencies": { "@react-navigation/native": "^7.3.0", "@types/color": "^4.2.0", + "@types/react-test-renderer": "^19.1.0", "jest": "^29.7.0", "react": "^19.1.0", "react-native": "0.81.1", "react-native-bottom-tabs": "*", "react-native-builder-bob": "^0.40.13", + "react-test-renderer": "19.1.0", "typescript": "^5.9.2" }, "dependencies": { diff --git a/packages/react-navigation/src/__tests__/NativeBottomTabView.test.tsx b/packages/react-navigation/src/__tests__/NativeBottomTabView.test.tsx new file mode 100644 index 00000000..f44506df --- /dev/null +++ b/packages/react-navigation/src/__tests__/NativeBottomTabView.test.tsx @@ -0,0 +1,56 @@ +import TestRenderer, { act } from 'react-test-renderer'; +import NativeBottomTabView from '../views/NativeBottomTabView'; + +jest.mock('@react-navigation/native', () => ({ + CommonActions: { navigate: jest.fn() }, +})); + +jest.mock('react-native-bottom-tabs', () => { + const React = require('react'); + return (props: object) => React.createElement('TabView', props); +}); + +describe('tabBarIconSize', () => { + it('maps per-screen overrides and preserves an unset default', () => { + const state = { + index: 0, + key: 'tabs', + routeNames: ['Home', 'Profile'], + history: [], + preloadedRouteKeys: [], + stale: false as const, + type: 'tab' as const, + routes: [ + { key: 'home', name: 'Home' }, + { key: 'profile', name: 'Profile' }, + ], + }; + const descriptors = { + home: { + options: {}, + render: jest.fn(() => null), + }, + profile: { + options: { tabBarIconSize: 34 }, + render: jest.fn(() => null), + }, + }; + let renderer: TestRenderer.ReactTestRenderer; + + act(() => { + renderer = TestRenderer.create( + + ); + }); + + const { navigationState } = renderer!.root.find( + (node) => (node.type as unknown) === 'TabView' + ).props; + expect(navigationState.routes[0].iconSize).toBeUndefined(); + expect(navigationState.routes[1].iconSize).toBe(34); + }); +}); diff --git a/packages/react-navigation/src/types.ts b/packages/react-navigation/src/types.ts index eb1967cd..1f8b208e 100644 --- a/packages/react-navigation/src/types.ts +++ b/packages/react-navigation/src/types.ts @@ -71,6 +71,11 @@ export type NativeBottomTabNavigationOptions = { */ tabBarIcon?: (props: { focused: boolean }) => ImageSourcePropType | AppleIcon; + /** + * Width and height of the tab image icon in logical pixels. + */ + tabBarIconSize?: number; + /** * Rendering mode for the tab icon. Use `original` to preserve multicolor image icons. */ @@ -159,6 +164,7 @@ export type NativeBottomTabNavigationConfig = Partial< | 'renderScene' | 'getLazy' | 'getIcon' + | 'getIconSize' | 'getIconRenderingMode' | 'getLabelText' | 'getBadge' diff --git a/packages/react-navigation/src/views/NativeBottomTabView.tsx b/packages/react-navigation/src/views/NativeBottomTabView.tsx index 629bec4b..4ac7468c 100644 --- a/packages/react-navigation/src/views/NativeBottomTabView.tsx +++ b/packages/react-navigation/src/views/NativeBottomTabView.tsx @@ -28,7 +28,13 @@ export default function NativeBottomTabView({ return ( ({ + ...route, + iconSize: descriptors[route.key]?.options.tabBarIconSize, + })), + }} renderScene={({ route }) => descriptors[route.key]?.render()} getActiveTintColor={({ route }) => { return descriptors[route.key]?.options.tabBarActiveTintColor; diff --git a/yarn.lock b/yarn.lock index e58ee29d..da394218 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3029,12 +3029,14 @@ __metadata: dependencies: "@react-navigation/native": "npm:^7.3.0" "@types/color": "npm:^4.2.0" + "@types/react-test-renderer": "npm:^19.1.0" color: "npm:^5.0.0" jest: "npm:^29.7.0" react: "npm:^19.1.0" react-native: "npm:0.81.1" react-native-bottom-tabs: "npm:*" react-native-builder-bob: "npm:^0.40.13" + react-test-renderer: "npm:19.1.0" typescript: "npm:^5.9.2" peerDependencies: "@react-navigation/native": ">=7" @@ -16648,10 +16650,13 @@ __metadata: "@react-native/eslint-config": "npm:0.81.1" "@types/jest": "npm:^29.5.5" "@types/react": "npm:^18.2.44" + "@types/react-test-renderer": "npm:^19.1.0" + jest: "npm:^29.7.0" react: "npm:^19.1.0" react-freeze: "npm:^1.0.0" react-native: "npm:0.81.1" react-native-builder-bob: "npm:^0.40.13" + react-test-renderer: "npm:19.1.0" sf-symbols-typescript: "npm:^2.0.0" typescript: "npm:^5.9.2" use-latest-callback: "npm:^0.2.1"