Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tall-icons-grow.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function NativeBottomTabsOriginalIcons() {
component={Contacts}
options={{
tabBarIcon: () => require('../../assets/avatar-1.png'),
tabBarIconSize: 34,
tabBarIconRenderingMode: 'original',
}}
/>
Expand Down
15 changes: 15 additions & 0 deletions artifacts/issue-561/README.md
Original file line number Diff line number Diff line change
@@ -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`
10 changes: 10 additions & 0 deletions artifacts/issue-561/android-avatar-hierarchy.txt
Original file line number Diff line number Diff line change
@@ -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"}
10 changes: 10 additions & 0 deletions artifacts/issue-561/android-default-hierarchy.txt
Original file line number Diff line number Diff line change
@@ -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"}
22 changes: 22 additions & 0 deletions artifacts/issue-561/android-tab-icon-size.gesture-telemetry.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
Binary file added artifacts/issue-561/android-tab-icon-size.mp4
Binary file not shown.
Binary file added artifacts/issue-561/android-tab-icon-size.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions artifacts/issue-561/ios-tab-icon-size.gesture-telemetry.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
Binary file added artifacts/issue-561/ios-tab-icon-size.mp4
Binary file not shown.
Binary file added artifacts/issue-561/ios-tab-icon-size.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions docs/docs/docs/guides/standalone-usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions docs/docs/docs/guides/usage-with-react-navigation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Tab.Screen
name="Profile"
component={Profile}
options={{
tabBarIcon: () => ({ uri: avatarFileUri }),
tabBarIconSize: 34,
}}
/>
```

#### `tabBarIconRenderingMode`

Rendering mode for image icons.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -59,7 +63,8 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) {
var disablePageAnimations = false
var items: MutableList<TabInfo> = mutableListOf()
private val iconSources: MutableMap<Int, ImageSource> = mutableMapOf()
private val drawableCache: MutableMap<ImageSource, Drawable> = mutableMapOf()
private var iconSizes: List<Double> = emptyList()
private val drawableCache: MutableMap<DrawableCacheKey, Drawable> = mutableMapOf()

private var isLayoutEnqueued = false
private var selectedItem: String? = null
Expand Down Expand Up @@ -239,6 +244,9 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) {
}

fun updateItems(items: MutableList<TabInfo>) {
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()
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -286,6 +295,8 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) {

view.findViewById<View>(com.google.android.material.R.id.navigation_bar_item_content_container)
?.setTabTestID(item.testID)

updateIconSize(menuItem, item)
}
}
}
Expand All @@ -296,6 +307,11 @@ class ReactBottomNavigationView(context: Context) : LinearLayout(context) {
}
}

fun setIconSizes(iconSizes: List<Double>) {
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)
}
Expand Down Expand Up @@ -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)
}
}
}
Expand All @@ -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))
Expand All @@ -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)
}
}
Expand All @@ -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<NavigationBarItemView>(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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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")
Expand All @@ -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?) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSNumber *> *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;
}
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion packages/react-native-bottom-tabs/ios/TabViewImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,15 @@ struct TabViewImpl: View {
icon: icon,
title: tabData.title,
color: props.inactiveTintColor,
iconSize: iconSize(for: tabData),
preservesOriginalIconColors: preservesOriginalIconColors,
props: props
)
item.selectedImage = makeTabBarItemImage(
icon: selectedIcon,
title: tabData.title,
color: tabActiveColor,
iconSize: iconSize(for: tabData),
preservesOriginalIconColors: preservesOriginalIconColors,
props: props
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading