From 65835976f57ee743e4c6fc90cd06424831684a8f Mon Sep 17 00:00:00 2001 From: Konrad Michalik Date: Fri, 3 Jul 2026 13:42:13 +0200 Subject: [PATCH 1/6] feat(Stack v5, iOS): Support icon loading in header items and menu --- .../single-feature-tests/stack-v5/index.ts | 3 + .../test-stack-header-icon-ios/index.tsx | 248 ++++++++++++++++++ .../scenario-description.ts | 11 + .../RNSStackHeaderConfigComponentDescriptor.h | 7 + .../RNSStackHeaderConfigShadowNode.cpp | 12 + .../RNSStackHeaderConfigShadowNode.h | 3 + .../rnscreens/RNSStackHeaderConfigState.cpp | 9 + .../rnscreens/RNSStackHeaderConfigState.h | 6 + .../RNSStackHeaderConfigComponentView.h | 4 +- .../RNSStackHeaderConfigComponentView.mm | 26 ++ .../header/RNSStackHeaderContentFactory.h | 4 +- .../header/RNSStackHeaderContentFactory.mm | 36 ++- .../stack/header/RNSStackHeaderIconData.h | 28 ++ .../stack/header/RNSStackHeaderIconData.mm | 17 ++ .../stack/header/RNSStackHeaderIconMapper.h | 15 ++ .../stack/header/RNSStackHeaderIconMapper.mm | 52 ++++ .../stack/header/RNSStackHeaderIconResolver.h | 25 ++ .../header/RNSStackHeaderIconResolver.mm | 50 ++++ .../header/RNSStackHeaderItemComponentView.h | 1 + .../header/RNSStackHeaderItemComponentView.mm | 8 + .../header/RNSStackHeaderItemDataProviding.h | 2 + .../header/RNSStackHeaderMenuCoordinator.h | 11 +- .../header/RNSStackHeaderMenuCoordinator.mm | 75 +++++- .../stack/header/RNSStackHeaderMenuData.h | 10 +- .../stack/header/RNSStackHeaderMenuData.mm | 4 + .../stack/header/RNSStackHeaderMenuMapper.mm | 17 +- .../screen/RNSStackScreenHeaderCoordinator.h | 2 + .../screen/RNSStackScreenHeaderCoordinator.mm | 17 +- ios/helpers/image/RNSImageLoading.h | 15 ++ .../header/StackHeaderConfig.ios.types.ts | 12 + src/components/gamma/stack/header/index.ts | 2 + .../stack/header/ios/StackHeaderItem.ios.tsx | 61 ++++- .../header/ios/StackHeaderItem.ios.types.ts | 2 + .../header/ios/StackHeaderMenu.ios.types.ts | 22 ++ .../StackHeaderItemIOSNativeComponent.ts | 5 + 35 files changed, 783 insertions(+), 39 deletions(-) create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-header-icon-ios/index.tsx create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-header-icon-ios/scenario-description.ts create mode 100644 ios/gamma/stack/header/RNSStackHeaderIconData.h create mode 100644 ios/gamma/stack/header/RNSStackHeaderIconData.mm create mode 100644 ios/gamma/stack/header/RNSStackHeaderIconMapper.h create mode 100644 ios/gamma/stack/header/RNSStackHeaderIconMapper.mm create mode 100644 ios/gamma/stack/header/RNSStackHeaderIconResolver.h create mode 100644 ios/gamma/stack/header/RNSStackHeaderIconResolver.mm create mode 100644 ios/helpers/image/RNSImageLoading.h 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 f8dd9a5e45..426bd4e3fe 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -9,6 +9,7 @@ import TestStackSimpleNav from './test-stack-simple-nav'; import TestStackSubviewsAndroid from './test-stack-subviews-android'; import TestStackSubviewsIOS from './test-stack-subviews-ios'; import TestStackHeaderMenuIOS from './test-stack-header-menu-ios'; +import TestStackHeaderIconIOS from './test-stack-header-icon-ios'; import TestStackBackButton from './test-stack-back-button-android'; import TestStackToolbarMenuCommands from './test-stack-toolbar-menu-commands-android'; import TestStackToolbarMenuDisabled from './test-stack-toolbar-menu-disabled-android'; @@ -29,6 +30,7 @@ export { default as TestStackSimpleNav } from './test-stack-simple-nav'; export { default as TestStackSubviewsAndroid } from './test-stack-subviews-android'; export { default as TestStackSubviewsIOS } from './test-stack-subviews-ios'; export { default as TestStackHeaderMenuIOS } from './test-stack-header-menu-ios'; +export { default as TestStackHeaderIconIOS } from './test-stack-header-icon-ios'; export { default as TestStackBackButton } from './test-stack-back-button-android'; export { default as TestStackToolbarMenuCommands } from './test-stack-toolbar-menu-commands-android'; export { default as TestStackToolbarMenuDisabled } from './test-stack-toolbar-menu-disabled-android'; @@ -46,6 +48,7 @@ const scenarios = { TestStackSubviewsAndroid, TestStackSubviewsIOS, TestStackHeaderMenuIOS, + TestStackHeaderIconIOS, TestStackHeaderSubviewOnPress, TestStackHeaderSelectiveUpdates, TestStackBackButton, diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-icon-ios/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-icon-ios/index.tsx new file mode 100644 index 0000000000..cc513f90fa --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-icon-ios/index.tsx @@ -0,0 +1,248 @@ +import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { createScenario } from '@apps/tests/shared/helpers'; +import { + StackContainer, + useStackNavigationContext, +} from '@apps/shared/gamma/containers/stack'; +import { + StackHeaderConfigProps, + PlatformIconIOS, +} from 'react-native-screens/components/gamma/stack/header'; +import { Button, ScrollView, Text, View, StyleSheet } from 'react-native'; +import { scenarioDescription } from './scenario-description'; +import { ToastProvider, useToast } from '@apps/shared'; +import { Colors } from '@apps/shared/styling'; + +type IconVariant = 'sfSymbol' | 'xcasset' | 'imageSource' | 'templateSource'; + +const ICON_VARIANTS: IconVariant[] = [ + 'sfSymbol', + 'xcasset', + 'imageSource', + 'templateSource', +]; + +function iconForVariant(variant: IconVariant): PlatformIconIOS { + switch (variant) { + case 'sfSymbol': + return { type: 'sfSymbol', name: 'star.fill' }; + case 'xcasset': + return { type: 'xcasset', name: 'custom-icon-fill' }; + case 'imageSource': + return { + type: 'imageSource', + imageSource: require('@assets/search_black.png'), + }; + case 'templateSource': + return { + type: 'templateSource', + templateSource: require('@assets/variableIcons/icon.png'), + }; + } +} + +function nextVariant(current: IconVariant): IconVariant { + const idx = ICON_VARIANTS.indexOf(current); + return ICON_VARIANTS[(idx + 1) % ICON_VARIANTS.length]!; +} + +function buildHeaderConfig( + itemIconVariant: IconVariant, + menuIconVariant: IconVariant, + cycleMenuIcons: () => void, + showToast: (text: string) => void, +): StackHeaderConfigProps { + const itemIcon = iconForVariant(itemIconVariant); + const menuIcon = iconForVariant(menuIconVariant); + + return { + title: 'Header Icons', + ios: { + trailingItems: [ + { + type: 'item', + id: 'icon-item', + title: 'Actions', + icon: itemIcon, + onPress: () => showToast('Item pressed'), + menu: { + type: 'menu', + id: 'main-menu', + onSelectionChange: selection => + showToast('Selected: ' + selection.join(', ')), + children: [ + { + id: 'cycle-action', + type: 'menuItem', + itemType: 'action', + title: `Cycle icons (${menuIconVariant})`, + keepsMenuPresented: true, + onPress: cycleMenuIcons, + }, + { + id: 'toggle-1', + type: 'menuItem', + itemType: 'toggle', + title: 'Toggle 1', + icon: menuIcon, + keepsMenuPresented: true, + }, + { + id: 'toggle-2', + type: 'menuItem', + itemType: 'toggle', + title: 'Toggle 2', + icon: menuIcon, + keepsMenuPresented: true, + }, + { + id: 'toggle-3', + type: 'menuItem', + itemType: 'toggle', + title: 'Toggle 3', + icon: menuIcon, + keepsMenuPresented: true, + }, + { + id: 'submenu', + type: 'menu', + title: 'Submenu', + icon: menuIcon, + children: [ + { + id: 'sub-toggle-1', + type: 'menuItem', + itemType: 'toggle', + title: 'Sub Toggle 1', + icon: menuIcon, + keepsMenuPresented: true, + }, + { + id: 'sub-toggle-2', + type: 'menuItem', + itemType: 'toggle', + title: 'Sub Toggle 2', + icon: menuIcon, + keepsMenuPresented: true, + }, + { + id: 'sub-toggle-3', + type: 'menuItem', + itemType: 'toggle', + title: 'Sub Toggle 3', + icon: menuIcon, + keepsMenuPresented: true, + }, + ], + }, + ], + }, + }, + ], + }, + }; +} + +function ConfigScreen() { + const navigation = useStackNavigationContext(); + const toast = useToast(); + + const [itemIconVariant, setItemIconVariant] = + useState('sfSymbol'); + const [menuIconVariant, setMenuIconVariant] = + useState('sfSymbol'); + + const showToast = useCallback( + (text: string) => { + toast.push({ backgroundColor: Colors.GreenDark120, message: text }); + }, + [toast], + ); + + const cycleMenuIcons = useCallback(() => { + setMenuIconVariant(v => nextVariant(v)); + }, []); + + const { setRouteOptions, routeKey } = navigation; + const headerConfig = useMemo( + () => + buildHeaderConfig( + itemIconVariant, + menuIconVariant, + cycleMenuIcons, + showToast, + ), + [itemIconVariant, menuIconVariant, cycleMenuIcons, showToast], + ); + + useLayoutEffect(() => { + setRouteOptions(routeKey, { + headerConfig, + }); + }, [headerConfig, setRouteOptions, routeKey]); + + return ( + + + Bar Button Item Icon + {itemIconVariant} +