diff --git a/FabricExample/android/app/src/main/res/drawable/person_walking.xml b/FabricExample/android/app/src/main/res/drawable/person_walking.xml new file mode 100644 index 0000000000..7cf9490d16 --- /dev/null +++ b/FabricExample/android/app/src/main/res/drawable/person_walking.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FabricExample/android/app/src/main/res/drawable/swm_logo.xml b/FabricExample/android/app/src/main/res/drawable/swm_logo.xml new file mode 100644 index 0000000000..de43a23b56 --- /dev/null +++ b/FabricExample/android/app/src/main/res/drawable/swm_logo.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/FabricExample/ios/FabricExample/Images.xcassets/nano.swm.symbolset/Contents.json b/FabricExample/ios/FabricExample/Images.xcassets/nano.swm.symbolset/Contents.json new file mode 100644 index 0000000000..6e08e083e5 --- /dev/null +++ b/FabricExample/ios/FabricExample/Images.xcassets/nano.swm.symbolset/Contents.json @@ -0,0 +1,15 @@ +{ + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "symbol-rendering-intent": "template" + }, + "symbols": [ + { + "filename": "nano.swm.svg", + "idiom": "universal" + } + ] +} \ No newline at end of file diff --git a/FabricExample/ios/FabricExample/Images.xcassets/nano.swm.symbolset/nano.swm.svg b/FabricExample/ios/FabricExample/Images.xcassets/nano.swm.symbolset/nano.swm.svg new file mode 100644 index 0000000000..614671e8b0 --- /dev/null +++ b/FabricExample/ios/FabricExample/Images.xcassets/nano.swm.symbolset/nano.swm.svg @@ -0,0 +1,45 @@ + + + + + + Small + Medium + Large + + + Ultralight + Regular + Black + Generated from nano.swm + Template v.3.0 + Generated by react-native-nano-icons + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FabricExample/ios/FabricExample/Images.xcassets/nanomc.walker.imageset/Contents.json b/FabricExample/ios/FabricExample/Images.xcassets/nanomc.walker.imageset/Contents.json new file mode 100644 index 0000000000..405b6983dd --- /dev/null +++ b/FabricExample/ios/FabricExample/Images.xcassets/nanomc.walker.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images": [ + { + "filename": "nanomc.walker.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true, + "template-rendering-intent": "original" + } +} \ No newline at end of file diff --git a/FabricExample/ios/FabricExample/Images.xcassets/nanomc.walker.imageset/nanomc.walker.svg b/FabricExample/ios/FabricExample/Images.xcassets/nanomc.walker.imageset/nanomc.walker.svg new file mode 100644 index 0000000000..883d10e8bd --- /dev/null +++ b/FabricExample/ios/FabricExample/Images.xcassets/nanomc.walker.imageset/nanomc.walker.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/java/com/swmansion/rnscreens/helpers/NoTintDrawable.kt b/android/src/main/java/com/swmansion/rnscreens/helpers/NoTintDrawable.kt new file mode 100644 index 0000000000..2c2e8e7a66 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/helpers/NoTintDrawable.kt @@ -0,0 +1,18 @@ +package com.swmansion.rnscreens.helpers + +import android.content.res.ColorStateList +import android.graphics.PorterDuff +import android.graphics.drawable.Drawable +import androidx.appcompat.graphics.drawable.DrawableWrapperCompat + +// Ignores tinting so the wrapped icon keeps its own colors even when a host view +// (e.g. BottomNavigationView) applies an itemIconTintList. +internal class NoTintDrawable( + drawable: Drawable, +) : DrawableWrapperCompat(drawable) { + override fun setTintList(tint: ColorStateList?) = Unit + + override fun setTint(tintColor: Int) = Unit + + override fun setTintMode(tintMode: PorterDuff.Mode?) = Unit +} diff --git a/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceApplicator.kt b/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceApplicator.kt index 790428e886..10cc9835f4 100644 --- a/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceApplicator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceApplicator.kt @@ -3,6 +3,8 @@ package com.swmansion.rnscreens.tabs.appearance import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList +import android.graphics.drawable.Drawable +import android.graphics.drawable.InsetDrawable import android.graphics.drawable.StateListDrawable import android.util.TypedValue import android.view.MenuItem @@ -16,12 +18,83 @@ import com.google.android.material.R import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.navigation.NavigationBarView import com.swmansion.rnscreens.tabs.screen.TabsScreen +import com.swmansion.rnscreens.utils.dpToPx +import com.swmansion.rnscreens.utils.pxToDp import com.swmansion.rnscreens.utils.resolveColorAttr @SuppressLint("PrivateResource") // We want to use variables from material design for default values internal class TabsAppearanceApplicator( private val bottomNavigationView: BottomNavigationView, ) { + // Resolved on each access: tracks the material library version and the display density. + internal val defaultIconSizeDp: Float + get() = + bottomNavigationView.pxToDp( + bottomNavigationView.resources.getDimension(R.dimen.mtrl_navigation_bar_item_default_icon_size), + ) + + internal fun effectiveIconSizeDp(tabsScreen: TabsScreen): Float = + if (tabsScreen.drawableIconSize > 0f) tabsScreen.drawableIconSize else defaultIconSizeDp + + // Material allows only one icon size for all items; iconBoxDp is the largest effective per-tab size. + fun applyIconBox(iconBoxDp: Float) { + bottomNavigationView.itemIconSize = bottomNavigationView.dpToPx(iconBoxDp).toInt() + } + + // Resolved on each access: tracks the material library version and the display density. + private val defaultIndicatorWidthPx: Int + get() = bottomNavigationView.resources.getDimensionPixelSize(R.dimen.m3_bottom_nav_item_active_indicator_width) + + private val defaultIndicatorHeightPx: Int + get() = bottomNavigationView.resources.getDimensionPixelSize(R.dimen.m3_bottom_nav_item_active_indicator_height) + + // Auto-scale preserves the themed default padding around the icon: default indicator minus default icon size. + private val autoIndicatorHorizontalPaddingDp: Float + get() = bottomNavigationView.pxToDp(defaultIndicatorWidthPx.toFloat()) - defaultIconSizeDp + + private val autoIndicatorVerticalPaddingDp: Float + get() = bottomNavigationView.pxToDp(defaultIndicatorHeightPx.toFloat()) - defaultIconSizeDp + + private fun applyActiveIndicatorSize( + widthDp: Float?, + heightDp: Float?, + iconBoxDp: Float, + ) { + val autoScale = iconBoxDp > defaultIconSizeDp + bottomNavigationView.itemActiveIndicatorWidth = + resolveIndicatorDimensionPx(widthDp, iconBoxDp + autoIndicatorHorizontalPaddingDp, defaultIndicatorWidthPx, autoScale) + bottomNavigationView.itemActiveIndicatorHeight = + resolveIndicatorDimensionPx(heightDp, iconBoxDp + autoIndicatorVerticalPaddingDp, defaultIndicatorHeightPx, autoScale) + } + + // Explicit dp wins; else auto-scale to the enlarged icon box; else themed Material default. + private fun resolveIndicatorDimensionPx( + explicitDp: Float?, + autoScaledDp: Float, + defaultPx: Int, + autoScale: Boolean, + ): Int = + when { + explicitDp != null && explicitDp > 0f -> bottomNavigationView.dpToPx(explicitDp).toInt() + autoScale -> bottomNavigationView.dpToPx(autoScaledDp).toInt() + else -> defaultPx + } + + // Inset the icon so it renders at effectiveDp, centered within iconBoxDp. + // Intrinsic-relative on purpose: Material FIT_CENTER-scales the drawable to the icon box, + // so the intrinsic factor cancels and the glyph lands at effectiveDp/iconBoxDp of the box. + private fun sizeIcon( + icon: Drawable?, + effectiveDp: Float, + iconBoxDp: Float, + ): Drawable? { + if (icon == null || effectiveDp >= iconBoxDp) return icon + val larger = maxOf(icon.intrinsicWidth, icon.intrinsicHeight) + if (larger <= 0) return icon + val insetPx = (larger * (iconBoxDp - effectiveDp) / (2f * effectiveDp)).toInt() + return if (insetPx > 0) InsetDrawable(icon, insetPx) else icon + } + private val states = arrayOf( intArrayOf(-android.R.attr.state_enabled), // disabled @@ -34,6 +107,7 @@ internal class TabsAppearanceApplicator( context: Context, tabBarAppearance: TabsAppearance?, isTabBarHidden: Boolean, + iconBoxDp: Float, ) { bottomNavigationView.isVisible = !isTabBarHidden bottomNavigationView.setBackgroundColor( @@ -109,6 +183,12 @@ internal class TabsAppearanceApplicator( bottomNavigationView.isItemActiveIndicatorEnabled = tabBarAppearance?.tabBarItemActiveIndicatorEnabled ?: true bottomNavigationView.itemActiveIndicatorColor = ColorStateList.valueOf(activeIndicatorColor) + + applyActiveIndicatorSize( + tabBarAppearance?.tabBarItemActiveIndicatorWidth, + tabBarAppearance?.tabBarItemActiveIndicatorHeight, + iconBoxDp, + ) } fun updateFontStyles( @@ -174,24 +254,26 @@ internal class TabsAppearanceApplicator( fun updateMenuItemAppearance( menuItem: MenuItem, tabsScreen: TabsScreen, + iconBoxDp: Float, ) { if (menuItem.title != tabsScreen.tabTitle) { menuItem.title = tabsScreen.tabTitle } - val targetIcon = - if (tabsScreen.selectedIcon != null && tabsScreen.icon != null) { + // Sized per slot: a StateListDrawable's intrinsic size follows its current state, so a + // single inset computed from one child would mis-size the other when their resolutions differ. + val effectiveDp = effectiveIconSizeDp(tabsScreen) + val iconDrawable = sizeIcon(tabsScreen.icon.drawable, effectiveDp, iconBoxDp) + val selectedIconDrawable = sizeIcon(tabsScreen.selectedIcon.drawable, effectiveDp, iconBoxDp) + menuItem.icon = + if (selectedIconDrawable != null && iconDrawable != null) { StateListDrawable().apply { - addState(intArrayOf(android.R.attr.state_checked), tabsScreen.selectedIcon?.mutate()) - addState(intArrayOf(), tabsScreen.icon?.mutate()) + addState(intArrayOf(android.R.attr.state_checked), selectedIconDrawable.mutate()) + addState(intArrayOf(), iconDrawable.mutate()) } } else { - tabsScreen.icon + iconDrawable } - - if (menuItem.icon != targetIcon) { - menuItem.icon = targetIcon - } } internal fun updateBadgeAppearance( diff --git a/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceCoordinator.kt b/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceCoordinator.kt index 9b46e00169..dbf39b851e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceCoordinator.kt +++ b/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceCoordinator.kt @@ -14,19 +14,27 @@ internal class TabsAppearanceCoordinator( ) { private val appearanceApplicator = TabsAppearanceApplicator(bottomNavigationView) + // Icon box is bar-wide: the largest effective size across tabs. + private fun resolveIconBoxDp(): Float = + tabsScreenFragments.maxOfOrNull { appearanceApplicator.effectiveIconSizeDp(it.tabsScreen) } + ?: appearanceApplicator.defaultIconSizeDp + fun updateTabAppearance( context: Context, tabsContainer: TabsContainer, ) { val selectedTabAppearance = tabsContainer.selectedTab.tabsScreen.appearance - appearanceApplicator.updateSharedAppearance(context, selectedTabAppearance, tabsContainer.tabBarHidden) - updateMenuItems(context, selectedTabAppearance) + val iconBoxDp = resolveIconBoxDp() + appearanceApplicator.applyIconBox(iconBoxDp) + appearanceApplicator.updateSharedAppearance(context, selectedTabAppearance, tabsContainer.tabBarHidden, iconBoxDp) + updateMenuItems(context, selectedTabAppearance, iconBoxDp) appearanceApplicator.updateFontStyles(context, selectedTabAppearance) // It needs to be updated after updateMenuItems } private fun updateMenuItems( context: Context, tabsAppearance: TabsAppearance?, + iconBoxDp: Float, ) { tabsScreenFragments.forEachIndexed { index, fragment -> val menuItemId = menuItemIdForFragmentAtIndex(index) @@ -35,7 +43,7 @@ internal class TabsAppearanceCoordinator( "[RNScreens] Missing MenuItem for id: $menuItemId" } check(menuItem.itemId == menuItemId) { "[RNScreens] Illegal state: menu items are shuffled" } - updateMenuItemAppearance(context, menuItem, fragment.tabsScreen, tabsAppearance) + updateMenuItemAppearance(context, menuItem, fragment.tabsScreen, tabsAppearance, iconBoxDp) } } @@ -44,8 +52,9 @@ internal class TabsAppearanceCoordinator( menuItem: MenuItem, tabsScreen: TabsScreen, appearance: TabsAppearance?, + iconBoxDp: Float = resolveIconBoxDp(), ) { - appearanceApplicator.updateMenuItemAppearance(menuItem, tabsScreen) + appearanceApplicator.updateMenuItemAppearance(menuItem, tabsScreen, iconBoxDp) appearanceApplicator.updateBadgeAppearance(context, menuItem, tabsScreen, appearance) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceModel.kt b/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceModel.kt index 7fd509f60b..a87f61f5fd 100644 --- a/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceModel.kt +++ b/android/src/main/java/com/swmansion/rnscreens/tabs/appearance/TabsAppearanceModel.kt @@ -10,6 +10,8 @@ internal data class TabsAppearance( val disabled: ItemStateAppearance? = null, val tabBarItemActiveIndicatorColor: Int? = null, val tabBarItemActiveIndicatorEnabled: Boolean? = null, + val tabBarItemActiveIndicatorWidth: Float? = null, + val tabBarItemActiveIndicatorHeight: Float? = null, val tabBarItemTitleFontFamily: String? = null, val tabBarItemTitleSmallLabelFontSize: Float? = null, val tabBarItemTitleLargeLabelFontSize: Float? = null, diff --git a/android/src/main/java/com/swmansion/rnscreens/tabs/container/TabsContainer.kt b/android/src/main/java/com/swmansion/rnscreens/tabs/container/TabsContainer.kt index a425e63c71..9fa4ec5308 100644 --- a/android/src/main/java/com/swmansion/rnscreens/tabs/container/TabsContainer.kt +++ b/android/src/main/java/com/swmansion/rnscreens/tabs/container/TabsContainer.kt @@ -420,6 +420,17 @@ class TabsContainer internal constructor( } } + override fun onIconSizeChange(tabsScreen: TabsScreen) { + // Icon box is bar-wide, so this must run even when the changed tab is not selected. + // Skip when detached; reattachment reapplies the full appearance anyway. + if (isAttachedToWindow) { + invalidationFlags.isNavigationMenuAppearanceInvalidated = true + post { + this.flushPendingUpdates() + } + } + } + override fun getFragmentForTabsScreen(tabsScreen: TabsScreen): TabsScreenFragment? = tabsModel.find { it.tabsScreen === diff --git a/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreen.kt b/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreen.kt index b8bec2bfc2..49f7386b0b 100644 --- a/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreen.kt @@ -1,7 +1,6 @@ package com.swmansion.rnscreens.tabs.screen import android.content.res.Configuration -import android.graphics.drawable.Drawable import android.view.ViewGroup import androidx.fragment.app.Fragment import com.facebook.react.uimanager.ThemedReactContext @@ -9,7 +8,6 @@ import com.swmansion.rnscreens.common.FragmentProviding import com.swmansion.rnscreens.common.container.Container import com.swmansion.rnscreens.common.container.ContainerItem import com.swmansion.rnscreens.common.container.ContainerItemSupport -import com.swmansion.rnscreens.helpers.getSystemDrawableResource import com.swmansion.rnscreens.scrollviewmarker.ScrollViewMarker import com.swmansion.rnscreens.scrollviewmarker.ScrollViewSeeking import com.swmansion.rnscreens.tabs.appearance.TabsAppearance @@ -78,24 +76,21 @@ class TabsScreen( // endregion // region Icon - var drawableIconResourceName: String? by Delegates.observable(null) { _, oldValue, newValue -> - if (newValue != oldValue) { - icon = getSystemDrawableResource(reactContext, newValue) - } - } - var selectedDrawableIconResourceName: String? by Delegates.observable(null) { _, oldValue, newValue -> - if (newValue != oldValue) { - selectedIcon = getSystemDrawableResource(reactContext, newValue) - } - } + internal val icon = TabsScreenIcon(reactContext, ::onMenuItemAttributesChange) + internal val selectedIcon = TabsScreenIcon(reactContext, ::onMenuItemAttributesChange) - var icon: Drawable? by Delegates.observable(null) { _, oldValue, newValue -> - updateMenuItemAttributesIfNeeded(oldValue, newValue) + internal fun resolveIconsIfNeeded() { + icon.resolveIfNeeded() + selectedIcon.resolveIfNeeded() } - var selectedIcon: Drawable? by Delegates.observable(null) { _, oldValue, newValue -> - updateMenuItemAttributesIfNeeded(oldValue, newValue) + // Per-tab icon size in dp; 0 means the system default. + // The icon box is bar-wide, so a change here invalidates the whole bar, not just this item. + var drawableIconSize: Float by Delegates.observable(0f) { _, oldValue, newValue -> + if (newValue != oldValue) { + tabsScreenDelegate.get()?.onIconSizeChange(this) + } } // endregion diff --git a/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenDelegate.kt b/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenDelegate.kt index efc776c63a..567f65ef94 100644 --- a/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenDelegate.kt +++ b/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenDelegate.kt @@ -8,6 +8,12 @@ internal interface TabsScreenDelegate { fun onMenuItemAttributesChange(tabsScreen: TabsScreen) + /** + * The icon box is bar-wide, so an icon size change requires a whole-bar appearance + * update regardless of tab selection. + */ + fun onIconSizeChange(tabsScreen: TabsScreen) + /** * **If a fragment is associated with the tab screen**, notify the delegate that the fragment * got configuration update. diff --git a/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenIcon.kt b/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenIcon.kt new file mode 100644 index 0000000000..66e22467b6 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenIcon.kt @@ -0,0 +1,69 @@ +package com.swmansion.rnscreens.tabs.screen + +import android.content.Context +import android.graphics.drawable.Drawable +import com.swmansion.rnscreens.helpers.IconResolution +import com.swmansion.rnscreens.helpers.NoTintDrawable +import com.swmansion.rnscreens.helpers.PropIconResolver +import kotlin.properties.Delegates + +/** + * Single icon slot (normal or selected) of a tab bar item. Props stage here and may arrive + * in any order within a single update batch; resolution runs once per transaction via + * [resolveIfNeeded]. [onChanged] fires only when the resolved [drawable] actually changes. + */ +internal class TabsScreenIcon( + private val context: Context, + private val onChanged: () -> Unit, +) { + var drawableResourceName: String? by Delegates.observable(null) { _, oldValue, newValue -> + if (newValue != oldValue) isInvalidated = true + } + + var tinted: Boolean by Delegates.observable(true) { _, oldValue, newValue -> + if (newValue != oldValue) isInvalidated = true + } + + var imageUri: String? by Delegates.observable(null) { _, oldValue, newValue -> + if (newValue != oldValue) isInvalidated = true + } + + // Resolved output; read by the appearance applicator. + var drawable: Drawable? = null + private set + + private var isInvalidated = false + + // Handles source precedence (drawable name wins over uri), dedup, and stale async image drops. + private val resolver = PropIconResolver() + + // Kept unwrapped so a tint-only change re-wraps without reloading. + private var rawDrawable: Drawable? = null + private var appliedTinted = true + + fun resolveIfNeeded() { + if (!isInvalidated) { + return + } + isInvalidated = false + resolver.resolve(context, drawableResourceName, imageUri) { result -> + when (result) { + IconResolution.Unchanged -> if (tinted != appliedTinted) emit() + is IconResolution.Resolved -> { + rawDrawable = result.drawable + emit() + } + } + } + } + + // Tint is read at emit time, so a toggle during an in-flight image load still applies. + private fun emit() { + appliedTinted = tinted + val next = rawDrawable?.let { if (tinted) it else NoTintDrawable(it) } + if (next !== drawable) { + drawable = next + onChanged() + } + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenViewManager.kt index 41b023feb0..40bfcd7d83 100644 --- a/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/tabs/screen/TabsScreenViewManager.kt @@ -8,7 +8,6 @@ import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNSTabsScreenAndroidManagerDelegate import com.facebook.react.viewmanagers.RNSTabsScreenAndroidManagerInterface -import com.swmansion.rnscreens.helpers.loadImage import com.swmansion.rnscreens.helpers.makeEventRegistrationInfo import com.swmansion.rnscreens.helpers.readOptionalBoolean import com.swmansion.rnscreens.helpers.readOptionalColor @@ -55,6 +54,11 @@ class TabsScreenViewManager : view.onViewManagerAddEventEmitters() } + override fun onAfterUpdateTransaction(view: TabsScreen) { + super.onAfterUpdateTransaction(view) + view.resolveIconsIfNeeded() + } + override fun setScreenKey( view: TabsScreen, value: String?, @@ -125,42 +129,49 @@ class TabsScreenViewManager : view: TabsScreen, value: String?, ) { - view.drawableIconResourceName = value + view.icon.drawableResourceName = value } override fun setSelectedDrawableIconResourceName( view: TabsScreen, value: String?, ) { - view.selectedDrawableIconResourceName = value + view.selectedIcon.drawableResourceName = value + } + + override fun setDrawableIconTinted( + view: TabsScreen, + value: Boolean, + ) { + view.icon.tinted = value + } + + override fun setSelectedDrawableIconTinted( + view: TabsScreen, + value: Boolean, + ) { + view.selectedIcon.tinted = value + } + + override fun setDrawableIconSize( + view: TabsScreen, + value: Float, + ) { + view.drawableIconSize = value } override fun setImageIconResource( view: TabsScreen, value: ReadableMap?, ) { - val uri = value?.getString("uri") - if (uri != null) { - loadImage(view.context, uri) { drawable -> - if (drawable != null) { - view.icon = drawable - } - } - } + view.icon.imageUri = value?.getString("uri") } override fun setSelectedImageIconResource( view: TabsScreen, value: ReadableMap?, ) { - val uri = value?.getString("uri") - if (uri != null) { - loadImage(view.context, uri) { drawable -> - if (drawable != null) { - view.selectedIcon = drawable - } - } - } + view.selectedIcon.imageUri = value?.getString("uri") } override fun setStandardAppearance( @@ -189,6 +200,8 @@ class TabsScreenViewManager : disabled = if (appearance.hasKey("disabled")) parseItemStateAppearance(context, appearance.getMap("disabled")) else null, tabBarItemActiveIndicatorColor = appearance.readOptionalColor(context, "tabBarItemActiveIndicatorColor"), tabBarItemActiveIndicatorEnabled = appearance.readOptionalBoolean("tabBarItemActiveIndicatorEnabled"), + tabBarItemActiveIndicatorWidth = appearance.readOptionalFloat("tabBarItemActiveIndicatorWidth"), + tabBarItemActiveIndicatorHeight = appearance.readOptionalFloat("tabBarItemActiveIndicatorHeight"), tabBarItemTitleFontFamily = appearance.readOptionalString("tabBarItemTitleFontFamily"), tabBarItemTitleSmallLabelFontSize = appearance.readOptionalFloat("tabBarItemTitleSmallLabelFontSize"), tabBarItemTitleLargeLabelFontSize = appearance.readOptionalFloat("tabBarItemTitleLargeLabelFontSize"), diff --git a/apps/Example.tsx b/apps/Example.tsx index d4f2dfe8a3..e535c2c21a 100644 --- a/apps/Example.tsx +++ b/apps/Example.tsx @@ -27,6 +27,7 @@ import SearchBar from './src/screens/SearchBar'; import Events from './src/screens/Events'; import Gestures from './src/screens/Gestures'; import BarButtonItems from './src/screens/BarButtonItems'; +import CustomNativeTabsIcons from './src/screens/CustomNativeTabsIcons'; import { GestureDetectorProvider } from 'react-native-screens/gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -68,6 +69,11 @@ const SCREENS: Record< component: SwipeBackAnimation, type: 'example', }, + CustomNativeTabsIcons: { + title: 'Custom Native Tabs Icons', + component: CustomNativeTabsIcons, + type: 'example', + }, StackPresentation: { title: 'Stack Presentation', component: StackPresentation, diff --git a/apps/src/screens/CustomNativeTabsIcons.android.tsx b/apps/src/screens/CustomNativeTabsIcons.android.tsx new file mode 100644 index 0000000000..a40412a80e --- /dev/null +++ b/apps/src/screens/CustomNativeTabsIcons.android.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { + TabsContainer, + type TabRouteConfig, + DEFAULT_TAB_ROUTE_OPTIONS, +} from '@apps/shared/containers/tabs'; +import { Colors } from '@apps/shared/styling'; + +function TabScreen() { + return ( + + Custom drawable tab icons + + OG SWM: size unaltered showcases the visual shrink due to its aspect + ratio. + + + Sized SWM: a wide logo sized to 44dp via `drawableIconSize`. + + + Multicolor Tint: a VectorDrawable that keeps its own colors when focused + (`tinted: false`) and is template(system)-tinted otherwise. + + + Sys (unaltered): a built-in star. Size unaltered defaults to 24dp. + + + Image Tint: an `imageSource` icon, tinted by default and keeping its own + colors when selected (`tinted: false`). + + + The active indicator is bar-wide via `tabBarItemActiveIndicatorWidth` / + `Height`. And shared throughout all icons + + + ); +} + +const INDICATOR = { + tabBarItemActiveIndicatorWidth: 80, + tabBarItemActiveIndicatorHeight: 40, +}; + +const ROUTES: TabRouteConfig[] = [ + { + name: 'OG_SWM', + Component: TabScreen, + options: { + ...DEFAULT_TAB_ROUTE_OPTIONS, + title: 'OG SWM', + android: { + icon: { type: 'drawableResource', name: 'swm_logo' }, + standardAppearance: INDICATOR, + }, + }, + }, + { + name: 'SIZED_SWM', + Component: TabScreen, + options: { + ...DEFAULT_TAB_ROUTE_OPTIONS, + title: 'Sized SWM', + android: { + drawableIconSize: 44, + icon: { type: 'drawableResource', name: 'swm_logo' }, + standardAppearance: INDICATOR, + }, + }, + }, + { + name: 'Multicolor', + Component: TabScreen, + options: { + ...DEFAULT_TAB_ROUTE_OPTIONS, + title: 'Multicolor Tint', + android: { + drawableIconSize: 30, + icon: { + type: 'drawableResource', + name: 'person_walking', + tinted: true, + }, + selectedIcon: { + type: 'drawableResource', + name: 'person_walking', + tinted: false, + }, + standardAppearance: INDICATOR, + }, + }, + }, + { + name: 'System', + Component: TabScreen, + options: { + ...DEFAULT_TAB_ROUTE_OPTIONS, + title: 'Sys (unaltered)', + android: { + icon: { type: 'drawableResource', name: 'star_big_off' }, + selectedIcon: { type: 'drawableResource', name: 'star_big_on' }, + standardAppearance: INDICATOR, + }, + }, + }, + { + name: 'IMAGE_TINT', + Component: TabScreen, + options: { + ...DEFAULT_TAB_ROUTE_OPTIONS, + title: 'Image Tint', + android: { + icon: { + type: 'imageSource', + imageSource: require('@assets/variableIcons/icon.png'), + }, + selectedIcon: { + type: 'imageSource', + imageSource: require('@assets/variableIcons/icon.png'), + tinted: false, + }, + standardAppearance: INDICATOR, + }, + }, + }, +]; + +export default function CustomNativeTabsIcons() { + return ; +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + gap: 12, + }, + label: { + fontSize: 17, + fontWeight: '600', + textAlign: 'center', + }, + hint: { + fontSize: 13, + color: Colors.LightOffNavy, + textAlign: 'center', + lineHeight: 20, + }, +}); diff --git a/apps/src/screens/CustomNativeTabsIcons.ios.tsx b/apps/src/screens/CustomNativeTabsIcons.ios.tsx new file mode 100644 index 0000000000..8f934c833a --- /dev/null +++ b/apps/src/screens/CustomNativeTabsIcons.ios.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { + TabsContainer, + type TabRouteConfig, + DEFAULT_TAB_ROUTE_OPTIONS, +} from '@apps/shared/containers/tabs'; +import { Colors } from '@apps/shared/styling'; + +function TabScreen() { + return ( + + Custom asset-catalog tab icons + + Symbol: a custom symbol (`nano.swm` symbolset). It is not a built-in SF + Symbol, so it resolves via the custom-symbol fallback. Being a template, + it follows the system/host tint. + + + Tinted: the same custom symbol, tinted RED when selected via + `standardAppearance`. + + + Walker: a multicolor imageset (`nanomc.walker`) rendered in its own + colors — it ignores tinting. + + + System: a built-in SF Symbol star with a filled selected variant. + + + Image: an `imageSource` icon rendered as a template (`tinted: true`), so + it follows the item icon color. + + + ); +} + +const ROUTES: TabRouteConfig[] = [ + { + name: 'SWM_SYMBOL', + Component: TabScreen, + options: { + ...DEFAULT_TAB_ROUTE_OPTIONS, + title: 'Symbol', + ios: { + icon: { type: 'sfSymbol', name: 'nano.swm' }, + }, + }, + }, + { + name: 'SWM_TINTED', + Component: TabScreen, + options: { + ...DEFAULT_TAB_ROUTE_OPTIONS, + title: 'Tinted', + ios: { + icon: { type: 'sfSymbol', name: 'nano.swm' }, + standardAppearance: { + stacked: { + selected: { + tabBarItemIconColor: Colors.RedLight100, + }, + }, + }, + }, + }, + }, + { + name: 'WALKER', + Component: TabScreen, + options: { + ...DEFAULT_TAB_ROUTE_OPTIONS, + title: 'Walker', + ios: { + icon: { type: 'xcasset', name: 'nanomc.walker' }, + }, + }, + }, + { + name: 'System', + Component: TabScreen, + options: { + ...DEFAULT_TAB_ROUTE_OPTIONS, + title: 'System', + ios: { + icon: { type: 'sfSymbol', name: 'star' }, + selectedIcon: { type: 'sfSymbol', name: 'star.fill' }, + }, + }, + }, + { + name: 'IMAGE_TINT', + Component: TabScreen, + options: { + ...DEFAULT_TAB_ROUTE_OPTIONS, + title: 'Image', + ios: { + icon: { + type: 'imageSource', + imageSource: require('@assets/variableIcons/icon.png'), + tinted: true, + }, + }, + }, + }, +]; + +export default function CustomNativeTabsIcons() { + return ; +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + gap: 12, + }, + label: { + fontSize: 17, + fontWeight: '600', + textAlign: 'center', + }, + hint: { + fontSize: 13, + color: Colors.LightOffNavy, + textAlign: 'center', + lineHeight: 20, + }, +}); diff --git a/ios/helpers/image/RNSImageLoadingHelper.h b/ios/helpers/image/RNSImageLoadingHelper.h index 40dab5fb81..62caa51bd6 100644 --- a/ios/helpers/image/RNSImageLoadingHelper.h +++ b/ios/helpers/image/RNSImageLoadingHelper.h @@ -27,6 +27,13 @@ asTemplate:(BOOL)isTemplate completionBlock:(void (^_Nonnull)(UIImage *_Nullable image))imageLoadingCompletionBlock; +/** + * Loads a symbol image by name: a built-in SF Symbol if one matches, otherwise a custom + * symbol from the app's asset catalog (custom symbols are semantically SF Symbols, but + * UIKit only resolves them via `imageNamed:`). + */ ++ (nullable UIImage *)symbolImageNamed:(nonnull NSString *)name; + @end #endif // defined(__cplusplus) diff --git a/ios/helpers/image/RNSImageLoadingHelper.mm b/ios/helpers/image/RNSImageLoadingHelper.mm index 64beb499a6..c979ad3bc8 100644 --- a/ios/helpers/image/RNSImageLoadingHelper.mm +++ b/ios/helpers/image/RNSImageLoadingHelper.mm @@ -59,6 +59,15 @@ + (void)loadImageFromSource:(nonnull RCTImageSource *)imageSource }]; } ++ (nullable UIImage *)symbolImageNamed:(nonnull NSString *)name +{ + UIImage *image = [UIImage systemImageNamed:name]; + if (image == nil) { + image = [UIImage imageNamed:name]; + } + return image; +} + + (nullable UIImage *)handleRenderingModeForImage:(nullable UIImage *)image isTemplate:(BOOL)isTemplate { if (isTemplate) { diff --git a/ios/tabs/RNSTabBarAppearanceCoordinator.mm b/ios/tabs/RNSTabBarAppearanceCoordinator.mm index fee81a8fa6..0e99f7dc25 100644 --- a/ios/tabs/RNSTabBarAppearanceCoordinator.mm +++ b/ios/tabs/RNSTabBarAppearanceCoordinator.mm @@ -62,7 +62,7 @@ - (void)setIconsForTabBarItem:(UITabBarItem *)tabBarItem if (screenView.iconType == RNSTabsIconTypeSfSymbol || screenView.iconType == RNSTabsIconTypeXcasset) { if (screenView.iconResourceName != nil) { if (screenView.iconType == RNSTabsIconTypeSfSymbol) { - tabBarItem.image = [UIImage systemImageNamed:screenView.iconResourceName]; + tabBarItem.image = [RNSImageLoadingHelper symbolImageNamed:screenView.iconResourceName]; } else { tabBarItem.image = [UIImage imageNamed:screenView.iconResourceName]; } @@ -77,7 +77,7 @@ - (void)setIconsForTabBarItem:(UITabBarItem *)tabBarItem if (screenView.selectedIconResourceName != nil) { if (screenView.iconType == RNSTabsIconTypeSfSymbol) { - tabBarItem.selectedImage = [UIImage systemImageNamed:screenView.selectedIconResourceName]; + tabBarItem.selectedImage = [RNSImageLoadingHelper symbolImageNamed:screenView.selectedIconResourceName]; } else { tabBarItem.selectedImage = [UIImage imageNamed:screenView.selectedIconResourceName]; } diff --git a/react-navigation b/react-navigation index 449fee23ff..40b7e211a7 160000 --- a/react-navigation +++ b/react-navigation @@ -1 +1 @@ -Subproject commit 449fee23ff8ebf226dad3328d711d1b95734d23b +Subproject commit 40b7e211a7737574f5d01aa8820bf7e8a4f95499 diff --git a/src/components/shared/types.ts b/src/components/shared/types.ts index a49f1df765..344ee446be 100644 --- a/src/components/shared/types.ts +++ b/src/components/shared/types.ts @@ -44,13 +44,27 @@ export type UserInterfaceStyle = 'unspecified' | 'light' | 'dark'; export type PlatformIconShared = { type: 'imageSource'; imageSource: ImageSourcePropType; + /** + * Whether the host component tints the icon with its state-dependent icon color. + * The default is platform-specific: Android tints image icons, while iOS + * renders them in their original colors unless `tinted` is `true`. + */ + tinted?: boolean; }; export type PlatformIconAndroidDrawableResource = { type: 'drawableResource'; name: string; + /** + * Whether the host component tints the icon with its state-dependent icon color. + * Defaults to `true`; `false` keeps the drawable's own colors. + */ + tinted?: boolean; }; +/** + * @deprecated Use `{ type: 'imageSource', imageSource, tinted: true }` instead. + */ export type PlatformIconIOSTemplate = { type: 'templateSource'; templateSource: ImageSourcePropType; diff --git a/src/components/tabs/screen/TabsScreen.android.tsx b/src/components/tabs/screen/TabsScreen.android.tsx index 0aab02fdb1..c48c4d4b8a 100644 --- a/src/components/tabs/screen/TabsScreen.android.tsx +++ b/src/components/tabs/screen/TabsScreen.android.tsx @@ -63,6 +63,7 @@ function TabsScreen(props: TabsScreenProps) { {...iconProps} {...filteredBaseProps} // Android-specific + drawableIconSize={android?.drawableIconSize} standardAppearance={mapAppearanceToNativeProps( android?.standardAppearance, )}> @@ -127,14 +128,22 @@ function mapItemStateAppearanceToNativeProp( }; } +function iconTintedOf( + icon: PlatformIconAndroid | undefined, +): boolean | undefined { + return icon?.tinted; +} + function parseIconsToNativeProps( icon: PlatformIconAndroid | undefined, selectedIcon: PlatformIconAndroid | undefined, ): { imageIconResource?: ImageResolvedAssetSource | undefined; drawableIconResourceName?: string | undefined; + drawableIconTinted?: boolean | undefined; selectedImageIconResource?: ImageResolvedAssetSource | undefined; selectedDrawableIconResourceName?: string | undefined; + selectedDrawableIconTinted?: boolean | undefined; } { const parsedIcon = parseAndroidIconToNativeProps(icon); const parsedSelectedIcon = parseAndroidIconToNativeProps(selectedIcon); @@ -142,9 +151,11 @@ function parseIconsToNativeProps( return { imageIconResource: parsedIcon.imageIconResource, drawableIconResourceName: parsedIcon.drawableIconResourceName, + drawableIconTinted: iconTintedOf(icon), selectedImageIconResource: parsedSelectedIcon.imageIconResource, selectedDrawableIconResourceName: parsedSelectedIcon.drawableIconResourceName, + selectedDrawableIconTinted: iconTintedOf(selectedIcon), }; } diff --git a/src/components/tabs/screen/TabsScreen.android.types.ts b/src/components/tabs/screen/TabsScreen.android.types.ts index 876cbde186..653ad3049a 100644 --- a/src/components/tabs/screen/TabsScreen.android.types.ts +++ b/src/components/tabs/screen/TabsScreen.android.types.ts @@ -99,6 +99,20 @@ export interface TabsScreenAppearanceAndroid { * @platform android */ tabBarItemActiveIndicatorEnabled?: boolean | undefined; + /** + * @summary Active-indicator pill width in dp. If unset, it auto-scales to wrap + * the icon box when icons are enlarged via `drawableIconSize`. + * + * @platform android + */ + tabBarItemActiveIndicatorWidth?: number | undefined; + /** + * @summary Active-indicator pill height in dp. If unset, it auto-scales to wrap + * the icon box when icons are enlarged via `drawableIconSize`. + * + * @platform android + */ + tabBarItemActiveIndicatorHeight?: number | undefined; /** * @summary Specifies the font family used for the title of each tab bar item. * @@ -162,8 +176,10 @@ export interface TabsScreenPropsAndroid { * @summary Specifies the icon for the tab bar item. * * Supported values: - * - `{ type: 'imageSource', imageSource }` - * Uses an image from the provided resource. + * - `{ type: 'imageSource', imageSource, tinted? }` + * Uses an image from the provided resource. The tab bar tints it with the + * item icon color unless `tinted` is `false`, which keeps the image's own + * colors. * * Remarks: `imageSource` type doesn't support SVGs on Android. * For loading SVGs use `drawableResource` type. @@ -186,4 +202,14 @@ export interface TabsScreenPropsAndroid { * @platform android */ selectedIcon?: PlatformIconAndroid | undefined; + /** + * @summary Per-tab icon size in dp. + * + * The bottom bar's icon box is the largest `drawableIconSize` across all tabs; + * each tab's icon is inset to its own size within that box. Tabs without a value + * use the system default. + * + * @platform android + */ + drawableIconSize?: number | undefined; } diff --git a/src/components/tabs/screen/TabsScreen.ios.tsx b/src/components/tabs/screen/TabsScreen.ios.tsx index 209fdcd838..30b2027be6 100644 --- a/src/components/tabs/screen/TabsScreen.ios.tsx +++ b/src/components/tabs/screen/TabsScreen.ios.tsx @@ -165,7 +165,9 @@ function parseIconsToNativeProps( parsedSelectedIcon.iconType !== undefined && parsedIcon.iconType !== parsedSelectedIcon.iconType ) { - throw new Error('[RNScreens] icon and selectedIcon must be same type.'); + throw new Error( + '[RNScreens] icon and selectedIcon must be same type. Note that `imageSource` icons with different `tinted` values resolve to different types.', + ); } else if ( parsedIcon.iconType === undefined && parsedSelectedIcon.iconType !== undefined @@ -200,7 +202,7 @@ function parseIconToNativeProps(icon: PlatformIconIOS | undefined): { }; } else if (icon.type === 'imageSource') { return { - iconType: 'image', + iconType: icon.tinted ? 'template' : 'image', iconImageSource: icon.imageSource, }; } else if (icon.type === 'templateSource') { diff --git a/src/components/tabs/screen/TabsScreen.ios.types.ts b/src/components/tabs/screen/TabsScreen.ios.types.ts index d2153ebf1f..040bbccb38 100644 --- a/src/components/tabs/screen/TabsScreen.ios.types.ts +++ b/src/components/tabs/screen/TabsScreen.ios.types.ts @@ -233,14 +233,19 @@ export interface TabsScreenPropsIOS { * @summary Specifies the icon for the tab bar item. * * Supported values: - * - `{ type: 'imageSource', imageSource }` - * Uses an image from the provided resource. + * - `{ type: 'imageSource', imageSource, tinted? }` + * Uses an image from the provided resource. By default the image renders + * in its original colors; with `tinted: true` it is used as a template + * image and follows the state-dependent icon color. * - `{ type: 'sfSymbol', name }` - * Uses an SF Symbol with the specified name. + * Uses an SF Symbol with the specified name. If no system symbol + * matches, falls back to an asset with that name (e.g. a custom + * SF Symbol from the app's asset catalog). * - `{ type: 'xcasset', name }` * Uses asset from Xcassets. * - `{ type: 'templateSource', templateSource }` - * Uses the provided image as a template image. + * Deprecated: use `{ type: 'imageSource', imageSource, tinted: true }` + * instead. Uses the provided image as a template image. * The icon color will depend on the current state * of the tab bar item and icon color-related props. * diff --git a/src/fabric/tabs/TabsScreenAndroidNativeComponent.ts b/src/fabric/tabs/TabsScreenAndroidNativeComponent.ts index 104117271d..462f5d799a 100644 --- a/src/fabric/tabs/TabsScreenAndroidNativeComponent.ts +++ b/src/fabric/tabs/TabsScreenAndroidNativeComponent.ts @@ -50,6 +50,9 @@ export type Appearance = { // TabBarItem - Active Indicator tabBarItemActiveIndicatorColor?: ProcessedColorValue | null | undefined; tabBarItemActiveIndicatorEnabled?: CT.WithDefault; + // Indicator size (dp); unset = auto-scale to the icon box. + tabBarItemActiveIndicatorWidth?: CT.Float | undefined; + tabBarItemActiveIndicatorHeight?: CT.Float | undefined; // TabBarItem - Label tabBarItemTitleFontFamily?: string | undefined; @@ -102,6 +105,10 @@ export interface NativeProps extends ViewProps { imageIconResource?: ImageSource | undefined; selectedDrawableIconResourceName?: string | undefined; selectedImageIconResource?: ImageSource | undefined; + drawableIconTinted?: CT.WithDefault; + selectedDrawableIconTinted?: CT.WithDefault; + // Per-tab icon size (dp); 0/unset = system default. + drawableIconSize?: CT.Float | undefined; // Appearance standardAppearance?: Appearance | undefined;