Skip to content

Commit ba89b39

Browse files
huntiemeta-codesync[bot]
authored andcommitted
Add tab bar controller, minimally support iPhone Duo (#58664)
Summary: Moves RNTester's tab bar on iOS to a native `UITabBarController`, hosted by a `<RNTesterTabsIOS>` component within the single React Native root — allowing us to leverage the platform tab bar and safe area behaviours needed to support iPhone Duo. Android is unchanged. No new capabilities are added to React Native itself. <img width="681" height="423" alt="image" src="https://github.com/user-attachments/assets/e9517bce-24b9-4ea3-affe-d22f6902733a" /> **Motivation** RNTester on iOS runs as one React Native surface that draws its own header and bottom tab bar in JS, so it cannot follow platform layouts such as iPhone Duo's, where the status bar sits in a side cutout and the tab bar moves to the side. **Necessary and intentional fork**: Host the iOS tab bar natively in an app-local Fabric component, which gives RNTester the platform tab bar and minimally supports iPhone Duo, while the app stays a single React Native root. - This avoids us from touching `<SafeAreaView>` in core at this stage, or making any hardcoded hacks to the custom tab bar — which had already drifted into buggy territory on the regular iPhone. - The tab bar component is written inline rather than taken from a library such as `react-native-screens`, to avoid introducing a dependency within core (against `main`). **Changes** - Add `<RNTesterTabsIOS>`, an app-local Fabric component wrapping a `UITabBarController` with Components, APIs and Playground; the React content moves into the selected tab and is kept within the safe area. - Keep tab selection in `RNTesterNavigationReducer`, shared with Android: native reports tab presses and the insets content should clear. - Cap the module list and example frames at 600pt wide, on all platforms. - Move Playground to the last tab, on both platforms. **⚠️ Key tradeoff** The header stays in JS, so there is no native back button or back swipe. A native header needs a view controller per navigation level, which is left out of scope. **Notes** Out-of-tree platforms: RNTester stays a single React Native surface. `<RNTesterTabsIOS>` renders only on iOS, so other platforms keep the JS header and tab bar. Changelog: [Internal] Pull Request resolved: #58664 Test Plan: ```sh xcodebuild -workspace packages/rn-tester/RNTesterPods.xcworkspace -scheme RNTester -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 18 Pro' build ``` - ✅ RNTester builds for iOS Simulator and runs on iPhone 18 Pro (iOS 27.0) and iPhone Duo (iOS 27.1). - ✅ Tabs switch, including by the `apis-tab` test ID the Maestro flow uses; modules and examples push with native back and the docs button; Recently Viewed updates on the list. - ✅ Deep links open the target on the Components tab, warm (`rntester://example/Switch`) and at cold launch (`rntester://example/Image`). - ✅ iPhone Duo: each screen stays within the safe area, folded and unfolded; the list and example frames cap at 600pt when unfolded. - ✅ Android: RNTester runs on an emulator with its single surface, and the tabs read Components, APIs, Playground. - ⚠️ Not run: Maestro. | iPhone 18 Pro | iPhone 18 Pro, module | iPhone Duo, folded | Android | |---|---|---|---| | <img src="https://github.com/user-attachments/assets/e1fdd188-c0c7-42d9-b857-632f80d4b2e4" width="260" /> | <img src="https://github.com/user-attachments/assets/5b3a35ed-5d45-49aa-a19a-624772c1743f" width="260" /> | <img src="https://github.com/user-attachments/assets/292cfe7a-b660-45da-aa76-31ee32b4c608" width="300" /> | <img src="https://github.com/user-attachments/assets/7cf2cbc1-e0c0-45f2-a50d-241559cc7d86" width="260" /> | | Native tab bar and header; Playground moves to the last tab | Module screen with JS back and docs buttons | Content within the safe area, clear of the side cutout and tab bar | **(Unchanged)** | | iPhone Duo, unfolded | |---| | <img src="https://github.com/user-attachments/assets/f8227bc5-b48b-4205-ace3-136eabe4930a" width="640" /> | | Max content width applied | <details> <summary>V1 screenshots (outdated)</summary> | iPhone 18 Pro | iPhone 18 Pro, module | iPhone Duo, folded | Android | |---|---|---|---| | <img src="https://github.com/user-attachments/assets/e1fdd188-c0c7-42d9-b857-632f80d4b2e4" width="260" /> | <img src="https://github.com/user-attachments/assets/6cb69ded-2efb-498a-8d27-f950b881f608" width="260" /> | <img src="https://github.com/user-attachments/assets/743ab749-4b86-4c52-80e7-d399ee6ab74e" width="300" /> | <img src="https://github.com/user-attachments/assets/7cf2cbc1-e0c0-45f2-a50d-241559cc7d86" width="260" /> | | Native tab bar and header; Playground moves to the last tab | Pushed module screen with native back and docs buttons | Content within the safe area, clear of the side cutout and tab bar | **(Unchanged)** | | iPhone Duo, unfolded | |---| | <img src="https://github.com/user-attachments/assets/cc7e6624-a812-401c-9f24-2c6e744ac782" width="640" /> | | Max content width applied | </details> Reviewed By: cipolleschi Differential Revision: D123669749 Pulled By: javache fbshipit-source-id: 5e738eb2893b4548d0fa98a77d369a7c2718ca0c
1 parent bf1258e commit ba89b39

15 files changed

Lines changed: 516 additions & 37 deletions

‎packages/rn-tester/Podfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def pods(target_name, options = {})
5151
# RNTester native modules and components
5252
pod 'ScreenshotManager', :path => "NativeModuleExample"
5353
pod 'MyNativeView', :path => "NativeComponentExample"
54+
pod 'RNTesterTabsIOS', :path => "RNTesterTabsIOS"
5455
pod 'NativeCxxModuleExample', :path => "NativeCxxModuleExample"
5556
end
5657

‎packages/rn-tester/Podfile.lock‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2645,6 +2645,35 @@ PODS:
26452645
- React-perflogger (= 1000.0.0)
26462646
- React-utils (= 1000.0.0)
26472647
- SocketRocket
2648+
- RNTesterTabsIOS (0.87.0-main):
2649+
- boost
2650+
- DoubleConversion
2651+
- fast_float
2652+
- fmt
2653+
- glog
2654+
- hermes-engine
2655+
- RCT-Folly
2656+
- RCT-Folly/Fabric
2657+
- RCTRequired
2658+
- RCTTypeSafety
2659+
- React-bridging
2660+
- React-Core
2661+
- React-cxxstableapi
2662+
- React-debug
2663+
- React-Fabric
2664+
- React-featureflags
2665+
- React-graphics
2666+
- React-ImageManager
2667+
- React-jsi
2668+
- React-NativeModulesApple
2669+
- React-RCTFabric
2670+
- React-renderercss
2671+
- React-rendererdebug
2672+
- React-utils
2673+
- ReactCodegen
2674+
- ReactCommon/turbomodule/core
2675+
- SocketRocket
2676+
- Yoga
26482677
- ScreenshotManager (0.87.0-main):
26492678
- boost
26502679
- DoubleConversion
@@ -2765,6 +2794,7 @@ DEPENDENCIES:
27652794
- ReactCodegen (from `build/generated/ios/ReactCodegen`)
27662795
- ReactCommon-Samples (from `../react-native/ReactCommon/react/nativemodule/samples`)
27672796
- ReactCommon/turbomodule/core (from `../react-native/ReactCommon`)
2797+
- RNTesterTabsIOS (from `RNTesterTabsIOS`)
27682798
- ScreenshotManager (from `NativeModuleExample`)
27692799
- SocketRocket (~> 0.7.1)
27702800
- Yoga (from `../react-native/ReactCommon/yoga`)
@@ -2948,6 +2978,8 @@ EXTERNAL SOURCES:
29482978
:path: "../react-native/ReactCommon"
29492979
ReactCommon-Samples:
29502980
:path: "../react-native/ReactCommon/react/nativemodule/samples"
2981+
RNTesterTabsIOS:
2982+
:path: RNTesterTabsIOS
29512983
ScreenshotManager:
29522984
:path: NativeModuleExample
29532985
Yoga:
@@ -3041,10 +3073,11 @@ SPEC CHECKSUMS:
30413073
ReactCodegen: 19447115b4e0b8eec131ae256e7000a9a0910dc4
30423074
ReactCommon: 1376b66d6ef10d128c76c2d59fef49e706832ca6
30433075
ReactCommon-Samples: b74ec150a8210d62be6cc899d28a24e7b795edd3
3076+
RNTesterTabsIOS: a0e709192adb16ce7cd9a2485d958ee5d5b12fab
30443077
ScreenshotManager: c005d764f11c09fb4a5640a3157024dddce859e9
30453078
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
30463079
Yoga: a9afa834491f5b80bc3b10d2d91e8bfb15ab1100
30473080

3048-
PODFILE CHECKSUM: 59f1e7d58cffbfafbeb3df31151743dd4c9a52e8
3081+
PODFILE CHECKSUM: abfcf138a255569f3324d5118cecdcbc64bdb8c6
30493082

30503083
COCOAPODS: 1.16.2

‎packages/rn-tester/RNTester/SceneDelegate.mm‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ - (NSURL *)bundleURL
124124
if (!dict[@"RNTMyNativeView"]) {
125125
dict[@"RNTMyNativeView"] = NSClassFromString(@"RNTMyNativeViewComponentView");
126126
}
127+
if (!dict[@"RNTesterTabsIOS"]) {
128+
dict[@"RNTesterTabsIOS"] = NSClassFromString(@"RNTesterTabsIOSComponentView");
129+
}
127130
if (!dict[@"SampleNativeComponent"]) {
128131
dict[@"SampleNativeComponent"] = NSClassFromString(@"RCTSampleNativeComponentComponentView");
129132
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
require "json"
7+
8+
package = JSON.parse(File.read(File.join(__dir__, "../" "package.json")))
9+
10+
Pod::Spec.new do |s|
11+
s.name = "RNTesterTabsIOS"
12+
s.version = package["version"]
13+
s.summary = package["description"]
14+
s.description = "RNTester's native tab bar"
15+
s.homepage = "https://github.com/react/react-native"
16+
s.license = "MIT"
17+
s.platforms = min_supported_versions
18+
s.author = "Meta Platforms, Inc. and its affiliates"
19+
s.source = { :git => "https://github.com/react/react-native.git", :tag => "#{s.version}" }
20+
s.pod_target_xcconfig = {
21+
"HEADER_SEARCH_PATHS" => "\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCodegen/ReactCodegen.framework/Headers\"",
22+
"CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard()
23+
}
24+
25+
s.source_files = "ios/**/*.{h,mm}"
26+
s.requires_arc = true
27+
28+
install_modules_dependencies(s)
29+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import <React/RCTViewComponentView.h>
9+
10+
NS_ASSUME_NONNULL_BEGIN
11+
12+
/**
13+
* Hosts RNTester's tabs in a UITabBarController, so the tab bar adapts to the
14+
* device (a bottom bar, or a sidebar on wide layouts) while the app stays a
15+
* single React Native root.
16+
*
17+
* The React children are the selected tab's content: they are mounted into a
18+
* single container view, which moves into the selected tab's view controller.
19+
*/
20+
@interface RNTesterTabsIOSComponentView : RCTViewComponentView
21+
@end
22+
23+
NS_ASSUME_NONNULL_END
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#import "RNTesterTabsIOSComponentView.h"
9+
10+
#import <React/RCTConversions.h>
11+
#import <React/RCTLog.h>
12+
#import <React/UIView+React.h>
13+
#import <react/renderer/components/AppSpecs/ComponentDescriptors.h>
14+
#import <react/renderer/components/AppSpecs/EventEmitters.h>
15+
#import <react/renderer/components/AppSpecs/Props.h>
16+
17+
using namespace facebook::react;
18+
19+
/**
20+
* A tab's view controller, which reports when the tab bar or window changes
21+
* the area its content needs to clear.
22+
*/
23+
@interface RNTesterTabViewController : UIViewController
24+
@property (nonatomic, copy) void (^safeAreaInsetsDidChange)(UIEdgeInsets insets);
25+
@end
26+
27+
@implementation RNTesterTabViewController
28+
29+
- (void)viewSafeAreaInsetsDidChange
30+
{
31+
[super viewSafeAreaInsetsDidChange];
32+
if (self.safeAreaInsetsDidChange != nil) {
33+
self.safeAreaInsetsDidChange(self.view.safeAreaInsets);
34+
}
35+
}
36+
37+
@end
38+
39+
@interface RNTesterTabsIOSComponentView () <UITabBarControllerDelegate>
40+
@end
41+
42+
@implementation RNTesterTabsIOSComponentView {
43+
UITabBarController *_tabBarController;
44+
// Holds the mounted React children, and moves into the selected tab.
45+
UIView *_containerView;
46+
NSArray<NSString *> *_tabKeys;
47+
UIEdgeInsets _contentInsets;
48+
}
49+
50+
+ (ComponentDescriptorProvider)componentDescriptorProvider
51+
{
52+
return concreteComponentDescriptorProvider<RNTesterTabsIOSComponentDescriptor>();
53+
}
54+
55+
// The view owns a view controller, which is not worth resetting for reuse.
56+
+ (BOOL)shouldBeRecycled
57+
{
58+
return NO;
59+
}
60+
61+
- (instancetype)initWithFrame:(CGRect)frame
62+
{
63+
if (self = [super initWithFrame:frame]) {
64+
static const auto defaultProps = std::make_shared<const RNTesterTabsIOSProps>();
65+
_props = defaultProps;
66+
67+
_containerView = [UIView new];
68+
_containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
69+
70+
_tabBarController = [UITabBarController new];
71+
_tabBarController.delegate = self;
72+
self.contentView = _tabBarController.view;
73+
}
74+
return self;
75+
}
76+
77+
- (void)didMoveToWindow
78+
{
79+
[super didMoveToWindow];
80+
if (self.window == nil) {
81+
[_tabBarController willMoveToParentViewController:nil];
82+
[_tabBarController removeFromParentViewController];
83+
return;
84+
}
85+
86+
UIViewController *parentViewController = self.reactViewController;
87+
if (parentViewController != nil && _tabBarController.parentViewController == nil) {
88+
[parentViewController addChildViewController:_tabBarController];
89+
[_tabBarController didMoveToParentViewController:parentViewController];
90+
}
91+
}
92+
93+
#pragma mark - Children
94+
95+
// Children are laid out against this view's bounds, which the tab's view shares, so they keep their
96+
// frames wherever the container moves.
97+
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
98+
{
99+
[_containerView insertSubview:childComponentView atIndex:index];
100+
}
101+
102+
- (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
103+
{
104+
[childComponentView removeFromSuperview];
105+
}
106+
107+
#pragma mark - Props
108+
109+
- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps
110+
{
111+
const auto &oldTabsProps = static_cast<const RNTesterTabsIOSProps &>(*_props);
112+
const auto &newTabsProps = static_cast<const RNTesterTabsIOSProps &>(*props);
113+
114+
// RNTester's tabs are a constant, so they are built once, from the first props.
115+
bool tabsCreated = _tabKeys == nil;
116+
if (tabsCreated) {
117+
[self createTabs:newTabsProps.tabs];
118+
} else if (![[self keysForTabs:newTabsProps.tabs] isEqualToArray:_tabKeys]) {
119+
RCTLogError(@"RNTesterTabsIOS builds its tabs once, and does not support changing them after mount.");
120+
}
121+
if (tabsCreated || oldTabsProps.selectedTab != newTabsProps.selectedTab) {
122+
[self selectTab:newTabsProps.selectedTab];
123+
}
124+
if (oldTabsProps.tabBarHidden != newTabsProps.tabBarHidden) {
125+
if (@available(iOS 18.0, tvOS 18.0, *)) {
126+
[_tabBarController setTabBarHidden:newTabsProps.tabBarHidden animated:NO];
127+
} else {
128+
_tabBarController.tabBar.hidden = newTabsProps.tabBarHidden;
129+
}
130+
}
131+
132+
[super updateProps:props oldProps:oldProps];
133+
}
134+
135+
- (NSArray<NSString *> *)keysForTabs:(const std::vector<RNTesterTabsIOSTabsStruct> &)tabs
136+
{
137+
NSMutableArray<NSString *> *keys = [NSMutableArray arrayWithCapacity:tabs.size()];
138+
for (const auto &tab : tabs) {
139+
[keys addObject:RCTNSStringFromString(tab.key)];
140+
}
141+
return keys;
142+
}
143+
144+
- (void)createTabs:(const std::vector<RNTesterTabsIOSTabsStruct> &)tabs
145+
{
146+
__weak RNTesterTabsIOSComponentView *weakSelf = self;
147+
NSMutableArray<UIViewController *> *viewControllers = [NSMutableArray arrayWithCapacity:tabs.size()];
148+
149+
for (const auto &tab : tabs) {
150+
auto *viewController = [RNTesterTabViewController new];
151+
viewController.tabBarItem =
152+
[[UITabBarItem alloc] initWithTitle:RCTNSStringFromString(tab.title)
153+
image:[UIImage systemImageNamed:RCTNSStringFromString(tab.systemImage)]
154+
tag:0];
155+
viewController.tabBarItem.accessibilityIdentifier = RCTNSStringFromString(tab.testID);
156+
viewController.safeAreaInsetsDidChange = ^(UIEdgeInsets insets) {
157+
[weakSelf tabSafeAreaInsetsDidChange:insets];
158+
};
159+
[viewControllers addObject:viewController];
160+
}
161+
162+
_tabKeys = [self keysForTabs:tabs];
163+
_tabBarController.viewControllers = viewControllers;
164+
}
165+
166+
- (void)selectTab:(const std::string &)key
167+
{
168+
NSUInteger index = [_tabKeys indexOfObject:RCTNSStringFromString(key)];
169+
if (index == NSNotFound) {
170+
return;
171+
}
172+
_tabBarController.selectedIndex = index;
173+
174+
UIView *tabView = _tabBarController.selectedViewController.view;
175+
_containerView.frame = tabView.bounds;
176+
[tabView addSubview:_containerView];
177+
}
178+
179+
#pragma mark - UITabBarControllerDelegate
180+
181+
- (BOOL)tabBarController:(UITabBarController *)tabBarController
182+
shouldSelectViewController:(UIViewController *)viewController
183+
{
184+
NSUInteger index = [tabBarController.viewControllers indexOfObject:viewController];
185+
if (index != NSNotFound && _eventEmitter != nullptr) {
186+
static_cast<const RNTesterTabsIOSEventEmitter &>(*_eventEmitter)
187+
.onTabPress({.key = RCTStringFromNSString(_tabKeys[index])});
188+
}
189+
// JS selects the tab by updating `selectedTab`.
190+
return NO;
191+
}
192+
193+
#pragma mark - Insets
194+
195+
- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter
196+
{
197+
[super updateEventEmitter:eventEmitter];
198+
// The insets can settle before the first event emitter arrives.
199+
UIViewController *selectedViewController = _tabBarController.selectedViewController;
200+
if (selectedViewController.isViewLoaded && selectedViewController.view.window != nil) {
201+
_contentInsets = UIEdgeInsetsZero;
202+
[self tabSafeAreaInsetsDidChange:selectedViewController.view.safeAreaInsets];
203+
}
204+
}
205+
206+
- (void)tabSafeAreaInsetsDidChange:(UIEdgeInsets)insets
207+
{
208+
// The home indicator's inset only applies beneath a tab bar along the bottom edge, which already
209+
// clears it. With the tab bar at the side, content runs to the bottom edge.
210+
CGRect tabBarFrame = _tabBarController.tabBar.frame;
211+
CGRect bounds = _tabBarController.view.bounds;
212+
if (CGRectGetWidth(tabBarFrame) < CGRectGetWidth(bounds) || CGRectGetMaxY(tabBarFrame) < CGRectGetMaxY(bounds)) {
213+
insets.bottom = 0;
214+
}
215+
// A window with no status bar along the top, such as the iPhone Duo's, reports no top inset. Keep
216+
// content a small distance from the display's rounded top edge.
217+
if (insets.top == 0) {
218+
insets.top = 16;
219+
}
220+
if (UIEdgeInsetsEqualToEdgeInsets(insets, _contentInsets) || _eventEmitter == nullptr) {
221+
return;
222+
}
223+
_contentInsets = insets;
224+
static_cast<const RNTesterTabsIOSEventEmitter &>(*_eventEmitter)
225+
.onContentInsetsChange({
226+
.top = insets.top,
227+
.left = insets.left,
228+
.bottom = insets.bottom,
229+
.right = insets.right,
230+
});
231+
}
232+
233+
@end
234+
235+
Class<RCTComponentViewProtocol> RNTesterTabsIOSCls(void)
236+
{
237+
return RNTesterTabsIOSComponentView.class;
238+
}

0 commit comments

Comments
 (0)