From afa146bdc7f042b7742938d6366d07334c72b4c1 Mon Sep 17 00:00:00 2001 From: Konrad Michalik Date: Thu, 2 Jul 2026 11:57:45 +0200 Subject: [PATCH 1/6] feat(iOS, Stack v5): Refactor Stack Header implementation and handle selective updates --- .../single-feature-tests/stack-v5/index.ts | 2 + .../test-stack-header-menu-ios/index.tsx | 1 + .../index.tsx | 255 +++++++++++++ .../scenario-description.ts | 11 + .../scenario.md | 43 +++ .../RNSStackHeaderConfigComponentView.h | 16 +- .../RNSStackHeaderConfigComponentView.mm | 148 ++------ .../RNSStackHeaderConfigDataProviding.h | 24 ++ .../header/RNSStackHeaderContentFactory.h | 14 + .../header/RNSStackHeaderContentFactory.mm | 18 - ios/gamma/stack/header/RNSStackHeaderData.h | 49 --- ios/gamma/stack/header/RNSStackHeaderData.mm | 51 --- .../header/RNSStackHeaderItemComponentView.h | 4 +- .../header/RNSStackHeaderItemComponentView.mm | 19 +- .../header/RNSStackHeaderItemDataProviding.h | 3 - .../RNSStackHeaderItemInvalidationDelegate.h | 6 +- .../RNSStackHeaderItemSpacerComponentView.mm | 2 +- .../header/RNSStackHeaderMenuCoordinator.h | 10 +- .../header/RNSStackHeaderMenuCoordinator.mm | 21 +- .../RNSStackHeaderMenuTrackerRegistry.h | 19 + .../RNSStackHeaderMenuTrackerRegistry.mm | 36 ++ .../host/RNSStackNavigationBarCoordinator.h | 7 +- .../host/RNSStackNavigationBarCoordinator.mm | 15 +- .../RNSStackNavigationItemCoordinator.h | 17 - .../RNSStackNavigationItemCoordinator.mm | 60 --- .../screen/RNSStackScreenComponentView.h | 1 - .../screen/RNSStackScreenHeaderCoordinator.h | 18 +- .../screen/RNSStackScreenHeaderCoordinator.mm | 354 +++++++++++++++++- 28 files changed, 863 insertions(+), 361 deletions(-) create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/index.tsx create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/scenario-description.ts create mode 100644 apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/scenario.md create mode 100644 ios/gamma/stack/header/RNSStackHeaderConfigDataProviding.h delete mode 100644 ios/gamma/stack/header/RNSStackHeaderData.h delete mode 100644 ios/gamma/stack/header/RNSStackHeaderData.mm create mode 100644 ios/gamma/stack/header/RNSStackHeaderMenuTrackerRegistry.h create mode 100644 ios/gamma/stack/header/RNSStackHeaderMenuTrackerRegistry.mm delete mode 100644 ios/gamma/stack/screen/RNSStackNavigationItemCoordinator.h delete mode 100644 ios/gamma/stack/screen/RNSStackNavigationItemCoordinator.mm 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 40a2cec582..f8dd9a5e45 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/index.ts +++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts @@ -18,6 +18,7 @@ import TestStackToolbarMenuIcon from './test-stack-toolbar-menu-icon-android'; import TestStackToolbarMenuGroups from './test-stack-toolbar-menu-groups-android'; import TestStackToolbarNestedMenu from './test-stack-toolbar-nested-menu-android'; import TestStackHeaderSubviewOnPress from './test-stack-header-subview-onpress-ios'; +import TestStackHeaderSelectiveUpdates from './test-stack-header-selective-updates-ios'; // Scenario entry-point components — each scenario's default export re-exported // under a name for direct rendering (e.g. from App.tsx or e2e harnesses). @@ -46,6 +47,7 @@ const scenarios = { TestStackSubviewsIOS, TestStackHeaderMenuIOS, TestStackHeaderSubviewOnPress, + TestStackHeaderSelectiveUpdates, TestStackBackButton, TestStackToolbarMenuCommands, TestStackToolbarMenuDisabled, diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/index.tsx index 3ecc4dc07b..3cb3045d1c 100644 --- a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/index.tsx +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-menu-ios/index.tsx @@ -64,6 +64,7 @@ function buildHeaderConfig( id: `toggle-${i}-1`, type: 'menuItem', itemType: 'toggle', + initialToggleState: true, title: `Toggle ${i}-1`, }, { diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/index.tsx new file mode 100644 index 0000000000..3b4bc2ee35 --- /dev/null +++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/index.tsx @@ -0,0 +1,255 @@ +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 type { + StackHeaderConfigProps, + StackHeaderMenuIOS, + StackHeaderMenuElementIOS, +} from 'react-native-screens/components/gamma/stack/header'; +import { Button, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { scenarioDescription } from './scenario-description'; +import PressableWithFeedback from '@apps/shared/PressableWithFeedback'; +import { SettingsSwitch } from '@apps/shared/SettingsSwitch'; +import { SettingsPicker } from '@apps/shared/SettingsPicker'; +import { ToastProvider, useToast } from '@apps/shared'; +import { Colors } from '@apps/shared/styling'; + +const MENU_MODES = ['none', 'single', 'multi'] as const; +type MenuMode = (typeof MENU_MODES)[number]; + +interface ItemConfig { + titleVariant: 'foo' | 'bar'; + customView: boolean; + menuMode: MenuMode; +} + +const DEFAULT_ITEMS: ItemConfig[] = [ + { titleVariant: 'foo', customView: false, menuMode: 'none' }, + { titleVariant: 'foo', customView: false, menuMode: 'none' }, +]; + +const THIRD_ITEM_DEFAULT: ItemConfig = { + titleVariant: 'foo', + customView: false, + menuMode: 'none', +}; + +function itemTitle(index: number, variant: 'foo' | 'bar'): string { + return variant === 'foo' ? `Foo ${index + 1}` : `Bar ${index + 1}`; +} + +function buildMenu( + itemIndex: number, + menuMode: 'single' | 'multi', + showToast: (text: string) => void, +): StackHeaderMenuIOS { + const singleSelection = menuMode === 'single'; + + const children: StackHeaderMenuElementIOS[] = [ + { + id: `Option-${itemIndex}-A`, + type: 'menuItem', + itemType: 'toggle', + initialToggleState: true, + title: `Option-${itemIndex}-A`, + }, + { + id: `Option-${itemIndex}-B`, + type: 'menuItem', + itemType: 'toggle', + title: `Option-${itemIndex}-B`, + }, + { + id: `Option-${itemIndex}-C`, + type: 'menuItem', + itemType: 'toggle', + title: `Option-${itemIndex}-C`, + }, + ]; + + return { + type: 'menu', + id: `menu-${itemIndex}`, + singleSelection, + onSelectionChange: selection => + showToast( + `Item ${itemIndex + 1} [${menuMode}]: "${selection.join('", "')}"`, + ), + children, + }; +} + +type StackHeaderItems = NonNullable< + StackHeaderConfigProps['ios'] +>['trailingItems']; + +function buildHeaderConfig( + items: ItemConfig[], + showToast: (text: string) => void, +): StackHeaderConfigProps { + const trailingItems: StackHeaderItems = items.flatMap((item, i) => { + const menu = + item.menuMode !== 'none' + ? buildMenu(i, item.menuMode, showToast) + : undefined; + + let outItems: StackHeaderItems = []; + + if (item.customView) { + outItems.push({ + type: 'item', + id: `trailing-${i}`, + render: () => ( + + ), + menu, + }); + } else { + outItems.push({ + type: 'item', + id: `trailing-${i}`, + title: itemTitle(i, item.titleVariant), + onPress: () => showToast(`Pressed Item ${i + 1}`), + menu, + }); + } + + outItems.push({ type: 'spacer', id: `spacer-${i}`, sizing: 'flexible' }); + + return outItems; + }); + + return { + title: 'Selective Updates', + ios: { + trailingItems, + }, + }; +} + +export function App() { + return ( + + + + ); +} + +function ConfigScreen() { + const navigation = useStackNavigationContext(); + const toast = useToast(); + const [items, setItems] = useState(DEFAULT_ITEMS); + const [showThirdItem, setShowThirdItem] = useState(false); + const [thirdItemConfig, setThirdItemConfig] = + useState(THIRD_ITEM_DEFAULT); + + const allItems = useMemo( + () => (showThirdItem ? [...items, thirdItemConfig] : items), + [items, showThirdItem, thirdItemConfig], + ); + + const showToast = useCallback( + (text: string) => { + toast.push({ backgroundColor: Colors.GreenDark120, message: text }); + }, + [toast], + ); + + const updateItem = useCallback( + (index: number, update: Partial) => { + if (index === 2) { + setThirdItemConfig(prev => ({ ...prev, ...update })); + } else { + setItems(prev => + prev.map((item, i) => (i === index ? { ...item, ...update } : item)), + ); + } + }, + [], + ); + + const { setRouteOptions, routeKey } = navigation; + const headerConfig = useMemo( + () => buildHeaderConfig(allItems, showToast), + [allItems, showToast], + ); + + useLayoutEffect(() => { + setRouteOptions(routeKey, { + headerConfig, + }); + }, [headerConfig, setRouteOptions, routeKey]); + + return ( + + + On iOS 26 and above, item update is visible as visual flash / blur. + Updating single item should NOT make other items flash. Updating the + menu should NOT make any item flash. Updating the title when custom item + is set should NOT make it flash. + + {allItems.map((item, i) => ( + + Item {i + 1} + + label="Title" + value={item.titleVariant} + onValueChange={v => updateItem(i, { titleVariant: v })} + items={['foo', 'bar']} + /> + updateItem(i, { customView: v })} + /> + + label="Menu" + value={item.menuMode} + onValueChange={v => updateItem(i, { menuMode: v })} + items={[...MENU_MODES]} + /> + + ))} +