Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.swmansion.rnscreens.gamma.tabs.host

import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.graphics.drawable.StateListDrawable
import android.util.TypedValue
import android.view.MenuItem
import android.view.ViewGroup
Expand Down Expand Up @@ -160,8 +161,18 @@ class TabsHostAppearanceApplicator(
menuItem.title = tabsScreen.tabTitle
}

if (menuItem.icon != tabsScreen.icon) {
menuItem.icon = tabsScreen.icon
val targetIcon =
if (tabsScreen.selectedIcon != null && tabsScreen.icon != null) {
StateListDrawable().apply {
addState(intArrayOf(android.R.attr.state_checked), tabsScreen.selectedIcon?.mutate())
addState(intArrayOf(), tabsScreen.icon?.mutate())
}
} else {
tabsScreen.icon
}

if (menuItem.icon != targetIcon) {
menuItem.icon = targetIcon
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ internal fun loadTabImage(
context: Context,
uri: String,
view: TabsScreen,
isSelected: Boolean,
) {
// Since image loading might happen on a background thread
// ref. https://frescolib.org/docs/intro-image-pipeline.html
// We should schedule rendering the result on the UI thread
val resolvedUri = ImageSource(context, uri).getUri(context) ?: return
loadTabImageInternal(context, resolvedUri) { drawable ->
Handler(Looper.getMainLooper()).post {
view.icon = drawable
if (isSelected) {
view.selectedIcon = drawable
} else {
view.icon = drawable
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,20 @@ class TabsScreen(
}
}

var selectedDrawableIconResourceName: String? by Delegates.observable(null) { _, oldValue, newValue ->
if (newValue != oldValue) {
selectedIcon = getSystemDrawableResource(reactContext, newValue)
}
}

var icon: Drawable? by Delegates.observable(null) { _, oldValue, newValue ->
updateMenuItemAttributesIfNeeded(oldValue, newValue)
}

var selectedIcon: Drawable? by Delegates.observable(null) { _, oldValue, newValue ->
updateMenuItemAttributesIfNeeded(oldValue, newValue)
}

var shouldUseRepeatedTabSelectionScrollToTopSpecialEffect: Boolean = true
var shouldUseRepeatedTabSelectionPopToRootSpecialEffect: Boolean = true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,14 @@ class TabsScreenViewManager :
view.drawableIconResourceName = value
}

@ReactProp(name = "selectedDrawableIconResourceName")
override fun setSelectedDrawableIconResourceName(
view: TabsScreen,
value: String?,
) {
view.selectedDrawableIconResourceName = value
}

override fun setOrientation(
view: TabsScreen,
value: String?,
Expand All @@ -236,7 +244,18 @@ class TabsScreenViewManager :
) {
val uri = value?.getString("uri")
if (uri != null) {
loadTabImage(view.context, uri, view)
loadTabImage(view.context, uri, view, false)
}
}

@ReactProp(name = "selectedImageIconResource")
override fun setSelectedImageIconResource(
view: TabsScreen,
value: ReadableMap?,
) {
val uri = value?.getString("uri")
if (uri != null) {
loadTabImage(view.context, uri, view, true)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ public void setProperty(T view, String propName, @Nullable Object value) {
case "imageIconResource":
mViewManager.setImageIconResource(view, (ReadableMap) value);
break;
case "selectedDrawableIconResourceName":
mViewManager.setSelectedDrawableIconResourceName(view, value == null ? null : (String) value);
break;
case "selectedImageIconResource":
mViewManager.setSelectedImageIconResource(view, (ReadableMap) value);
break;
case "tabBarItemBadgeTextColor":
mViewManager.setTabBarItemBadgeTextColor(view, ColorPropConverter.getColor(value, view.getContext()));
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public interface RNSTabsScreenManagerInterface<T extends View> extends ViewManag
void setOrientation(T view, @Nullable String value);
void setDrawableIconResourceName(T view, @Nullable String value);
void setImageIconResource(T view, @Nullable ReadableMap value);
void setSelectedDrawableIconResourceName(T view, @Nullable String value);
void setSelectedImageIconResource(T view, @Nullable ReadableMap value);
void setTabBarItemBadgeTextColor(T view, @Nullable Integer value);
void setTabBarItemBadgeBackgroundColor(T view, @Nullable Integer value);
void setStandardAppearance(T view, Dynamic value);
Expand Down
6 changes: 4 additions & 2 deletions apps/src/tests/issue-tests/Test3443.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,10 @@ const TAB_CONFIGS: TabConfiguration[] = [
},
},
selectedIcon: {
type: 'xcasset',
name: 'custom-icon-fill',
ios: {
type: 'xcasset',
name: 'custom-icon-fill',
},
},
},
component: makeTab(
Expand Down
38 changes: 29 additions & 9 deletions apps/src/tests/issue-tests/TestBottomTabs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,18 @@ const TAB_CONFIGS: TabConfiguration[] = [
},
android: {
type: 'imageSource',
imageSource: require('../../../../assets/variableIcons/icon_fill.png'),
imageSource: require('../../../../assets/variableIcons/icon.png'),
},
},
selectedIcon: {
type: 'sfSymbol',
name: 'house.fill',
ios: {
type: 'sfSymbol',
name: 'house.fill',
},
android: {
type: 'imageSource',
imageSource: require('../../../../assets/variableIcons/icon_fill.png'),
}
},
},
component: Tab1,
Expand Down Expand Up @@ -120,8 +126,14 @@ const TAB_CONFIGS: TabConfiguration[] = [
},
},
selectedIcon: {
type: 'templateSource',
templateSource: require('../../../../assets/variableIcons/icon_fill.png'),
ios: {
type: 'templateSource',
templateSource: require('../../../../assets/variableIcons/icon_fill.png'),
},
android: {
type: 'drawableResource',
name: 'sym_call_incoming',
}
},
title: 'Tab2',
},
Expand Down Expand Up @@ -163,8 +175,10 @@ const TAB_CONFIGS: TabConfiguration[] = [
},
},
selectedIcon: {
type: 'imageSource',
imageSource: require('../../../../assets/variableIcons/icon_fill.png'),
shared: {
type: 'imageSource',
imageSource: require('../../../../assets/variableIcons/icon_fill.png'),
}
},
title: 'Tab3',
// systemItem: 'search', // iOS specific
Expand All @@ -191,8 +205,14 @@ const TAB_CONFIGS: TabConfiguration[] = [
},
},
selectedIcon: {
type: 'sfSymbol',
name: 'rectangle.stack.fill',
ios: {
type: 'sfSymbol',
name: 'rectangle.stack.fill',
},
android: {
type: 'drawableResource',
name: 'custom_home_icon',
},
},
title: 'Tab4',
systemItem: 'search', // iOS specific
Expand Down
26 changes: 20 additions & 6 deletions apps/src/tests/issue-tests/TestBottomTabsOrientation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,14 @@ function makeTabConfigs(
},
},
selectedIcon: {
type: 'sfSymbol',
name: 'house.fill',
ios: {
type: 'sfSymbol',
name: 'house.fill',
},
android: {
type: 'imageSource',
imageSource: require('../../../assets/variableIcons/icon_fill.png'),
},
},
orientation: tabsOrientations.home.tabScreen,
},
Expand All @@ -117,8 +123,14 @@ function makeTabConfigs(
},
},
selectedIcon: {
type: 'templateSource',
templateSource: require('../../../assets/variableIcons/icon_fill.png'),
ios: {
type: 'templateSource',
templateSource: require('../../../assets/variableIcons/icon_fill.png'),
},
android: {
type: 'drawableResource',
name: 'sym_call_missed',
},
},
orientation: tabsOrientations.portrait.tabScreen,
},
Expand All @@ -137,8 +149,10 @@ function makeTabConfigs(
},
},
selectedIcon: {
type: 'imageSource',
imageSource: require('../../../assets/variableIcons/icon_fill.png'),
shared: {
type: 'imageSource',
imageSource: require('../../../assets/variableIcons/icon_fill.png'),
}
},
orientation: tabsOrientations.landscape.tabScreen,
},
Expand Down
31 changes: 25 additions & 6 deletions src/components/tabs/TabsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,34 +315,53 @@ function parseIOSIconToNativeProps(icon: PlatformIconIOS | undefined): {

function parseIconsToNativeProps(
icon: PlatformIcon | undefined,
selectedIcon: PlatformIconIOS | undefined,
selectedIcon: PlatformIcon | undefined,
): {
imageIconResource?: ImageResolvedAssetSource;
drawableIconResourceName?: string;
iconType?: IconType;
iconImageSource?: ImageSourcePropType;
iconResourceName?: string;
// android
selectedImageIconResource?: ImageSourcePropType;
selectedDrawableIconResourceName?: string;
// iOS
selectedIconImageSource?: ImageSourcePropType;
selectedIconResourceName?: string;
} {
if (Platform.OS === 'android') {
const androidNativeProps = parseAndroidIconToNativeProps(
icon?.android || icon?.shared,
);
const { imageIconResource, drawableIconResourceName } =
parseAndroidIconToNativeProps(icon?.android || icon?.shared);

const androidSelectedSource =
(selectedIcon as PlatformIcon)?.android ||
(selectedIcon as PlatformIcon)?.shared;
const selectedIconProps = androidSelectedSource
? parseAndroidIconToNativeProps(androidSelectedSource)
: {};

return {
...androidNativeProps,
imageIconResource,
drawableIconResourceName,
selectedImageIconResource: selectedIconProps.imageIconResource,
selectedDrawableIconResourceName:
selectedIconProps.drawableIconResourceName,
};
}

if (Platform.OS === 'ios') {
const { iconImageSource, iconResourceName, iconType } =
parseIOSIconToNativeProps(icon?.ios || icon?.shared);

const iosSelectedSource =
(selectedIcon as PlatformIcon)?.ios ||
(selectedIcon as PlatformIcon)?.shared;

const {
iconImageSource: selectedIconImageSource,
iconResourceName: selectedIconResourceName,
iconType: selectedIconType,
} = parseIOSIconToNativeProps(selectedIcon);
} = parseIOSIconToNativeProps(iosSelectedSource);

if (
iconType !== undefined &&
Expand Down
23 changes: 11 additions & 12 deletions src/components/tabs/TabsScreen.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import type {
} from 'react-native';
import type {
PlatformIcon,
PlatformIconIOS,
UserInterfaceStyle,
ScrollEdgeEffect,
} from '../../types';
Expand Down Expand Up @@ -343,12 +342,22 @@ export interface TabsScreenProps {
*
* Remarks: Requires passing a drawable to resources via Android Studio.
*
* On iOS, if no `selectedIcon` is provided, this icon will also
* If no `selectedIcon` is provided, this icon will also
* be used as the selected state icon.
*
* @platform android, ios
*/
icon?: PlatformIcon;
/**
* @summary Specifies the icon for tab bar item when it is selected.
*
* Supports the same values as `icon` property for given platform.
*
* To use `selectedIcon`, `icon` must also be provided.
*
* @platform android, ios
*/
selectedIcon?: PlatformIcon;
/**
* @summary Specifies which special effects (also known as microinteractions)
* are enabled for the tab screen.
Expand Down Expand Up @@ -502,16 +511,6 @@ export interface TabsScreenProps {
* @platform ios
*/
scrollEdgeAppearance?: TabsScreenAppearance;
/**
* @summary Specifies the icon for tab bar item when it is selected.
*
* Supports the same values as `icon` property for iOS.
*
* To use `selectedIcon`, `icon` must also be provided.
*
* @platform ios
*/
selectedIcon?: PlatformIconIOS;
/**
* @summary System-provided tab bar item with predefined icon and title
*
Expand Down
4 changes: 4 additions & 0 deletions src/fabric/tabs/TabsScreenNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ export interface NativeProps extends ViewProps {
// Android-specific image handling
drawableIconResourceName?: string;
imageIconResource?: ImageSource;

selectedDrawableIconResourceName?: string;
selectedImageIconResource?: ImageSource;

tabBarItemBadgeTextColor?: ColorValue;
tabBarItemBadgeBackgroundColor?: ColorValue;

Expand Down
7 changes: 7 additions & 0 deletions src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export const compatibilityFlags = {
* is in use or not.
*/
usesNewAndroidHeaderHeightImplementation: true,

/**
* In https://github.com/software-mansion/react-native-screens/pull/3633, we added a support
* for `selectedIcon` prop for BottomTabs on Android. To allow backward compatibility,
* we expose a way to check whether the new implementation is in use or not.
*/
isSelectedIconSupportedForAndroidBottomTabs: true,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: should be coordinated with #3672

} as const;

const _featureFlags = {
Expand Down
Loading