From 5236b8b5dfbd46578fff6e6152b4554d8518991d Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 27 May 2026 12:12:03 +0200 Subject: [PATCH 01/18] add icon props on JS layer, extract common fuction with Tabs --- .../header/StackHeaderConfig.android.tsx | 39 +++++++++++--- .../header/StackHeaderConfig.android.types.ts | 51 +++++++++++++++++++ src/components/shared/index.ts | 35 +++++++++++++ .../tabs/screen/TabsScreen.android.tsx | 37 +------------- ...StackHeaderConfigAndroidNativeComponent.ts | 6 +++ 5 files changed, 126 insertions(+), 42 deletions(-) create mode 100644 src/components/shared/index.ts diff --git a/src/components/gamma/stack/header/StackHeaderConfig.android.tsx b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx index 827655ee92..f9278ee868 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.android.tsx +++ b/src/components/gamma/stack/header/StackHeaderConfig.android.tsx @@ -5,7 +5,7 @@ import React, { useImperativeHandle, useRef, } from 'react'; -import { Image, StyleSheet } from 'react-native'; +import { Image, processColor, StyleSheet } from 'react-native'; import type { StackHeaderConfigProps, StackHeaderConfigRef, @@ -23,6 +23,7 @@ import type { StackHeaderTypeAndroid, StackHeaderToolbarMenuItemOptionsAndroid, } from './StackHeaderConfig.android.types'; +import { parseIconToNativeProps } from 'react-native-screens/components/shared'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE @@ -215,7 +216,31 @@ function parseToolbarMenuItemOptionsToNativeProps( options: StackHeaderToolbarMenuItemOptionsAndroid, ): NativeToolbarMenuItemOptionsAndroid[] { const nativeOptions: NativeToolbarMenuItemOptionsAndroid = Object.fromEntries( - Object.entries(options).map(([key, value]) => { + Object.entries(options).flatMap(([key, value]): [string, unknown][] => { + const typedKey = key as keyof StackHeaderToolbarMenuItemOptionsAndroid; + + switch (typedKey) { + case 'iconTintColorNormal': + case 'iconTintColorPressed': + case 'iconTintColorFocused': + case 'iconTintColorDisabled': + return [ + [ + key, + processColor( + value as StackHeaderToolbarMenuItemOptionsAndroid[typeof typedKey], + ), + ], + ]; + + case 'icon': { + const iconValue = + value as StackHeaderToolbarMenuItemOptionsAndroid['icon']; + const parsedIcon = parseIconToNativeProps(iconValue); + return Object.entries(parsedIcon); + } + } + if ( typeof value === 'object' && value !== null && @@ -225,10 +250,12 @@ function parseToolbarMenuItemOptionsToNativeProps( } return [ - key, - // We need to replace explicit `undefined` with `null` - // so that we're able to read that information on the native side. - value === undefined ? null : value, + [ + key, + // We need to replace explicit `undefined` with `null` + // so that we're able to read that information on the native side. + value === undefined ? null : value, + ], ]; }), ); diff --git a/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts b/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts index 02e3c97cd2..6b921c35ed 100644 --- a/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts +++ b/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts @@ -114,6 +114,57 @@ export interface StackHeaderToolbarMenuItemAndroid { * @platform android */ showAsAction?: StackHeaderToolbarMenuItemShowAsActionAndroid | undefined; + /** + * @summary Specifies the icon for the menu item. + * + * Supported values: + * - `{ type: 'imageSource', imageSource }` + * Uses an image from the provided resource. + * + * Remarks: `imageSource` type doesn't support SVGs on Android. + * For loading SVGs use `drawableResource` type. + * + * - `{ type: 'drawableResource', name }` + * Uses a drawable resource with the given name. + * + * Remarks: Requires passing a drawable to resources via Android Studio. + * + * @remarks + * The icon will be visible only if the menu item is shown in the Toolbar. + * + * @platform android + */ + icon?: PlatformIconAndroid | undefined; + /** + * @summary Specifies the tint color to apply to the menu item icon. + * + * @platform android + */ + iconTintColorNormal?: ColorValue | undefined; + /** + * @summary Specifies the tint color to apply to the menu item icon when item + * is pressed. + * + * @platform android + */ + iconTintColorPressed?: ColorValue | undefined; + /** + * @summary Specifies the tint color to apply to the menu item icon when item + * is focused (e.g. by keyboard navigation). + * + * @platform android + */ + iconTintColorFocused?: ColorValue | undefined; + /** + * @summary Specifies the tint color to apply to the menu item icon when item + * is disabled. + * + * @remarks + * Disabling menu item isn't currently supported. + * + * @platform android + */ + iconTintColorDisabled?: ColorValue | undefined; } export type StackHeaderToolbarMenuItemClickedEvent = { diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts new file mode 100644 index 0000000000..051e77400b --- /dev/null +++ b/src/components/shared/index.ts @@ -0,0 +1,35 @@ +import { Image, ImageResolvedAssetSource } from 'react-native'; +import { PlatformIconAndroid } from 'react-native-screens'; + +export function parseIconToNativeProps(icon: PlatformIconAndroid | undefined): { + imageIconResource?: ImageResolvedAssetSource | undefined; + drawableIconResourceName?: string | undefined; +} { + if (!icon) { + return {}; + } + + let parsedIconResource; + if (icon.type === 'imageSource') { + parsedIconResource = Image.resolveAssetSource(icon.imageSource); + if (!parsedIconResource) { + console.error('[RNScreens] Failed to resolve an asset.'); + } + + return { + // I'm keeping undefined as a fallback if `Image.resolveAssetSource` has failed for some reason. + // It won't render any icon, but it will prevent from crashing on the native side which is expecting + // ReadableMap. Passing `iconResource` directly will result in crash, because `require` API is returning + // double as a value. + imageIconResource: parsedIconResource || undefined, + }; + } else if (icon.type === 'drawableResource') { + return { + drawableIconResourceName: icon.name, + }; + } else { + throw new Error( + '[RNScreens] Incorrect icon format for Android. You must provide `imageSource` or `drawableResource`.', + ); + } +} diff --git a/src/components/tabs/screen/TabsScreen.android.tsx b/src/components/tabs/screen/TabsScreen.android.tsx index df1f86aeed..452f5daec3 100644 --- a/src/components/tabs/screen/TabsScreen.android.tsx +++ b/src/components/tabs/screen/TabsScreen.android.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { - Image, ImageResolvedAssetSource, StyleSheet, processColor, @@ -19,6 +18,7 @@ import type { import type { TabsScreenProps } from '../screen/TabsScreen.types'; import type { PlatformIconAndroid } from '../../../types'; import { useTabsScreen } from './useTabsScreen'; +import { parseIconToNativeProps } from '../../shared'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE @@ -151,41 +151,6 @@ function parseIconsToNativeProps( }; } -function parseIconToNativeProps(icon: PlatformIconAndroid | undefined): { - imageIconResource?: ImageResolvedAssetSource | undefined; - drawableIconResourceName?: string | undefined; -} { - if (!icon) { - return {}; - } - - let parsedIconResource; - if (icon.type === 'imageSource') { - parsedIconResource = Image.resolveAssetSource(icon.imageSource); - if (!parsedIconResource) { - console.error( - '[RNScreens] failed to resolve an asset for bottom tab icon', - ); - } - - return { - // I'm keeping undefined as a fallback if `Image.resolveAssetSource` has failed for some reason. - // It won't render any icon, but it will prevent from crashing on the native side which is expecting - // ReadableMap. Passing `iconResource` directly will result in crash, because `require` API is returning - // double as a value. - imageIconResource: parsedIconResource || undefined, - }; - } else if (icon.type === 'drawableResource') { - return { - drawableIconResourceName: icon.name, - }; - } else { - throw new Error( - '[RNScreens] Incorrect icon format for Android. You must provide `imageSource` or `drawableResource`.', - ); - } -} - export default TabsScreen; const styles = StyleSheet.create({ diff --git a/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts index ac5ea46735..00879b914d 100644 --- a/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts +++ b/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts @@ -30,6 +30,12 @@ export interface StackHeaderToolbarMenuItemAndroid { StackHeaderToolbarMenuItemShowAsActionAndroid, 'never' >; + drawableIconResourceName?: string | undefined; + imageIconResource?: ImageSource | undefined; + iconTintColorNormal?: ColorValue | undefined; + iconTintColorPressed?: ColorValue | undefined; + iconTintColorFocused?: ColorValue | undefined; + iconTintColorDisabled?: ColorValue | undefined; } export interface NativeProps extends ViewProps { From 8d51156b9e2b82dfbb470f12f4ad6ef7dfab697e Mon Sep 17 00:00:00 2001 From: Krzysztof Ligarski Date: Wed, 27 May 2026 12:21:51 +0200 Subject: [PATCH 02/18] add SFT --- .../single-feature-tests/stack-v5/index.ts | 2 + .../index.tsx | 375 ++++++++++++++++++ .../scenario-descriptions.ts | 11 + .../tabs/test-tabs-item-title-ios/index.tsx | 43 +- 4 files changed, 419 insertions(+), 12 deletions(-) create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/scenario-descriptions.ts diff --git a/apps/src/tests/single-feature-tests/stack-v5/index.ts b/apps/src/tests/single-feature-tests/stack-v5/index.ts index d10ba2b8e9..a5cfa317ab 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -7,6 +7,7 @@ import TestStackSubviews from './test-stack-subviews-android'; import TestStackBackButton from './test-stack-back-button-android'; import TestStackToolbarMenuCommands from './test-stack-toolbar-menu-commands-android'; import TestStackToolbarMenuShowAsAction from './test-stack-toolbar-menu-show-as-action-android'; +import TestStackToolbarMenuIcon from './test-stack-toolbar-menu-icon-android'; const scenarios = { PreventNativeDismissSingleStack, @@ -17,6 +18,7 @@ const scenarios = { TestStackBackButton, TestStackToolbarMenuCommands, TestStackToolbarMenuShowAsAction, + TestStackToolbarMenuIcon, }; const StackScenarioGroup: ScenarioGroup = { diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx new file mode 100644 index 0000000000..49d3ef0d6b --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-toolbar-menu-icon-android/index.tsx @@ -0,0 +1,375 @@ +import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; +import { Button, ScrollView, StyleSheet, Text } from 'react-native'; +import type { ColorValue } from 'react-native'; +import { createScenario } from '@apps/tests/shared/helpers'; +import { + StackContainer, + useStackNavigationContext, +} from '@apps/shared/gamma/containers/stack'; +import { SettingsPicker, SettingsSwitch } from '@apps/shared'; +import { Colors } from '@apps/shared/styling'; +import { + type StackHeaderConfigRef, + type StackHeaderToolbarMenuItemAndroid, + type StackHeaderToolbarMenuItemOptionsAndroid, +} from 'react-native-screens/experimental'; +import type { PlatformIconAndroid } from 'react-native-screens'; +import { scenarioDescription } from './scenario-descriptions'; + +type IdOption = 'item-1' | 'item-2' | 'item-3'; +type IconOption = 'none' | 'imageSource' | 'drawableResource'; +type TintColorOption = 'default' | 'purple' | 'red' | 'green'; +type ShowAsActionOption = 'always' | 'never' | 'ifRoom'; + +type CmdIconOption = 'no change' | IconOption; +type CmdTintColorOption = 'no change' | TintColorOption; + +const ID_OPTIONS: IdOption[] = ['item-1', 'item-2', 'item-3']; +const ICON_OPTIONS: IconOption[] = ['none', 'imageSource', 'drawableResource']; +const TINT_COLOR_OPTIONS: TintColorOption[] = [ + 'default', + 'purple', + 'red', + 'green', +]; +const SHOW_AS_ACTION_OPTIONS: ShowAsActionOption[] = [ + 'always', + 'never', + 'ifRoom', +]; + +const CMD_ICON_OPTIONS: CmdIconOption[] = ['no change', ...ICON_OPTIONS]; +const CMD_TINT_COLOR_OPTIONS: CmdTintColorOption[] = [ + 'no change', + ...TINT_COLOR_OPTIONS, +]; + +interface SlotConfig { + include: boolean; + id: IdOption; + icon: IconOption; + showAsAction: ShowAsActionOption; + tintColorNormal: TintColorOption; + tintColorPressed: TintColorOption; + tintColorFocused: TintColorOption; + tintColorDisabled: TintColorOption; +} + +type Slots = [SlotConfig, SlotConfig, SlotConfig]; + +const SLOT_DEFAULTS: Omit = { + include: true, + icon: 'imageSource', + showAsAction: 'always', + tintColorNormal: 'default', + tintColorPressed: 'default', + tintColorFocused: 'default', + tintColorDisabled: 'default', +}; + +const DEFAULT_SLOTS: Slots = [ + { ...SLOT_DEFAULTS, id: 'item-1' }, + { ...SLOT_DEFAULTS, id: 'item-2' }, + { ...SLOT_DEFAULTS, id: 'item-3' }, +]; + +const ITEM_TITLES: Record = { + 'item-1': 'Item 1', + 'item-2': 'Item 2', + 'item-3': 'Item 3', +}; + +function resolveIcon(option: IconOption): PlatformIconAndroid | undefined { + switch (option) { + case 'imageSource': + return { + type: 'imageSource', + imageSource: require('@assets/search_black.png'), + }; + case 'drawableResource': + return { + type: 'drawableResource', + name: 'sym_call_missed', + }; + default: + return undefined; + } +} + +function resolveTintColor(option: TintColorOption): ColorValue | undefined { + switch (option) { + case 'purple': + return Colors.PurpleLight100; + case 'red': + return Colors.RedLight100; + case 'green': + return Colors.GreenLight100; + default: + return undefined; + } +} + +function buildItems(slots: Slots): StackHeaderToolbarMenuItemAndroid[] { + return slots + .filter(s => s.include) + .map(s => ({ + id: s.id, + title: ITEM_TITLES[s.id], + showAsAction: s.showAsAction, + icon: resolveIcon(s.icon), + iconTintColorNormal: resolveTintColor(s.tintColorNormal), + iconTintColorPressed: resolveTintColor(s.tintColorPressed), + iconTintColorFocused: resolveTintColor(s.tintColorFocused), + iconTintColorDisabled: resolveTintColor(s.tintColorDisabled), + })); +} + +function updateSlotAt( + slots: Slots, + index: number, + patch: Partial, +): Slots { + return slots.map((s, i) => (i === index ? { ...s, ...patch } : s)) as Slots; +} + +const HEADER_TITLE = 'Toolbar Menu Icon Test'; + +export function App() { + return ( + + ); +} + +function MainScreen() { + const [slots, setSlots] = useState(DEFAULT_SLOTS); + const [lastClicked, setLastClicked] = useState(null); + + const [cmdTargetId, setCmdTargetId] = useState('item-1'); + const [cmdIcon, setCmdIcon] = useState('no change'); + const [cmdTintColorNormal, setCmdTintColorNormal] = + useState('no change'); + const [cmdTintColorPressed, setCmdTintColorPressed] = + useState('no change'); + const [cmdTintColorFocused, setCmdTintColorFocused] = + useState('no change'); + const [cmdTintColorDisabled, setCmdTintColorDisabled] = + useState('no change'); + + const headerConfigRef = useRef(null); + const { setRouteOptions, routeKey } = useStackNavigationContext(); + + useLayoutEffect(() => { + setRouteOptions(routeKey, { + headerConfig: { + title: HEADER_TITLE, + android: { + toolbarMenuItems: buildItems(DEFAULT_SLOTS), + onToolbarMenuItemClicked: event => + setLastClicked(event.nativeEvent.id), + }, + }, + headerConfigRef, + }); + }, [setRouteOptions, routeKey]); + + const applySlots = useCallback( + (next: Slots) => { + setSlots(next); + setRouteOptions(routeKey, { + headerConfig: { + title: HEADER_TITLE, + android: { + toolbarMenuItems: buildItems(next), + onToolbarMenuItemClicked: event => + setLastClicked(event.nativeEvent.id), + }, + }, + }); + }, + [setRouteOptions, routeKey], + ); + + const sendCommand = useCallback(() => { + const options: StackHeaderToolbarMenuItemOptionsAndroid = { + ...(cmdIcon !== 'no change' && { + icon: cmdIcon === 'none' ? undefined : resolveIcon(cmdIcon), + }), + ...(cmdTintColorNormal !== 'no change' && { + iconTintColorNormal: resolveTintColor(cmdTintColorNormal), + }), + ...(cmdTintColorPressed !== 'no change' && { + iconTintColorPressed: resolveTintColor(cmdTintColorPressed), + }), + ...(cmdTintColorFocused !== 'no change' && { + iconTintColorFocused: resolveTintColor(cmdTintColorFocused), + }), + ...(cmdTintColorDisabled !== 'no change' && { + iconTintColorDisabled: resolveTintColor(cmdTintColorDisabled), + }), + }; + headerConfigRef.current?.android?.setToolbarMenuItemOptions( + cmdTargetId, + options, + ); + }, [ + cmdTargetId, + cmdIcon, + cmdTintColorNormal, + cmdTintColorPressed, + cmdTintColorFocused, + cmdTintColorDisabled, + ]); + + return ( + + Send Command + + label="target id" + value={cmdTargetId} + items={ID_OPTIONS} + onValueChange={setCmdTargetId} + /> + + label="icon" + value={cmdIcon} + items={CMD_ICON_OPTIONS} + onValueChange={setCmdIcon} + /> + + label="tintColorNormal" + value={cmdTintColorNormal} + items={CMD_TINT_COLOR_OPTIONS} + onValueChange={setCmdTintColorNormal} + /> + + label="tintColorPressed" + value={cmdTintColorPressed} + items={CMD_TINT_COLOR_OPTIONS} + onValueChange={setCmdTintColorPressed} + /> + + label="tintColorFocused" + value={cmdTintColorFocused} + items={CMD_TINT_COLOR_OPTIONS} + onValueChange={setCmdTintColorFocused} + /> + + label="tintColorDisabled" + value={cmdTintColorDisabled} + items={CMD_TINT_COLOR_OPTIONS} + onValueChange={setCmdTintColorDisabled} + /> +