Skip to content

Commit 9a4fbaa

Browse files
kkafarCopilot
andauthored
feat(iOS, Tabs): Add proper support for moreNavigationController on iOS (#3785)
## Description Adds first-class support for UIKit's `moreNavigationController` — the overflow tab that UIKit automatically creates when a `UITabBarController` has more than 5 tabs. Previously the library had no awareness of this controller, which led to crashes and broken state when the user tapped the "More" tab or when JS attempted to navigate to it. This PR follows the RFC-1028 state model introduced in #3781, extending it to cover the `moreNavigationController` case. Important thing to note from this PR is that it introduces a special `rnscreens_moreNavigationController` *screenKey*. It is a reserved key, that can not be used by the user. ## Changes ### Native (iOS) - Introduced `kMoreNavigationControllerScreenKey` (`"moreNavigationController"`) as the reserved key identifying the more navigation controller across native and JS state. - Updated `updateSelectedViewController` to detect when the pending operation targets `moreNavigationController` and handle it separately from regular tabs. If the controller is not present in the tab bar at the time of the request (e.g. iPad sidebar/top bar mode), the operation is silently dropped — a rejection event will be added in a follow-up. - Fixed a crash in `tabBarController:shouldSelectViewController:` and `tabBarController:didSelectViewController:` — previously these methods unsafely cast `UINavigationController` (the more controller) to `RNSTabsScreenViewController`. - Removed the buggy `shouldAllowMoreControllerSelection` method, which always returned `YES` due to a logic error (`!expr ?: YES`). - Updated `updateSelectedViewControllerTo:` to accept an explicit `screenKey` parameter instead of deriving it from the view controller, eliminating a potential nil-key crash. - When JS programmatically navigates to `moreNavigationController`, the controller's stack is popped to root (animated only if the more tab is already selected), so the user always sees the full list of overflow tabs rather than whatever was previously on the navigation stack. User-driven taps leave the stack untouched. - `RNSTabBarController` now conforms to `UINavigationControllerDelegate` and sets itself as delegate of `moreNavigationController` when it becomes active. This allows tracking push/pop navigation within the more list — when the user drills into a screen from the more list, the state is updated accordingly. - Aligned navigation bar suppression between user-driven and JS-driven flows (the reason for suppression is still under investigation — likely to hide the Edit button — tracked with a `TODO`). - Added utility helpers guarded by `RNS_MORE_NAVIGATION_CONTROLLER_AVAILABLE`: `screenKeyForSelectedViewController`, `canHaveMoreNavigationController`, `isSelectedViewControllerTheMoreNavigationController`, `isMoreNavigationControllerPresentInTabBar`, `isMoreNavigationControllerTabBarItemSelected`, `isMoreNavigationControllerRequestedByOperation:`, `popToRootInMoreNavigationControllerIfNeededAnimated:`, `setupMoreNavigationControllerDelegateIfNeeded`, `disableNavigationBarInMoreNavigationController`. ### JS / TypeScript - `TabsContainer`: Added a guard that throws if any route config uses `"moreNavigationController"` as its name, as that key is now reserved. - `reducer`: The `"not found"` error is bypassed for `moreNavigationController` key, allowing it to flow through the state machine as a valid route. - `reducer`: Fixed provenance calculation for `change-tab` action: next provenance is now `max(suggestedState.provenance, confirmedState.provenance) + 1` instead of `confirmedState.provenance + 1`. This prevents a rejected update (e.g. more controller not in tab bar) from producing an identical provenance on retry, which would silently block re-navigation. ## Visual documentation > [!note] > When watching this video, please pay attention to the fact that *programmatic* navigation to the `moreNavigationController` always navigates to the ROOT (more selection list) of that view controller. While user-driven navigation preserves what's already on the stack of the `moreNavigationController`. This is intended. Programmer can always navigate to any given screen by requesting it by the key. https://github.com/user-attachments/assets/5bf979ba-92a1-4e18-af4c-5f2aeef809ad ## Test plan - Added `TestTabsMoreNavigationController` scenario in `apps/src/tests/single-feature-tests/tabs/`. - The scenario renders more than 5 tabs to trigger the "More" tab and covers JS-driven navigation to `moreNavigationController` as well as user-driven selection. ## Checklist - [x] Included code example that can be used to test this change. - [ ] Updated / created local changelog entries in relevant test files. - [ ] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] For API changes, updated relevant public types. - [ ] Ensured that CI passes --------- Co-authored-by: Copilot <[email protected]>
1 parent a68e005 commit 9a4fbaa

9 files changed

Lines changed: 378 additions & 57 deletions

File tree

apps/src/shared/gamma/containers/tabs/TabsContainer.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import React from 'react';
22
import { I18nManager, Platform, type NativeSyntheticEvent } from 'react-native';
33
import {
4+
SCREEN_KEY_MORE_NAV_CTRL,
45
type TabChangeEvent,
56
Tabs,
67
type TabsHostNavState,
78
} from 'react-native-screens';
8-
import SafeAreaView from '../../../../../../src/components/safe-area/SafeAreaView';
9-
import type { SafeAreaViewProps } from '../../../../../../src/components/safe-area/SafeAreaView.types';
9+
import { SafeAreaView, type SafeAreaViewProps } from 'react-native-screens/experimental'
1010
import type {
1111
ChangeTabMethod,
1212
TabRoute,
@@ -106,8 +106,7 @@ export function TabsContainer(props: TabsContainerProps) {
106106
route.routeKey === tabsNavState.suggestedState.selectedRouteKey;
107107

108108
RNSLog.info(
109-
`TabsContainer map to component -> ${route.routeKey} ${
110-
isSelected ? '(selected)' : ''
109+
`TabsContainer map to component -> ${route.routeKey} ${isSelected ? '(selected)' : ''
111110
}`,
112111
);
113112

@@ -200,7 +199,15 @@ function useSanitizeRouteConfigs(routeConfigs: TabRouteConfig[]) {
200199
return names.length === new Set(names).size;
201200
}, [routeConfigs]);
202201

202+
const noNameUsesReservedRouteKey = React.useMemo(() => {
203+
return routeConfigs.every(c => c.name !== SCREEN_KEY_MORE_NAV_CTRL);
204+
}, [routeConfigs]);
205+
203206
if (!areNamesUnique) {
204207
throw new Error('[Tabs] All tabs must have unique names');
205208
}
209+
210+
if (!noNameUsesReservedRouteKey) {
211+
throw new Error(`[Tabs] Tab name "${SCREEN_KEY_MORE_NAV_CTRL}" is reserved and can not be used`);
212+
}
206213
}

apps/src/shared/gamma/containers/tabs/reducer.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Platform } from 'react-native';
12
import type {
23
TabRoute,
34
TabRouteConfig,
@@ -8,6 +9,7 @@ import type {
89
TabsNavigationActionNativeChangeTab,
910
TabsNavigationActionSetOptions,
1011
} from './TabsContainer.types';
12+
import { SCREEN_KEY_MORE_NAV_CTRL } from 'react-native-screens';
1113

1214
const NOT_FOUND_INDEX = -1;
1315

@@ -58,7 +60,7 @@ function tabsActionChangeTabHandler(
5860
r => r.routeKey === action.routeKey,
5961
);
6062

61-
if (routeIndex === NOT_FOUND_INDEX) {
63+
if (routeIndex === NOT_FOUND_INDEX && !doesRouteKeyPointToMoreNavigationController(action.routeKey)) {
6264
console.error(
6365
`[Tabs] change-tab: route with key "${action.routeKey}" not found in state. Ignoring.`,
6466
);
@@ -71,7 +73,7 @@ function tabsActionChangeTabHandler(
7173

7274
return navStateWithSuggestedState(state, {
7375
selectedRouteKey: action.routeKey,
74-
provenance: state.confirmedState.provenance + 1, // suggested? What about update stacking before we receive confirmation?
76+
provenance: Math.max(state.suggestedState.provenance, state.confirmedState.provenance) + 1,
7577
});
7678
}
7779

@@ -83,7 +85,7 @@ function tabsActionNativeChangeTabHandler(
8385
r => r.routeKey === action.routeKey,
8486
);
8587

86-
if (routeIndex === NOT_FOUND_INDEX) {
88+
if (routeIndex === NOT_FOUND_INDEX && !doesRouteKeyPointToMoreNavigationController(action.routeKey)) {
8789
console.error(
8890
`[Tabs] change-tab: route with key "${action.routeKey}" not found in state. Ignoring.`,
8991
);
@@ -102,7 +104,6 @@ function tabsActionNativeChangeTabHandler(
102104
return state;
103105
}
104106

105-
// What about aligning suggestedState here?
106107
return navStateWithConfirmedState(state, {
107108
selectedRouteKey: action.routeKey,
108109
provenance: action.nativeEvent.provenance,
@@ -206,3 +207,8 @@ function navStateWithConfirmedState(
206207
suggestedState: state.suggestedState,
207208
};
208209
}
210+
211+
function doesRouteKeyPointToMoreNavigationController(routeKey: string): boolean {
212+
return Platform.OS === 'ios' && routeKey === SCREEN_KEY_MORE_NAV_CTRL;
213+
}
214+

apps/src/tests/single-feature-tests/tabs/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import TestTabsColorScheme from './test-tabs-color-scheme';
99
import TestTabsLayoutDirection from './test-tabs-layout-direction';
1010
import TestTabsIMEInsets from './test-tabs-ime-insets';
1111
import TestTabsSimpleNav from './test-tabs-simple-nav';
12+
import TestTabsMoreNavigationController from './test-tabs-more-navigation-controller';
1213

1314
const scenarios = {
1415
BottomAccessoryScenario,
@@ -20,6 +21,7 @@ const scenarios = {
2021
TestTabsLayoutDirection,
2122
TestTabsIMEInsets,
2223
TestTabsSimpleNav,
24+
TestTabsMoreNavigationController,
2325
};
2426

2527
const TabsScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import React from 'react';
2+
import type { Scenario } from '../../shared/helpers';
3+
import { Button, Text, View } from 'react-native';
4+
import {
5+
TabsContainer,
6+
type TabRouteConfig,
7+
DEFAULT_TAB_ROUTE_OPTIONS,
8+
useTabsNavigationContext,
9+
} from '../../../shared/gamma/containers/tabs';
10+
import { CenteredLayoutView } from '../../../shared/CenteredLayoutView';
11+
import { SCREEN_KEY_MORE_NAV_CTRL } from 'react-native-screens';
12+
13+
const SCENARIO: Scenario = {
14+
name: 'More navigation controller',
15+
key: 'test-tabs-more-navigation-controller',
16+
details: 'Test navigation and interactions with "More Naviagation Controller"',
17+
platforms: ['ios'],
18+
AppComponent: App,
19+
};
20+
21+
export default SCENARIO;
22+
23+
function ContentView() {
24+
const { routeKey } = useTabsNavigationContext();
25+
return (
26+
<CenteredLayoutView>
27+
<Text style={{ fontWeight: 'bold', textAlign: 'center' }}>
28+
{routeKey}
29+
</Text>
30+
<TabsNavigationButtons />
31+
</CenteredLayoutView>
32+
);
33+
}
34+
35+
function TabsNavigationButtons() {
36+
const nav = useTabsNavigationContext();
37+
38+
return (
39+
<View>
40+
<Button title="Select First" onPress={() => nav.changeTabTo('First')} />
41+
<Button title="Select Second" onPress={() => nav.changeTabTo('Second')} />
42+
<Button title="Select Third" onPress={() => nav.changeTabTo('Third')} />
43+
<Button title="Select Fourth" onPress={() => nav.changeTabTo('Fourth')} />
44+
<Button title="Select Fifth" onPress={() => nav.changeTabTo('Fifth')} />
45+
<Button title="Select Sixth" onPress={() => nav.changeTabTo('Sixth')} />
46+
<Button title="Select MoreTab" onPress={() => nav.changeTabTo(SCREEN_KEY_MORE_NAV_CTRL)} />
47+
</View>
48+
);
49+
}
50+
51+
const ROUTE_CONFIGS: TabRouteConfig[] = [
52+
{
53+
name: 'First',
54+
Component: ContentView,
55+
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'First' },
56+
},
57+
{
58+
name: 'Second',
59+
Component: ContentView,
60+
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'Second' },
61+
},
62+
{
63+
name: 'Third',
64+
Component: ContentView,
65+
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'Third' },
66+
},
67+
{
68+
name: 'Fourth',
69+
Component: ContentView,
70+
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'Fourth' },
71+
},
72+
{
73+
name: 'Fifth',
74+
Component: ContentView,
75+
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'Fifth' },
76+
},
77+
{
78+
name: 'Sixth',
79+
Component: ContentView,
80+
options: { ...DEFAULT_TAB_ROUTE_OPTIONS, title: 'Sixth' },
81+
},
82+
];
83+
84+
export function App() {
85+
return <TabsContainer routeConfigs={ROUTE_CONFIGS} />;
86+
}

0 commit comments

Comments
 (0)