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-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]}
+ />
+
+ ))}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ scroll: {
+ backgroundColor: Colors.cardBackground,
+ },
+ content: {
+ padding: 16,
+ gap: 12,
+ },
+ itemSection: {
+ gap: 4,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.cardBorder,
+ paddingBottom: 12,
+ },
+ heading: {
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+});
+
+export default createScenario(App, scenarioDescription);
diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/scenario-description.ts b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/scenario-description.ts
new file mode 100644
index 0000000000..98d613d2b9
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/scenario-description.ts
@@ -0,0 +1,11 @@
+import type { ScenarioDescription } from '@apps/tests/shared/helpers';
+
+export const scenarioDescription: ScenarioDescription = {
+ name: 'Stack Header Selective Updates (iOS)',
+ key: 'test-stack-header-selective-updates-ios',
+ details:
+ 'Tests that header item updates are scoped — only the changed item rebuilds. On iOS 26+, item rebuild causes a visible flash/blur on the affected bar button.',
+ platforms: ['ios'],
+ e2eCoverage: 'tbd',
+ smokeTest: false,
+};
diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/scenario.md
new file mode 100644
index 0000000000..136e6653bc
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-header-selective-updates-ios/scenario.md
@@ -0,0 +1,43 @@
+# Test Scenario: Stack Header Selective Updates (iOS)
+
+## Details
+
+**Description:** This test verifies that updating a single header item does not cause other items to rebuild.
+
+**OS test creation version:** iOS 26.4
+
+## E2E test
+
+TBD
+
+## Prerequisites
+
+- iOS emulator running iOS 26
+
+## Steps on iPhone with iOS 26
+
+1. Observe two items in the trailing area: "Foo 1" and "Foo 2"
+2. Change Title picker for Item 1 from "foo" to "bar"
+ - [ ] Item 1 flashes and changes to "Bar 1"
+ - [ ] Item 2 does NOT flash
+3. Change Menu picker for Item 1 to "single"
+ - [ ] Neither items flash
+4. Open Item 1 menu by long press and select Option-0-B
+ - [ ] A toast `Item 1 [single]: "Option-0-B"` selected appears
+ - [ ] When opened again, only Option-0-B is checked
+5. Change Menu picker for Item 1 to "multi"
+ - [ ] Menu can be opened and the default first option is selected
+6. Select Option-0-B in Item 1 menu
+ - [ ] A toast `Item 1 \[multi]: "Option-0-A", "Option-0-B"` selected appears
+ - [ ] When opened again, both Option-0-A and Option-0-B are checked
+7. Enable "Custom view" for Item 1
+ - [ ] Item 1 flashes and shows a purple pressable square
+ - [ ] Item 2 aligns with new width and does not flash
+8. Change Title picker for Item 1 while custom view is enabled
+ - [ ] Neither items flash
+9. Press "Add Item 3"
+ - [ ] A third item appears
+ - [ ] Items 2 and 3 flash
+10. Press "Remove Item 3"
+ - [ ] Items 2 and 3 flash
+ - [ ] Item 3 disappears under Item 2
diff --git a/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.h b/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.h
index 1efe087961..999c4a8511 100644
--- a/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.h
+++ b/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.h
@@ -1,11 +1,25 @@
#pragma once
#import "RNSReactBaseView.h"
+#import "RNSStackHeaderConfigDataProviding.h"
+#import "RNSStackHeaderEventsDelegate.h"
+#import "RNSStackHeaderItemInvalidationDelegate.h"
#import "RNSViewFrameChangeDelegate.h"
NS_ASSUME_NONNULL_BEGIN
-@interface RNSStackHeaderConfigComponentView : RNSReactBaseView
+@interface RNSStackHeaderConfigComponentView : RNSReactBaseView
+
+@property (nonatomic, readonly, nullable) NSString *title;
+@property (nonatomic, readonly, nullable) NSString *subtitle;
+@property (nonatomic, readonly) BOOL hidden;
+@property (nonatomic, readonly, nullable) NSString *largeTitle;
+@property (nonatomic, readonly, nullable) NSString *largeSubtitle;
+@property (nonatomic, readonly) BOOL largeTitleEnabled;
+@property (nonatomic, readonly) NSArray *children;
- (void)resetProps;
diff --git a/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.mm b/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.mm
index e3939d0737..6f8ca4a5a0 100644
--- a/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.mm
+++ b/ios/gamma/stack/header/RNSStackHeaderConfigComponentView.mm
@@ -2,11 +2,7 @@
#import "RNSLog.h"
#import "RNSStackHeaderConfigEventEmitter.h"
#import "RNSStackHeaderConfigShadowStateProxy.h"
-#import "RNSStackHeaderContentFactory.h"
-#import "RNSStackHeaderData.h"
-#import "RNSStackHeaderEventsDelegate.h"
#import "RNSStackHeaderItemComponentView.h"
-#import "RNSStackHeaderItemInvalidationDelegate.h"
#import "RNSStackHeaderItemSpacerComponentView.h"
#import "RNSStackNavigationController.h"
#import "RNSStackScreenComponentView.h"
@@ -30,22 +26,11 @@ static void RNSAssertIsValidHeaderChild(UIView *child)
RNSStackHeaderItemSpacerComponentView.class);
}
-@interface RNSStackHeaderConfigComponentView ()
-@end
-
@implementation RNSStackHeaderConfigComponentView {
- NSString *_Nullable _title;
- NSString *_Nullable _subtitle;
- BOOL _hidden;
- NSString *_Nullable _largeTitle;
- NSString *_Nullable _largeSubtitle;
- BOOL _largeTitleEnabled;
-
- NSMutableArray *> *_Nonnull _children;
-
std::shared_ptr _state;
RNSStackHeaderConfigShadowStateProxy *_Nonnull _shadowStateProxy;
RNSStackHeaderConfigEventEmitter *_Nonnull _reactEventEmitter;
+ NSMutableArray *_children;
}
- (instancetype)initWithFrame:(CGRect)frame
@@ -71,13 +56,27 @@ - (void)resetProps
_largeTitleEnabled = NO;
}
+- (NSArray *)children
+{
+ return [_children copy];
+}
+
#pragma mark - UIView lifecycle
- (void)didMoveToWindow
{
if (self.window != nil) {
[[self requireNavigationController] setNavigationBarFrameChangeDelegate:self];
- [self submitCurrentData];
+ RNSStackScreenHeaderCoordinator *coordinator = [self headerCoordinator];
+ coordinator.configDataProvider = self;
+ coordinator.frameChangeDelegate = self;
+ coordinator.eventsDelegate = self;
+ [coordinator rebuild];
+ } else {
+ RNSStackScreenHeaderCoordinator *coordinator = [self headerCoordinator];
+ coordinator.configDataProvider = nil;
+ coordinator.frameChangeDelegate = nil;
+ coordinator.eventsDelegate = nil;
}
[super didMoveToWindow];
}
@@ -89,7 +88,7 @@ - (void)mountChildComponentView:(UIView *)childCompone
RNSAssertIsValidHeaderChild(childComponentView);
// Do NOT call super — children are not added to the view hierarchy.
- // They are tracked here and converted to UIBarButtonItems in submitCurrentData.
+ // They are tracked here and read by the coordinator during rebuild.
[_children insertObject:childComponentView atIndex:index];
if ([childComponentView isKindOfClass:RNSStackHeaderItemComponentView.class]) {
@@ -98,7 +97,7 @@ - (void)mountChildComponentView:(UIView *)childCompone
((RNSStackHeaderItemSpacerComponentView *)childComponentView).invalidationDelegate = self;
}
- [self submitCurrentDataIfMounted];
+ [[self headerCoordinator] rebuild];
}
- (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index
@@ -112,14 +111,36 @@ - (void)unmountChildComponentView:(UIView *)childCompo
}
[_children removeObjectAtIndex:index];
- [self submitCurrentDataIfMounted];
+ [[self headerCoordinator] rebuild];
}
#pragma mark - RNSStackHeaderItemInvalidationDelegate
-- (void)headerItemDidInvalidate
+- (void)headerItemDidInvalidateWithId:(NSString *)itemId
+{
+ if (itemId == nil) {
+ RNSLog(@"[RNScreens] headerItemDidInvalidateWithId called with nil id, will run full header rebuild");
+ [[self headerCoordinator] rebuild];
+ return;
+ }
+ [[self headerCoordinator] rebuildItemWithId:itemId];
+}
+
+- (void)headerItemMenuDidChangeWithId:(NSString *)itemId
+{
+ if (itemId == nil) {
+ RNSLog(@"[RNScreens] headerItemMenuDidChangeWithId called with nil id, will run full header rebuild");
+ [[self headerCoordinator] rebuild];
+ return;
+ }
+ RNSStackScreenHeaderCoordinator *coordinator = [self headerCoordinator];
+ [coordinator resetTrackerForItemWithId:itemId];
+ [coordinator reapplyMenuForItemWithId:itemId];
+}
+
+- (void)headerItemSpacerDidInvalidate
{
- [self submitCurrentDataIfMounted];
+ [[self headerCoordinator] rebuild];
}
#pragma mark - RNSStackHeaderEventsDelegate
@@ -132,8 +153,6 @@ - (void)didPressMenuItem:(NSString *)menuItemId
- (void)didChangeSelectionForMenu:(NSString *)menuId selectedMenuItemIds:(NSArray *)selectedIds
{
[_reactEventEmitter emitOnMenuSelectionChange:menuId selectedMenuItemIds:selectedIds];
- // UIKit doesn't update UIAction.state after tap — rebuild menu so tracker state is reflected
- [self submitCurrentDataIfMounted];
}
- (void)didPressHeaderItem:(NSString *)itemId
@@ -211,7 +230,7 @@ - (void)updateProps:(const react::Props::Shared &)props oldProps:(const react::P
[super updateProps:props oldProps:oldProps];
- [self submitCurrentDataIfMounted];
+ [[self headerCoordinator] applyConfigProperties];
}
+ (react::ComponentDescriptorProvider)componentDescriptorProvider
@@ -233,92 +252,15 @@ - (void)updateEventEmitter:(const facebook::react::EventEmitter::Shared &)eventE
#pragma mark - Private
-- (void)submitCurrentDataIfMounted
+- (nullable RNSStackScreenHeaderCoordinator *)headerCoordinator
{
- if (self.superview != nil) {
- [self submitCurrentData];
+ if (self.superview == nil) {
+ return nil;
}
-}
-
-- (void)submitCurrentData
-{
+ RCTAssert([self.superview isKindOfClass:RNSStackScreenComponentView.class],
+ @"[RNScreens] Header Config should be a direct child of RNSStackScreenComponentView");
RNSStackScreenComponentView *screen = (RNSStackScreenComponentView *)self.superview;
-
- NSMutableArray *leadingItems = [NSMutableArray new];
- NSMutableArray *trailingItems = [NSMutableArray new];
- UIView *titleView = nil;
- UIView *subtitleView = nil;
- UIView *largeSubtitleView = nil;
- [self buildBarButtonItemsWithLeadingItems:leadingItems
- trailingItems:trailingItems
- titleView:&titleView
- subtitleView:&subtitleView
- largeSubtitleView:&largeSubtitleView];
-
- RNSStackHeaderData *data = [[RNSStackHeaderData alloc] initWithTitle:_title
- subtitle:_subtitle
- screenKey:screen.screenKey
- hidden:_hidden
- largeTitle:_largeTitle
- largeSubtitle:_largeSubtitle
- largeTitleEnabled:_largeTitleEnabled
- leadingBarButtonItems:leadingItems
- trailingBarButtonItems:trailingItems
- titleView:titleView
- subtitleView:subtitleView
- largeSubtitleView:largeSubtitleView];
- [screen.controller.headerCoordinator submitHeaderData:data];
-}
-
-- (void)buildBarButtonItemsWithLeadingItems:(NSMutableArray *)leadingItems
- trailingItems:(NSMutableArray *)trailingItems
- titleView:(UIView *_Nullable *_Nonnull)outTitleView
- subtitleView:(UIView *_Nullable *_Nonnull)outSubtitleView
- largeSubtitleView:(UIView *_Nullable *_Nonnull)outLargeSubtitleView
-{
- for (UIView *child in _children) {
- if ([child isKindOfClass:RNSStackHeaderItemComponentView.class]) {
- auto *item = static_cast(child);
- switch (item.placement) {
- case RNSHeaderItemPlacementLeading:
- [leadingItems addObject:[RNSStackHeaderContentFactory barButtonItemForHeaderItem:item
- withFrameChangeDelegate:self
- withHeaderEventsDelegate:self]];
- break;
- case RNSHeaderItemPlacementTrailing:
- [trailingItems addObject:[RNSStackHeaderContentFactory barButtonItemForHeaderItem:item
- withFrameChangeDelegate:self
- withHeaderEventsDelegate:self]];
- break;
- case RNSHeaderItemPlacementTitle:
- if (item.customView != nil) {
- *outTitleView = [RNSStackHeaderContentFactory wrappedViewForHeaderItem:item frameChangeDelegate:self];
- }
- break;
- case RNSHeaderItemPlacementSubtitle:
- if (item.customView != nil) {
- *outSubtitleView = [RNSStackHeaderContentFactory wrappedViewForHeaderItem:item frameChangeDelegate:self];
- }
- break;
- case RNSHeaderItemPlacementLargeSubtitle:
- if (item.customView != nil) {
- *outLargeSubtitleView = [RNSStackHeaderContentFactory wrappedViewForHeaderItem:item
- frameChangeDelegate:self];
- }
- break;
- }
- } else if ([child isKindOfClass:RNSStackHeaderItemSpacerComponentView.class]) {
- auto *spacer = static_cast(child);
- switch (spacer.placement) {
- case RNSHeaderItemSpacerPlacementLeading:
- [leadingItems addObject:[RNSStackHeaderContentFactory spacerForHeaderSpacerItem:spacer]];
- break;
- case RNSHeaderItemSpacerPlacementTrailing:
- [trailingItems addObject:[RNSStackHeaderContentFactory spacerForHeaderSpacerItem:spacer]];
- break;
- }
- }
- }
+ return screen.controller.headerCoordinator;
}
- (RNSStackNavigationController *)requireNavigationController
diff --git a/ios/gamma/stack/header/RNSStackHeaderConfigDataProviding.h b/ios/gamma/stack/header/RNSStackHeaderConfigDataProviding.h
new file mode 100644
index 0000000000..c3107810b4
--- /dev/null
+++ b/ios/gamma/stack/header/RNSStackHeaderConfigDataProviding.h
@@ -0,0 +1,24 @@
+#pragma once
+
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+@protocol RNSStackHeaderConfigDataProviding
+
+@property (nonatomic, readonly, nullable) NSString *title;
+@property (nonatomic, readonly, nullable) NSString *subtitle;
+@property (nonatomic, readonly) BOOL hidden;
+@property (nonatomic, readonly, nullable) NSString *largeTitle;
+@property (nonatomic, readonly, nullable) NSString *largeSubtitle;
+@property (nonatomic, readonly) BOOL largeTitleEnabled;
+
+/**
+ Children are expected to conform to either RNSStackHeaderItemDataProviding
+ or RNSStackHeaderItemSpacerDataProviding, other types are ignored.
+ */
+@property (nonatomic, readonly) NSArray *children;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/gamma/stack/header/RNSStackHeaderContentFactory.h b/ios/gamma/stack/header/RNSStackHeaderContentFactory.h
index 6e771f317d..7057e5ff37 100644
--- a/ios/gamma/stack/header/RNSStackHeaderContentFactory.h
+++ b/ios/gamma/stack/header/RNSStackHeaderContentFactory.h
@@ -7,17 +7,31 @@
#import "RNSStackHeaderItemSpacerDataProviding.h"
#import "RNSViewFrameChangeDelegate.h"
+@class RNSStackHeaderMenuToggleStateTracker;
+
NS_ASSUME_NONNULL_BEGIN
@interface RNSStackHeaderContentFactory : NSObject
+/**
+ Builds regular bar button item to be added to the list of leading or trailing items.
+ It does not define a menu, but it can be added to the resulting item separately.
+ */
+ (UIBarButtonItem *)barButtonItemForHeaderItem:(id)item
withFrameChangeDelegate:(id)delegate
withHeaderEventsDelegate:(id)headerEventsDelegate;
+/**
+ Builds views that can be added to other areas of the header: titleView, subtitleView, largeSubtitleView.
+ */
+ (UIView *)wrappedViewForHeaderItem:(id)item
frameChangeDelegate:(id)delegate;
+/**
+ Builds a bar spacer item that can be added to the list of leading or trailing items
+ to add separation to the items. Prior to iOS 26 it could resize the space between
+ item; on iOS 26 and above, it separates the default liquid glass bubble.
+ */
+ (UIBarButtonItem *)spacerForHeaderSpacerItem:(id)spacer;
@end
diff --git a/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm b/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm
index 470daca291..30bf50d6a9 100644
--- a/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm
+++ b/ios/gamma/stack/header/RNSStackHeaderContentFactory.mm
@@ -8,24 +8,6 @@ @implementation RNSStackHeaderContentFactory
+ (UIBarButtonItem *)barButtonItemForHeaderItem:(id)item
withFrameChangeDelegate:(id)delegate
withHeaderEventsDelegate:(id)headerEventsDelegate
-{
- UIBarButtonItem *barButtonItem =
- [RNSStackHeaderContentFactory internalBarButtonItemForHeaderItem:item
- withFrameChangeDelegate:delegate
- withHeaderEventsDelegate:headerEventsDelegate];
- if (item.menu != nil) {
- [RNSStackHeaderMenuCoordinator applyMenu:item.menu
- toBarButtonItem:barButtonItem
- withHeaderEventsDelegate:headerEventsDelegate
- stateTracker:item.menuToggleStateTracker];
- }
-
- return barButtonItem;
-}
-
-+ (UIBarButtonItem *)internalBarButtonItemForHeaderItem:(id)item
- withFrameChangeDelegate:(id)delegate
- withHeaderEventsDelegate:(id)headerEventsDelegate
{
if (item.customView != nil) {
#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
diff --git a/ios/gamma/stack/header/RNSStackHeaderData.h b/ios/gamma/stack/header/RNSStackHeaderData.h
deleted file mode 100644
index b355296810..0000000000
--- a/ios/gamma/stack/header/RNSStackHeaderData.h
+++ /dev/null
@@ -1,49 +0,0 @@
-#pragma once
-
-#import
-
-NS_ASSUME_NONNULL_BEGIN
-
-@interface RNSStackHeaderData : NSObject
-
-#pragma mark - Navigation Item props
-
-@property (nonatomic, copy, readonly, nullable) NSString *title;
-@property (nonatomic, copy, readonly, nullable) NSString *subtitle;
-@property (nonatomic, copy, readonly, nullable) NSString *screenKey;
-@property (nonatomic, copy, readonly, nullable) NSArray *leadingBarButtonItems;
-@property (nonatomic, copy, readonly, nullable) NSArray *trailingBarButtonItems;
-
-/*
- * Custom views for title/subtitle/largeSubtitle placements.
- * When non-nil, these take precedence over string-based title props.
- */
-@property (nonatomic, strong, readonly, nullable) UIView *titleView;
-@property (nonatomic, strong, readonly, nullable) UIView *subtitleView;
-@property (nonatomic, strong, readonly, nullable) UIView *largeSubtitleView;
-
-#pragma mark - Navigation Bar props
-
-@property (nonatomic, readonly) BOOL hidden;
-@property (nonatomic, copy, readonly, nullable) NSString *largeTitle;
-@property (nonatomic, copy, readonly, nullable) NSString *largeSubtitle;
-@property (nonatomic, readonly) BOOL largeTitleEnabled;
-
-- (instancetype)initWithTitle:(nullable NSString *)title
- subtitle:(nullable NSString *)subtitle
- screenKey:(nullable NSString *)screenKey
- hidden:(BOOL)hidden
- largeTitle:(nullable NSString *)largeTitle
- largeSubtitle:(nullable NSString *)largeSubtitle
- largeTitleEnabled:(BOOL)largeTitleEnabled
- leadingBarButtonItems:(nullable NSArray *)leadingBarButtonItems
- trailingBarButtonItems:(nullable NSArray *)trailingBarButtonItems
- titleView:(nullable UIView *)titleView
- subtitleView:(nullable UIView *)subtitleView
- largeSubtitleView:(nullable UIView *)largeSubtitleView;
-
-@property (class, nonatomic, readonly, nonnull) RNSStackHeaderData *empty;
-
-@end
-
-NS_ASSUME_NONNULL_END
diff --git a/ios/gamma/stack/header/RNSStackHeaderData.mm b/ios/gamma/stack/header/RNSStackHeaderData.mm
deleted file mode 100644
index 536c7d62be..0000000000
--- a/ios/gamma/stack/header/RNSStackHeaderData.mm
+++ /dev/null
@@ -1,51 +0,0 @@
-#import "RNSStackHeaderData.h"
-
-@implementation RNSStackHeaderData
-
-- (instancetype)initWithTitle:(nullable NSString *)title
- subtitle:(nullable NSString *)subtitle
- screenKey:(nullable NSString *)screenKey
- hidden:(BOOL)hidden
- largeTitle:(nullable NSString *)largeTitle
- largeSubtitle:(nullable NSString *)largeSubtitle
- largeTitleEnabled:(BOOL)largeTitleEnabled
- leadingBarButtonItems:(nullable NSArray *)leadingBarButtonItems
- trailingBarButtonItems:(nullable NSArray *)trailingBarButtonItems
- titleView:(nullable UIView *)titleView
- subtitleView:(nullable UIView *)subtitleView
- largeSubtitleView:(nullable UIView *)largeSubtitleView
-{
- if (self = [super init]) {
- _title = [title copy];
- _subtitle = [subtitle copy];
- _screenKey = [screenKey copy];
- _hidden = hidden;
- _largeTitle = [largeTitle copy];
- _largeSubtitle = [largeSubtitle copy];
- _largeTitleEnabled = largeTitleEnabled;
- _leadingBarButtonItems = [leadingBarButtonItems copy];
- _trailingBarButtonItems = [trailingBarButtonItems copy];
- _titleView = titleView;
- _subtitleView = subtitleView;
- _largeSubtitleView = largeSubtitleView;
- }
- return self;
-}
-
-+ (RNSStackHeaderData *)empty
-{
- return [[RNSStackHeaderData alloc] initWithTitle:nil
- subtitle:nil
- screenKey:nil
- hidden:NO
- largeTitle:nil
- largeSubtitle:nil
- largeTitleEnabled:NO
- leadingBarButtonItems:@[]
- trailingBarButtonItems:@[]
- titleView:nil
- subtitleView:nil
- largeSubtitleView:nil];
-}
-
-@end
diff --git a/ios/gamma/stack/header/RNSStackHeaderItemComponentView.h b/ios/gamma/stack/header/RNSStackHeaderItemComponentView.h
index 98851c0e6a..99669af789 100644
--- a/ios/gamma/stack/header/RNSStackHeaderItemComponentView.h
+++ b/ios/gamma/stack/header/RNSStackHeaderItemComponentView.h
@@ -7,13 +7,13 @@
NS_ASSUME_NONNULL_BEGIN
-@interface RNSStackHeaderItemComponentView : RNSReactBaseView
+@interface RNSStackHeaderItemComponentView
+ : RNSReactBaseView
@property (nonatomic, readonly) RNSHeaderItemPlacement placement;
@property (nonatomic, readonly, nullable) NSString *itemId;
@property (nonatomic, readonly, nullable) NSString *title;
@property (nonatomic, readonly, nullable) RNSStackHeaderMenuData *menu;
-@property (nonatomic, readonly, nullable) RNSStackHeaderMenuToggleStateTracker *menuToggleStateTracker;
@property (nonatomic, readonly, nullable) UIView *customView;
@property (nonatomic, readonly) BOOL respondsToOnPress;
diff --git a/ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm b/ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm
index 0bc765f83f..5fe09cbe15 100644
--- a/ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm
+++ b/ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm
@@ -6,7 +6,6 @@
#import "RNSStackHeaderItemShadowStateProxy.h"
#import "RNSStackHeaderMenuData.h"
#import "RNSStackHeaderMenuMapper.h"
-#import "RNSStackHeaderMenuToggleStateTracker.h"
#import
#import
@@ -16,9 +15,6 @@
namespace react = facebook::react;
-@interface RNSStackHeaderItemComponentView ()
-@end
-
@implementation RNSStackHeaderItemComponentView {
BOOL _didSetHeaderItemPlacement;
@@ -49,7 +45,6 @@ - (void)resetProps
_itemId = nil;
_title = nil;
_menu = nil;
- _menuToggleStateTracker = nil;
_placement = RNSHeaderItemPlacementTrailing;
_didSetHeaderItemPlacement = NO;
_respondsToOnPress = NO;
@@ -87,7 +82,7 @@ - (void)mountChildComponentView:(UIView *)childCompone
// An existing item may have transitioned from label-only to custom view,
// and needs to be rebuilt.
- [_invalidationDelegate headerItemDidInvalidate];
+ [_invalidationDelegate headerItemDidInvalidateWithId:_itemId];
}
- (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index
@@ -96,7 +91,7 @@ - (void)unmountChildComponentView:(UIView *)childCompo
// An existing item may have transitioned from custom view to label-only,
// and needs to be rebuilt.
- [_invalidationDelegate headerItemDidInvalidate];
+ [_invalidationDelegate headerItemDidInvalidateWithId:_itemId];
}
- (void)updateState:(const react::State::Shared &)state oldState:(const react::State::Shared &)oldState
@@ -149,6 +144,7 @@ - (void)updateProps:(const react::Props::Shared &)props oldProps:(const react::P
const auto &oldItemProps = *std::static_pointer_cast(_props);
BOOL needsUpdate = NO;
+ BOOL menuDidChange = NO;
if (oldItemProps.itemId != newItemProps.itemId) {
_itemId = RCTNSStringFromStringNilIfEmpty(newItemProps.itemId);
@@ -171,8 +167,7 @@ - (void)updateProps:(const react::Props::Shared &)props oldProps:(const react::P
if (oldItemProps.menu != newItemProps.menu) {
_menu = [RNSStackHeaderMenuMapper
menuFromDictionary:rnscreens::conversion::RNSConvertFollyDynamicToId(newItemProps.menu)];
- _menuToggleStateTracker = _menu != nil ? [RNSStackHeaderMenuToggleStateTracker new] : nil;
- needsUpdate = YES;
+ menuDidChange = YES;
}
if (oldItemProps.respondsToOnPress != newItemProps.respondsToOnPress) {
@@ -183,7 +178,18 @@ - (void)updateProps:(const react::Props::Shared &)props oldProps:(const react::P
[super updateProps:props oldProps:oldProps];
if (needsUpdate) {
- [_invalidationDelegate headerItemDidInvalidate];
+ // rebuilds the item; needs to rebuilds the menu, but keeps its state
+ [_invalidationDelegate headerItemDidInvalidateWithId:_itemId];
+ }
+
+ if (menuDidChange) {
+ // there are 3 distinct cases for rebuilding the menu
+ // 1. only menu changed -- no item rebuilding, menu state reset
+ // 2. some different prop changed -- item rebuilds, but menu should keep its state
+ // 3. both menu and some other prop changed -- both item and menu rebuilds + menu state should be reset
+ // If we don't have separate if-s, we won't cover all cases,
+ // but unfortunately we're rebuilding the menu twice for 3. case.
+ [_invalidationDelegate headerItemMenuDidChangeWithId:_itemId];
}
}
diff --git a/ios/gamma/stack/header/RNSStackHeaderItemDataProviding.h b/ios/gamma/stack/header/RNSStackHeaderItemDataProviding.h
index 61252bd604..44b4fa620e 100644
--- a/ios/gamma/stack/header/RNSStackHeaderItemDataProviding.h
+++ b/ios/gamma/stack/header/RNSStackHeaderItemDataProviding.h
@@ -4,7 +4,6 @@
#import "RNSHeaderItemPlacement.h"
#import "RNSStackHeaderMenuData.h"
-#import "RNSStackHeaderMenuToggleStateTracker.h"
NS_ASSUME_NONNULL_BEGIN
@@ -17,8 +16,6 @@ NS_ASSUME_NONNULL_BEGIN
@property (nonatomic, readonly, nullable) UIView *customView;
@property (nonatomic, readonly) BOOL respondsToOnPress;
-@property (nonatomic, readonly) RNSStackHeaderMenuToggleStateTracker *menuToggleStateTracker;
-
@end
NS_ASSUME_NONNULL_END
diff --git a/ios/gamma/stack/header/RNSStackHeaderItemInvalidationDelegate.h b/ios/gamma/stack/header/RNSStackHeaderItemInvalidationDelegate.h
index 2f9fe01f92..e988753d14 100644
--- a/ios/gamma/stack/header/RNSStackHeaderItemInvalidationDelegate.h
+++ b/ios/gamma/stack/header/RNSStackHeaderItemInvalidationDelegate.h
@@ -6,7 +6,11 @@ NS_ASSUME_NONNULL_BEGIN
@protocol RNSStackHeaderItemInvalidationDelegate
-- (void)headerItemDidInvalidate;
+- (void)headerItemDidInvalidateWithId:(NSString *)itemId;
+
+- (void)headerItemMenuDidChangeWithId:(NSString *)itemId;
+
+- (void)headerItemSpacerDidInvalidate;
@end
diff --git a/ios/gamma/stack/header/RNSStackHeaderItemSpacerComponentView.mm b/ios/gamma/stack/header/RNSStackHeaderItemSpacerComponentView.mm
index df80a4aa08..43982cba85 100644
--- a/ios/gamma/stack/header/RNSStackHeaderItemSpacerComponentView.mm
+++ b/ios/gamma/stack/header/RNSStackHeaderItemSpacerComponentView.mm
@@ -61,7 +61,7 @@ - (void)updateProps:(const react::Props::Shared &)props oldProps:(const react::P
[super updateProps:props oldProps:oldProps];
if (needsUpdate) {
- [_invalidationDelegate headerItemDidInvalidate];
+ [_invalidationDelegate headerItemSpacerDidInvalidate];
}
}
diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h
index 624a97fb8c..0993e4b13c 100644
--- a/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h
+++ b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.h
@@ -9,10 +9,18 @@ NS_ASSUME_NONNULL_BEGIN
@interface RNSStackHeaderMenuCoordinator : NSObject
+/**
+ Applies menu specification to the item. Sets up event delegate to receive
+ menu updates when user selects options or clicks actions.
+ Since menu needs to be rebuilt after user changes selection,
+ the `menuToggleCallback` is triggered and the caller is expected
+ to reapply the menu there.
+ */
+ (void)applyMenu:(RNSStackHeaderMenuData *)data
toBarButtonItem:(UIBarButtonItem *)item
withHeaderEventsDelegate:(id)delegate
- stateTracker:(RNSStackHeaderMenuToggleStateTracker *)tracker;
+ stateTracker:(RNSStackHeaderMenuToggleStateTracker *)tracker
+ menuToggleCallback:(nullable void (^)(void))onMenuToggle;
@end
diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm
index 430dd4cf5b..f7e4e6da69 100644
--- a/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm
+++ b/ios/gamma/stack/header/RNSStackHeaderMenuCoordinator.mm
@@ -8,6 +8,7 @@ + (void)applyMenu:(RNSStackHeaderMenuData *)data
toBarButtonItem:(UIBarButtonItem *)item
withHeaderEventsDelegate:(id)delegate
stateTracker:(RNSStackHeaderMenuToggleStateTracker *)tracker
+ menuToggleCallback:(nullable void (^)(void))onMenuToggle
{
#if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000
if (@available(tvOS 17.0, *)) {
@@ -15,7 +16,8 @@ + (void)applyMenu:(RNSStackHeaderMenuData *)data
withHeaderEventsDelegate:delegate
stateTracker:tracker
singleSelectionRoot:nil
- initialSingleSelectionStateClaimed:NULL];
+ initialSingleSelectionStateClaimed:NULL
+ menuToggleCallback:onMenuToggle];
}
#endif // !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000
}
@@ -25,6 +27,7 @@ + (UIMenu *)buildMenuFromData:(RNSStackHeaderMenuData *)data
stateTracker:(RNSStackHeaderMenuToggleStateTracker *)tracker
singleSelectionRoot:(nullable RNSStackHeaderMenuData *)singleSelectionRoot
initialSingleSelectionStateClaimed:(BOOL *)initialSingleSelectionStateClaimed
+ menuToggleCallback:(nullable void (^)(void))onMenuToggle
{
// Resolve singleSelection root: first menu in hierarchy with singleSelection becomes the root.
// Only the root is set the singleSelection option - less things to check if sth goes wrong
@@ -47,7 +50,8 @@ + (UIMenu *)buildMenuFromData:(RNSStackHeaderMenuData *)data
stateTracker:tracker
parentMenu:data
singleSelectionRoot:resolvedRoot
- initialSingleSelectionStateClaimed:initialSingleSelectionStateClaimed];
+ initialSingleSelectionStateClaimed:initialSingleSelectionStateClaimed
+ menuToggleCallback:onMenuToggle];
if (element != nil) {
[elements addObject:element];
}
@@ -62,13 +66,15 @@ + (nullable UIMenuElement *)buildElementFromData:(id)
parentMenu:(RNSStackHeaderMenuData *)parentMenu
singleSelectionRoot:(nullable RNSStackHeaderMenuData *)singleSelectionRoot
initialSingleSelectionStateClaimed:(BOOL *)initialSingleSelectionStateClaimed
+ menuToggleCallback:(nullable void (^)(void))onMenuToggle
{
if ([element isKindOfClass:[RNSStackHeaderMenuData class]]) {
return [self buildMenuFromData:(RNSStackHeaderMenuData *)element
withHeaderEventsDelegate:delegate
stateTracker:tracker
singleSelectionRoot:singleSelectionRoot
- initialSingleSelectionStateClaimed:initialSingleSelectionStateClaimed];
+ initialSingleSelectionStateClaimed:initialSingleSelectionStateClaimed
+ menuToggleCallback:onMenuToggle];
}
if ([element isKindOfClass:[RNSStackHeaderMenuItemData class]]) {
@@ -93,7 +99,8 @@ + (nullable UIMenuElement *)buildElementFromData:(id)
withParentMenu:parentMenu
singleSelectionRoot:singleSelectionRoot
toggleStateTracker:tracker
- headerEventsDelegate:delegate];
+ headerEventsDelegate:delegate
+ menuToggleCallback:onMenuToggle];
}
// it effective type is not 'toggle', then it is a regular action button that triggers onPress instead
@@ -118,6 +125,7 @@ + (nullable UIMenuElement *)buildToggleFromData:(RNSStackHeaderMenuItemData *)da
singleSelectionRoot:(nullable RNSStackHeaderMenuData *)singleSelectionRoot
toggleStateTracker:(RNSStackHeaderMenuToggleStateTracker *)tracker
headerEventsDelegate:(id)delegate
+ menuToggleCallback:(nullable void (^)(void))onMenuToggle
{
BOOL isItemToggledOn = [tracker getToggleStateForItemWithId:data.menuElementId initialState:data.initialToggleState];
BOOL insideSingleSelection = singleSelectionRoot != nil;
@@ -151,8 +159,13 @@ + (nullable UIMenuElement *)buildToggleFromData:(RNSStackHeaderMenuItemData *)da
// radio
if ([tracker toggleStateChanged]) {
[weakDelegate didChangeSelectionForMenu:eventMenuId selectedMenuItemIds:selectedIds];
+
[tracker setToggleStateChanged:NO];
}
+
+ if (onMenuToggle) {
+ onMenuToggle();
+ }
}];
toggleAction.state = isItemToggledOn ? UIMenuElementStateOn : UIMenuElementStateOff;
diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuTrackerRegistry.h b/ios/gamma/stack/header/RNSStackHeaderMenuTrackerRegistry.h
new file mode 100644
index 0000000000..4fbf55968f
--- /dev/null
+++ b/ios/gamma/stack/header/RNSStackHeaderMenuTrackerRegistry.h
@@ -0,0 +1,19 @@
+#pragma once
+
+#import
+
+@class RNSStackHeaderMenuToggleStateTracker;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface RNSStackHeaderMenuTrackerRegistry : NSObject
+
+- (RNSStackHeaderMenuToggleStateTracker *)trackerForItemId:(NSString *)itemId;
+
+- (void)resetTrackerForItemId:(NSString *)itemId;
+
+- (void)clear;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/ios/gamma/stack/header/RNSStackHeaderMenuTrackerRegistry.mm b/ios/gamma/stack/header/RNSStackHeaderMenuTrackerRegistry.mm
new file mode 100644
index 0000000000..75ee414a55
--- /dev/null
+++ b/ios/gamma/stack/header/RNSStackHeaderMenuTrackerRegistry.mm
@@ -0,0 +1,36 @@
+#import "RNSStackHeaderMenuTrackerRegistry.h"
+#import "RNSStackHeaderMenuToggleStateTracker.h"
+
+@implementation RNSStackHeaderMenuTrackerRegistry {
+ NSMutableDictionary *_trackers;
+}
+
+- (instancetype)init
+{
+ if (self = [super init]) {
+ _trackers = [NSMutableDictionary new];
+ }
+ return self;
+}
+
+- (RNSStackHeaderMenuToggleStateTracker *)trackerForItemId:(NSString *)itemId
+{
+ RNSStackHeaderMenuToggleStateTracker *tracker = _trackers[itemId];
+ if (tracker == nil) {
+ tracker = [RNSStackHeaderMenuToggleStateTracker new];
+ _trackers[itemId] = tracker;
+ }
+ return tracker;
+}
+
+- (void)resetTrackerForItemId:(NSString *)itemId
+{
+ _trackers[itemId] = [RNSStackHeaderMenuToggleStateTracker new];
+}
+
+- (void)clear
+{
+ [_trackers removeAllObjects];
+}
+
+@end
diff --git a/ios/gamma/stack/host/RNSStackNavigationBarCoordinator.h b/ios/gamma/stack/host/RNSStackNavigationBarCoordinator.h
index 038ea88e0c..510ac74267 100644
--- a/ios/gamma/stack/host/RNSStackNavigationBarCoordinator.h
+++ b/ios/gamma/stack/host/RNSStackNavigationBarCoordinator.h
@@ -1,17 +1,16 @@
#pragma once
#import
-#import "RNSStackHeaderData.h"
NS_ASSUME_NONNULL_BEGIN
@interface RNSStackNavigationBarCoordinator : NSObject
-- (void)applyConfiguration:(nonnull RNSStackHeaderData *)data
- forNavigationController:(nonnull UINavigationController *)navigationController
+- (void)setHidden:(BOOL)hidden
+ forNavigationController:(UINavigationController *)navigationController
animated:(BOOL)animated;
-- (void)initializeNavigationBarOfNavigationController:(nonnull UINavigationController *)navigationController;
+- (void)initializeNavigationBarOfNavigationController:(UINavigationController *)navigationController;
@end
diff --git a/ios/gamma/stack/host/RNSStackNavigationBarCoordinator.mm b/ios/gamma/stack/host/RNSStackNavigationBarCoordinator.mm
index 61a60e41ee..53484b3f2e 100644
--- a/ios/gamma/stack/host/RNSStackNavigationBarCoordinator.mm
+++ b/ios/gamma/stack/host/RNSStackNavigationBarCoordinator.mm
@@ -2,25 +2,18 @@
@implementation RNSStackNavigationBarCoordinator
-- (void)applyConfiguration:(RNSStackHeaderData *)data
- forNavigationController:(nonnull UINavigationController *)navigationController
+- (void)setHidden:(BOOL)hidden
+ forNavigationController:(UINavigationController *)navigationController
animated:(BOOL)animated
{
- [self setupVisibility:data forNavigationController:navigationController animated:animated];
+ [navigationController setNavigationBarHidden:hidden animated:animated];
}
-- (void)initializeNavigationBarOfNavigationController:(nonnull UINavigationController *)navigationController
+- (void)initializeNavigationBarOfNavigationController:(UINavigationController *)navigationController
{
#if !TARGET_OS_TV
navigationController.navigationBar.prefersLargeTitles = YES;
#endif // !TARGET_OS_TV
}
-- (void)setupVisibility:(RNSStackHeaderData *)data
- forNavigationController:(nonnull UINavigationController *)navigationController
- animated:(BOOL)animated
-{
- [navigationController setNavigationBarHidden:data.hidden animated:animated];
-}
-
@end
diff --git a/ios/gamma/stack/screen/RNSStackNavigationItemCoordinator.h b/ios/gamma/stack/screen/RNSStackNavigationItemCoordinator.h
deleted file mode 100644
index 2cf0b0f19d..0000000000
--- a/ios/gamma/stack/screen/RNSStackNavigationItemCoordinator.h
+++ /dev/null
@@ -1,17 +0,0 @@
-#pragma once
-
-#import
-#import "RNSStackHeaderData.h"
-
-NS_ASSUME_NONNULL_BEGIN
-
-@class RNSStackScreenController;
-
-@interface RNSStackNavigationItemCoordinator : NSObject
-
-- (void)applyConfiguration:(nonnull RNSStackHeaderData *)data
- forController:(nonnull RNSStackScreenController *)controller;
-
-@end
-
-NS_ASSUME_NONNULL_END
diff --git a/ios/gamma/stack/screen/RNSStackNavigationItemCoordinator.mm b/ios/gamma/stack/screen/RNSStackNavigationItemCoordinator.mm
deleted file mode 100644
index 5dd64e204b..0000000000
--- a/ios/gamma/stack/screen/RNSStackNavigationItemCoordinator.mm
+++ /dev/null
@@ -1,60 +0,0 @@
-#import "RNSStackNavigationItemCoordinator.h"
-#import "RNSDefines.h"
-#import "RNSStackScreenController.h"
-
-@implementation RNSStackNavigationItemCoordinator
-
-- (void)applyConfiguration:(RNSStackHeaderData *)data forController:(nonnull RNSStackScreenController *)controller
-{
- [self setupTitle:data forController:controller];
- [self setupSubtitle:data forController:controller];
- [self setupLargeTitleDisplayMode:data forController:controller];
- [self setupBarButtonItems:data forController:controller];
-}
-
-- (void)setupTitle:(RNSStackHeaderData *)data forController:(nonnull RNSStackScreenController *)controller
-{
- UINavigationItem *navItem = controller.navigationItem;
-
- navItem.titleView = data.titleView;
- navItem.title = data.title;
-
-#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
- if (@available(iOS 26.0, *)) {
- navItem.largeTitle = data.largeTitle;
- }
-#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-}
-
-- (void)setupSubtitle:(RNSStackHeaderData *)data forController:(nonnull RNSStackScreenController *)controller
-{
-#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
- if (@available(iOS 26.0, *)) {
- UINavigationItem *navItem = controller.navigationItem;
- navItem.subtitle = data.subtitle;
- navItem.largeSubtitle = data.largeSubtitle;
- navItem.subtitleView = data.subtitleView;
- navItem.largeSubtitleView = data.largeSubtitleView;
- }
-#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
-}
-
-- (void)setupLargeTitleDisplayMode:(RNSStackHeaderData *)data
- forController:(nonnull RNSStackScreenController *)controller
-{
-#if !TARGET_OS_TV
- controller.navigationItem.largeTitleDisplayMode =
- data.largeTitleEnabled ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever;
-#endif // !TARGET_OS_TV
-}
-
-- (void)setupBarButtonItems:(RNSStackHeaderData *)data forController:(nonnull RNSStackScreenController *)controller
-{
-#if !TARGET_OS_TV
- controller.navigationItem.leftItemsSupplementBackButton = YES;
-#endif // !TARGET_OS_TV
- [controller.navigationItem setLeftBarButtonItems:data.leadingBarButtonItems animated:YES];
- [controller.navigationItem setRightBarButtonItems:data.trailingBarButtonItems animated:YES];
-}
-
-@end
diff --git a/ios/gamma/stack/screen/RNSStackScreenComponentView.h b/ios/gamma/stack/screen/RNSStackScreenComponentView.h
index e5c1e17fd0..9e77e2254a 100644
--- a/ios/gamma/stack/screen/RNSStackScreenComponentView.h
+++ b/ios/gamma/stack/screen/RNSStackScreenComponentView.h
@@ -1,7 +1,6 @@
#pragma once
#import "RNSReactBaseView.h"
-#import "RNSStackHeaderData.h"
#import "RNSStackScreenComponentEventEmitter.h"
NS_ASSUME_NONNULL_BEGIN
diff --git a/ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.h b/ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.h
index 7280638157..cb89c1c8ea 100644
--- a/ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.h
+++ b/ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.h
@@ -1,7 +1,9 @@
#pragma once
#import
-#import "RNSStackHeaderData.h"
+#import "RNSStackHeaderConfigDataProviding.h"
+#import "RNSStackHeaderEventsDelegate.h"
+#import "RNSViewFrameChangeDelegate.h"
NS_ASSUME_NONNULL_BEGIN
@@ -11,7 +13,19 @@ NS_ASSUME_NONNULL_BEGIN
- (instancetype)initWithScreenController:(RNSStackScreenController *)controller;
-- (void)submitHeaderData:(nonnull RNSStackHeaderData *)data;
+@property (nonatomic, weak, nullable) id configDataProvider;
+@property (nonatomic, weak, nullable) id frameChangeDelegate;
+@property (nonatomic, weak, nullable) id eventsDelegate;
+
+- (void)rebuild;
+
+- (void)applyConfigProperties;
+
+- (void)rebuildItemWithId:(nullable NSString *)itemId;
+
+- (void)reapplyMenuForItemWithId:(nullable NSString *)itemId;
+
+- (void)resetTrackerForItemWithId:(nullable NSString *)itemId;
- (void)clearHeaderConfiguration;
diff --git a/ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.mm b/ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.mm
index 7763ab47da..a1b60108e4 100644
--- a/ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.mm
+++ b/ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.mm
@@ -1,36 +1,301 @@
#import "RNSStackScreenHeaderCoordinator.h"
#import "RCTAssert.h"
+#import "RNSDefines.h"
#import "RNSLog.h"
+#import "RNSStackHeaderContentFactory.h"
+#import "RNSStackHeaderItemDataProviding.h"
+#import "RNSStackHeaderItemSpacerDataProviding.h"
+#import "RNSStackHeaderMenuCoordinator.h"
+#import "RNSStackHeaderMenuTrackerRegistry.h"
#import "RNSStackNavigationBarCoordinator.h"
#import "RNSStackNavigationController.h"
-#import "RNSStackNavigationItemCoordinator.h"
-#import "RNSStackScreenComponentView.h"
#import "RNSStackScreenController.h"
@implementation RNSStackScreenHeaderCoordinator {
__weak RNSStackScreenController *_Nullable _screenController;
- RNSStackNavigationItemCoordinator *_Nonnull _navigationItemCoordinator;
+ RNSStackHeaderMenuTrackerRegistry *_Nonnull _trackerRegistry;
- RNSStackHeaderData *_Nullable _lastHeaderData;
+ NSMutableArray *_Nonnull _leadingBarButtonItems;
+ NSMutableArray *_Nonnull _trailingBarButtonItems;
+ NSMutableDictionary *_Nonnull _barButtonItemsByItemId;
}
- (instancetype)initWithScreenController:(RNSStackScreenController *)controller
{
if (self = [super init]) {
_screenController = controller;
- _navigationItemCoordinator = [RNSStackNavigationItemCoordinator new];
+ _trackerRegistry = [RNSStackHeaderMenuTrackerRegistry new];
+ _leadingBarButtonItems = [NSMutableArray new];
+ _trailingBarButtonItems = [NSMutableArray new];
+ _barButtonItemsByItemId = [NSMutableDictionary new];
}
return self;
}
+#pragma mark - Public
+
+- (void)rebuild
+{
+ if (_configDataProvider == nil) {
+ return;
+ }
+
+ RNSStackScreenController *controller = [self requireScreenController];
+
+ [_leadingBarButtonItems removeAllObjects];
+ [_trailingBarButtonItems removeAllObjects];
+ [_barButtonItemsByItemId removeAllObjects];
+ [_trackerRegistry clear];
+
+ UIView *titleView = nil;
+ UIView *subtitleView = nil;
+ UIView *largeSubtitleView = nil;
+
+ for (UIView *child in _configDataProvider.children) {
+ if ([child conformsToProtocol:@protocol(RNSStackHeaderItemDataProviding)]) {
+ id item = (id)child;
+
+ switch (item.placement) {
+ case RNSHeaderItemPlacementLeading:
+ [_leadingBarButtonItems addObject:[self buildBarButtonItemForItem:item]];
+ break;
+ case RNSHeaderItemPlacementTrailing:
+ [_trailingBarButtonItems addObject:[self buildBarButtonItemForItem:item]];
+ break;
+ case RNSHeaderItemPlacementTitle:
+ if (item.customView != nil) {
+ titleView = [RNSStackHeaderContentFactory wrappedViewForHeaderItem:item
+ frameChangeDelegate:_frameChangeDelegate];
+ }
+ break;
+ case RNSHeaderItemPlacementSubtitle:
+ if (item.customView != nil) {
+ subtitleView = [RNSStackHeaderContentFactory wrappedViewForHeaderItem:item
+ frameChangeDelegate:_frameChangeDelegate];
+ }
+ break;
+ case RNSHeaderItemPlacementLargeSubtitle:
+ if (item.customView != nil) {
+ largeSubtitleView = [RNSStackHeaderContentFactory wrappedViewForHeaderItem:item
+ frameChangeDelegate:_frameChangeDelegate];
+ }
+ break;
+ }
+ } else if ([child conformsToProtocol:@protocol(RNSStackHeaderItemSpacerDataProviding)]) {
+ id spacer = (id)child;
+ UIBarButtonItem *bbi = [RNSStackHeaderContentFactory spacerForHeaderSpacerItem:spacer];
+ switch (spacer.placement) {
+ case RNSHeaderItemSpacerPlacementLeading:
+ [_leadingBarButtonItems addObject:bbi];
+ break;
+ case RNSHeaderItemSpacerPlacementTrailing:
+ [_trailingBarButtonItems addObject:bbi];
+ break;
+ }
+ }
+ }
+
+ [self applyConfigPropertiesForController:controller];
+ [self applyItemsWithTitleView:titleView
+ subtitleView:subtitleView
+ largeSubtitleView:largeSubtitleView
+ forController:controller];
+
+ [self applyNavigationBarProperties];
+}
+
+- (void)applyConfigProperties
+{
+ if (_configDataProvider == nil) {
+ return;
+ }
+
+ [self applyConfigPropertiesForController:[self requireScreenController]];
+ [self applyNavigationBarProperties];
+}
+
+/**
+ Rebuilds an existing item: sets all props and applies the menu config.
+ */
+- (void)rebuildItemWithId:(NSString *)itemId
+{
+ if (_configDataProvider == nil || itemId == nil) {
+ return;
+ }
+
+ UIBarButtonItem *oldBarButtonItem = _barButtonItemsByItemId[itemId];
+
+ id targetItem = [self findItemWithId:itemId];
+ if (targetItem == nil) {
+ return;
+ }
+
+ RNSStackScreenController *controller = [self requireScreenController];
+
+ switch (targetItem.placement) {
+ case RNSHeaderItemPlacementLeading: {
+ UIBarButtonItem *newBarButtonItem = [self buildBarButtonItemForItem:targetItem];
+
+ if (oldBarButtonItem != nil) {
+ NSUInteger index = [_leadingBarButtonItems indexOfObject:oldBarButtonItem];
+ if (index != NSNotFound) {
+ _leadingBarButtonItems[index] = newBarButtonItem;
+ } else {
+ RNSLog(@"[RNScreens] Item %@ not found for rebuild.", oldBarButtonItem);
+ }
+ }
+
+ [controller.navigationItem setLeftBarButtonItems:[_leadingBarButtonItems copy] animated:YES];
+ break;
+ }
+ case RNSHeaderItemPlacementTrailing: {
+ UIBarButtonItem *newBarButtonItem = [self buildBarButtonItemForItem:targetItem];
+
+ if (oldBarButtonItem != nil) {
+ NSUInteger index = [_trailingBarButtonItems indexOfObject:oldBarButtonItem];
+ if (index != NSNotFound) {
+ _trailingBarButtonItems[index] = newBarButtonItem;
+ } else {
+ RNSLog(@"[RNScreens] Item %@ not found for rebuild.", oldBarButtonItem);
+ }
+ }
+
+ [controller.navigationItem setRightBarButtonItems:[_trailingBarButtonItems copy] animated:YES];
+ break;
+ }
+ case RNSHeaderItemPlacementTitle:
+ controller.navigationItem.titleView = targetItem.customView != nil
+ ? [RNSStackHeaderContentFactory wrappedViewForHeaderItem:targetItem frameChangeDelegate:_frameChangeDelegate]
+ : nil;
+ break;
+ case RNSHeaderItemPlacementSubtitle:
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ controller.navigationItem.subtitleView = targetItem.customView != nil
+ ? [RNSStackHeaderContentFactory wrappedViewForHeaderItem:targetItem
+ frameChangeDelegate:_frameChangeDelegate]
+ : nil;
+ }
+#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ break;
+ case RNSHeaderItemPlacementLargeSubtitle:
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ controller.navigationItem.largeSubtitleView = targetItem.customView != nil
+ ? [RNSStackHeaderContentFactory wrappedViewForHeaderItem:targetItem
+ frameChangeDelegate:_frameChangeDelegate]
+ : nil;
+ }
+#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ break;
+ }
+}
+
+- (void)resetTrackerForItemWithId:(NSString *)itemId
+{
+ if (itemId != nil) {
+ [_trackerRegistry resetTrackerForItemId:itemId];
+ }
+}
+
+- (void)clearHeaderConfiguration
+{
+ _configDataProvider = nil;
+ _frameChangeDelegate = nil;
+ _eventsDelegate = nil;
+
+ [_leadingBarButtonItems removeAllObjects];
+ [_trailingBarButtonItems removeAllObjects];
+ [_barButtonItemsByItemId removeAllObjects];
+ [_trackerRegistry clear];
+
+ RNSStackScreenController *controller = [self requireScreenController];
+ UINavigationItem *navItem = controller.navigationItem;
+
+ navItem.title = nil;
+ navItem.titleView = nil;
+ [navItem setLeftBarButtonItems:@[] animated:YES];
+ [navItem setRightBarButtonItems:@[] animated:YES];
+
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ navItem.subtitle = nil;
+ navItem.largeTitle = nil;
+ navItem.largeSubtitle = nil;
+ navItem.subtitleView = nil;
+ navItem.largeSubtitleView = nil;
+ }
+#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+
+#if !TARGET_OS_TV
+ navItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever;
+#endif // !TARGET_OS_TV
+
+ RNSStackNavigationController *navController = [self getNavigationController];
+ if (navController != nil) {
+ [navController.navigationBarCoordinator setHidden:NO forNavigationController:navController animated:YES];
+ }
+}
+
+/**
+ Finds an existing barButtonItem and its corresponding config, then applies it again.
+ If the config is missing, it clears the menu.
+ */
+- (void)reapplyMenuForItemWithId:(NSString *)itemId
+{
+ if (_configDataProvider == nil || itemId == nil) {
+ return;
+ }
+
+ UIBarButtonItem *barButtonItem = _barButtonItemsByItemId[itemId];
+ if (barButtonItem == nil) {
+ return;
+ }
+
+ id item = [self findItemWithId:itemId];
+ if (item == nil) {
+ return;
+ }
+
+ if (item.menu == nil) {
+ barButtonItem.menu = nil;
+ return;
+ }
+
+ RNSStackHeaderMenuToggleStateTracker *tracker = [_trackerRegistry trackerForItemId:itemId];
+ __weak auto weakSelf = self;
+ [RNSStackHeaderMenuCoordinator applyMenu:item.menu
+ toBarButtonItem:barButtonItem
+ withHeaderEventsDelegate:_eventsDelegate
+ stateTracker:tracker
+ menuToggleCallback:^{
+ [weakSelf reapplyMenuForItemWithId:itemId];
+ }];
+}
+
+- (nullable id)findItemWithId:(NSString *)itemId
+{
+ for (UIView *child in _configDataProvider.children) {
+ if ([child conformsToProtocol:@protocol(RNSStackHeaderItemDataProviding)]) {
+ id item = (id)child;
+ if ([item.itemId isEqualToString:itemId]) {
+ return item;
+ }
+ }
+ }
+ return nil;
+}
+
+#pragma mark - Private
+
- (RNSStackScreenController *)requireScreenController
{
RCTAssert(_screenController != nil, @"[RNScreens] Screen Controller cannot be nil");
return _screenController;
}
-- (RNSStackNavigationController *)getNavigationController
+- (nullable RNSStackNavigationController *)getNavigationController
{
RNSStackScreenController *screenController = [self requireScreenController];
UINavigationController *navController = screenController.navigationController;
@@ -43,24 +308,84 @@ - (RNSStackNavigationController *)getNavigationController
return (RNSStackNavigationController *)navController;
}
-#pragma mark - Header Data
+- (void)applyNavigationBarProperties
+{
+ RNSStackNavigationController *navController = [self getNavigationController];
+ if (navController != nil) {
+ [navController.navigationBarCoordinator setHidden:_configDataProvider.hidden
+ forNavigationController:navController
+ animated:YES];
+ }
+}
+
+- (void)applyConfigPropertiesForController:(RNSStackScreenController *)controller
+{
+ UINavigationItem *navItem = controller.navigationItem;
+
+ navItem.title = _configDataProvider.title;
+
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ navItem.largeTitle = _configDataProvider.largeTitle;
+ navItem.subtitle = _configDataProvider.subtitle;
+ navItem.largeSubtitle = _configDataProvider.largeSubtitle;
+ }
+#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+
+#if !TARGET_OS_TV
+ navItem.largeTitleDisplayMode = _configDataProvider.largeTitleEnabled ? UINavigationItemLargeTitleDisplayModeAlways
+ : UINavigationItemLargeTitleDisplayModeNever;
+#endif // !TARGET_OS_TV
+}
-- (void)submitHeaderData:(nonnull RNSStackHeaderData *)data
+- (void)applyItemsWithTitleView:(nullable UIView *)titleView
+ subtitleView:(nullable UIView *)subtitleView
+ largeSubtitleView:(nullable UIView *)largeSubtitleView
+ forController:(RNSStackScreenController *)controller
{
- _lastHeaderData = data;
+ UINavigationItem *navItem = controller.navigationItem;
- RNSStackScreenController *screenController = [self requireScreenController];
- [_navigationItemCoordinator applyConfiguration:data forController:screenController];
+#if !TARGET_OS_TV
+ navItem.leftItemsSupplementBackButton = YES;
+#endif // !TARGET_OS_TV
- RNSStackNavigationController *navController = [self getNavigationController];
- if (navController != nil) {
- [navController.navigationBarCoordinator applyConfiguration:data forNavigationController:navController animated:YES];
+ [navItem setLeftBarButtonItems:[_leadingBarButtonItems copy] animated:YES];
+ [navItem setRightBarButtonItems:[_trailingBarButtonItems copy] animated:YES];
+
+ navItem.titleView = titleView;
+
+#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
+ if (@available(iOS 26.0, *)) {
+ navItem.subtitleView = subtitleView;
+ navItem.largeSubtitleView = largeSubtitleView;
}
+#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
}
-- (void)clearHeaderConfiguration
+- (UIBarButtonItem *)buildBarButtonItemForItem:(id)item
{
- [self submitHeaderData:[RNSStackHeaderData empty]];
+ UIBarButtonItem *barButtonItem = [RNSStackHeaderContentFactory barButtonItemForHeaderItem:item
+ withFrameChangeDelegate:_frameChangeDelegate
+ withHeaderEventsDelegate:_eventsDelegate];
+
+ if (item.menu != nil && item.itemId != nil) {
+ RNSStackHeaderMenuToggleStateTracker *tracker = [_trackerRegistry trackerForItemId:item.itemId];
+ __weak auto weakSelf = self;
+ NSString *capturedItemId = item.itemId;
+ [RNSStackHeaderMenuCoordinator applyMenu:item.menu
+ toBarButtonItem:barButtonItem
+ withHeaderEventsDelegate:_eventsDelegate
+ stateTracker:tracker
+ menuToggleCallback:^{
+ [weakSelf reapplyMenuForItemWithId:capturedItemId];
+ }];
+ }
+
+ if (item.itemId != nil) {
+ _barButtonItemsByItemId[item.itemId] = barButtonItem;
+ }
+
+ return barButtonItem;
}
@end