Skip to content

Commit e32d0d4

Browse files
committed
[RNTester][iOS] Add tab bar controller, minimally support iPhone Duo
1 parent b71d466 commit e32d0d4

13 files changed

Lines changed: 447 additions & 36 deletions
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: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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/UIView+React.h>
12+
#import <react/renderer/components/AppSpecs/ComponentDescriptors.h>
13+
#import <react/renderer/components/AppSpecs/EventEmitters.h>
14+
#import <react/renderer/components/AppSpecs/Props.h>
15+
16+
#include <algorithm>
17+
18+
using namespace facebook::react;
19+
20+
/**
21+
* A tab's view controller, which reports when the tab bar or window changes
22+
* the area its content needs to clear.
23+
*/
24+
@interface RNTesterTabViewController : UIViewController
25+
@property (nonatomic, copy) void (^safeAreaInsetsDidChange)(UIEdgeInsets insets);
26+
@end
27+
28+
@implementation RNTesterTabViewController
29+
30+
- (void)viewSafeAreaInsetsDidChange
31+
{
32+
[super viewSafeAreaInsetsDidChange];
33+
if (self.safeAreaInsetsDidChange != nil) {
34+
self.safeAreaInsetsDidChange(self.view.safeAreaInsets);
35+
}
36+
}
37+
38+
@end
39+
40+
@interface RNTesterTabsIOSComponentView () <UITabBarControllerDelegate>
41+
@end
42+
43+
@implementation RNTesterTabsIOSComponentView {
44+
UITabBarController *_tabBarController;
45+
// Holds the mounted React children, and moves into the selected tab.
46+
UIView *_containerView;
47+
std::vector<std::string> _tabKeys;
48+
UIEdgeInsets _contentInsets;
49+
}
50+
51+
+ (ComponentDescriptorProvider)componentDescriptorProvider
52+
{
53+
return concreteComponentDescriptorProvider<RNTesterTabsIOSComponentDescriptor>();
54+
}
55+
56+
// The view owns a view controller, which is not worth resetting for reuse.
57+
+ (BOOL)shouldBeRecycled
58+
{
59+
return NO;
60+
}
61+
62+
- (instancetype)initWithFrame:(CGRect)frame
63+
{
64+
if (self = [super initWithFrame:frame]) {
65+
static const auto defaultProps = std::make_shared<const RNTesterTabsIOSProps>();
66+
_props = defaultProps;
67+
68+
_containerView = [UIView new];
69+
_containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
70+
71+
_tabBarController = [UITabBarController new];
72+
_tabBarController.delegate = self;
73+
self.contentView = _tabBarController.view;
74+
}
75+
return self;
76+
}
77+
78+
- (void)didMoveToWindow
79+
{
80+
[super didMoveToWindow];
81+
if (self.window == nil) {
82+
[_tabBarController willMoveToParentViewController:nil];
83+
[_tabBarController removeFromParentViewController];
84+
return;
85+
}
86+
87+
UIViewController *parentViewController = self.reactViewController;
88+
if (parentViewController != nil && _tabBarController.parentViewController == nil) {
89+
[parentViewController addChildViewController:_tabBarController];
90+
[_tabBarController didMoveToParentViewController:parentViewController];
91+
}
92+
}
93+
94+
#pragma mark - Children
95+
96+
// Children are laid out against this view's bounds, which the tab's view shares, so they keep their
97+
// frames wherever the container moves.
98+
- (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
99+
{
100+
[_containerView insertSubview:childComponentView atIndex:index];
101+
}
102+
103+
- (void)unmountChildComponentView:(UIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
104+
{
105+
[childComponentView removeFromSuperview];
106+
}
107+
108+
#pragma mark - Props
109+
110+
- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps
111+
{
112+
const auto &oldTabsProps = static_cast<const RNTesterTabsIOSProps &>(*_props);
113+
const auto &newTabsProps = static_cast<const RNTesterTabsIOSProps &>(*props);
114+
115+
// RNTester's tabs are a constant, so they are built once, from the first props.
116+
bool tabsCreated = _tabKeys.empty();
117+
if (tabsCreated) {
118+
[self createTabs:newTabsProps.tabs];
119+
}
120+
if (tabsCreated || oldTabsProps.selectedTab != newTabsProps.selectedTab) {
121+
[self selectTab:newTabsProps.selectedTab];
122+
}
123+
if (oldTabsProps.tabBarHidden != newTabsProps.tabBarHidden) {
124+
if (@available(iOS 18.0, tvOS 18.0, *)) {
125+
[_tabBarController setTabBarHidden:newTabsProps.tabBarHidden animated:NO];
126+
} else {
127+
_tabBarController.tabBar.hidden = newTabsProps.tabBarHidden;
128+
}
129+
}
130+
131+
[super updateProps:props oldProps:oldProps];
132+
}
133+
134+
- (void)createTabs:(const std::vector<RNTesterTabsIOSTabsStruct> &)tabs
135+
{
136+
__weak RNTesterTabsIOSComponentView *weakSelf = self;
137+
NSMutableArray<UIViewController *> *viewControllers = [NSMutableArray arrayWithCapacity:tabs.size()];
138+
_tabKeys.clear();
139+
140+
for (const auto &tab : tabs) {
141+
auto *viewController = [RNTesterTabViewController new];
142+
viewController.tabBarItem =
143+
[[UITabBarItem alloc] initWithTitle:RCTNSStringFromString(tab.title)
144+
image:[UIImage systemImageNamed:RCTNSStringFromString(tab.systemImage)]
145+
tag:0];
146+
viewController.tabBarItem.accessibilityIdentifier = RCTNSStringFromString(tab.testID);
147+
viewController.safeAreaInsetsDidChange = ^(UIEdgeInsets insets) {
148+
[weakSelf tabSafeAreaInsetsDidChange:insets];
149+
};
150+
[viewControllers addObject:viewController];
151+
_tabKeys.push_back(tab.key);
152+
}
153+
154+
_tabBarController.viewControllers = viewControllers;
155+
}
156+
157+
- (void)selectTab:(const std::string &)key
158+
{
159+
auto it = std::find(_tabKeys.begin(), _tabKeys.end(), key);
160+
if (it == _tabKeys.end()) {
161+
return;
162+
}
163+
_tabBarController.selectedIndex = std::distance(_tabKeys.begin(), it);
164+
165+
UIView *tabView = _tabBarController.selectedViewController.view;
166+
_containerView.frame = tabView.bounds;
167+
[tabView addSubview:_containerView];
168+
}
169+
170+
#pragma mark - UITabBarControllerDelegate
171+
172+
- (BOOL)tabBarController:(UITabBarController *)tabBarController
173+
shouldSelectViewController:(UIViewController *)viewController
174+
{
175+
NSUInteger index = [tabBarController.viewControllers indexOfObject:viewController];
176+
if (index != NSNotFound && _eventEmitter != nullptr) {
177+
static_cast<const RNTesterTabsIOSEventEmitter &>(*_eventEmitter).onTabPress({.key = _tabKeys[index]});
178+
}
179+
// JS selects the tab by updating `selectedTab`.
180+
return NO;
181+
}
182+
183+
#pragma mark - Insets
184+
185+
- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter
186+
{
187+
[super updateEventEmitter:eventEmitter];
188+
// The insets can settle before the first event emitter arrives.
189+
UIViewController *selectedViewController = _tabBarController.selectedViewController;
190+
if (selectedViewController.isViewLoaded && selectedViewController.view.window != nil) {
191+
_contentInsets = UIEdgeInsetsZero;
192+
[self tabSafeAreaInsetsDidChange:selectedViewController.view.safeAreaInsets];
193+
}
194+
}
195+
196+
- (void)tabSafeAreaInsetsDidChange:(UIEdgeInsets)insets
197+
{
198+
// The home indicator's inset only applies beneath a tab bar along the bottom edge, which already
199+
// clears it. With the tab bar at the side, content runs to the bottom edge.
200+
CGRect tabBarFrame = _tabBarController.tabBar.frame;
201+
CGRect bounds = _tabBarController.view.bounds;
202+
if (CGRectGetWidth(tabBarFrame) < CGRectGetWidth(bounds) || CGRectGetMaxY(tabBarFrame) < CGRectGetMaxY(bounds)) {
203+
insets.bottom = 0;
204+
}
205+
// With no status bar above it, UINavigationBar still sits 24pt below the top edge, clear of the
206+
// display's corners. Content leaves the same gap, since the device reports no top inset there.
207+
if (insets.top == 0) {
208+
insets.top = 24;
209+
}
210+
if (UIEdgeInsetsEqualToEdgeInsets(insets, _contentInsets) || _eventEmitter == nullptr) {
211+
return;
212+
}
213+
_contentInsets = insets;
214+
static_cast<const RNTesterTabsIOSEventEmitter &>(*_eventEmitter)
215+
.onContentInsetsChange({
216+
.top = insets.top,
217+
.left = insets.left,
218+
.bottom = insets.bottom,
219+
.right = insets.right,
220+
});
221+
}
222+
223+
@end
224+
225+
Class<RCTComponentViewProtocol> RNTesterTabsIOSCls(void)
226+
{
227+
return RNTesterTabsIOSComponentView.class;
228+
}

‎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
}

‎packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
0EA618032BE537D3001875EF /* RNTesterBundle.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 0EA618022BE537D3001875EF /* RNTesterBundle.bundle */; };
1111
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
1212
2DDEF0101F84BF7B00DBDF73 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2DDEF00F1F84BF7B00DBDF73 /* Images.xcassets */; };
13+
381050A32103CEBAB7922F8C /* RNTesterTabsIOSComponentView.mm in Sources */ = {isa = PBXBuildFile; fileRef = BF63BFE8602F7E7F36F33FB1 /* RNTesterTabsIOSComponentView.mm */; };
1314
383889DA23A7398900D06C3E /* RCTConvert_UIColorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 383889D923A7398900D06C3E /* RCTConvert_UIColorTests.m */; };
1415
3D2AFAF51D646CF80089D1A3 /* legacy_image@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 3D2AFAF41D646CF80089D1A3 /* legacy_image@2x.png */; };
1516
3F4D148C63BBF774A25488A6 /* libPods-RNTesterIntegrationTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 93A243F0D4D5C54911E811C4 /* libPods-RNTesterIntegrationTests.a */; };
@@ -98,9 +99,11 @@
9899
8145AE05241172D900A3F8DA /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = RNTester/LaunchScreen.storyboard; sourceTree = "<group>"; };
99100
832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SwiftTest.swift; path = RNTester/SwiftTest.swift; sourceTree = "<group>"; };
100101
93A243F0D4D5C54911E811C4 /* libPods-RNTesterIntegrationTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNTesterIntegrationTests.a"; sourceTree = BUILT_PRODUCTS_DIR; };
102+
A66247115FF189C6CBAFC68C /* RNTesterTabsIOSComponentView.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = RNTesterTabsIOSComponentView.h; path = RNTester/RNTesterTabsIOSComponentView.h; sourceTree = "<group>"; };
101103
A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTNetworkTaskTests.m; sourceTree = "<group>"; };
102104
AC474BFB29BBD4A1002BDAED /* RNTester.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = RNTester.xctestplan; path = RNTester/RNTester.xctestplan; sourceTree = "<group>"; };
103105
B0E70A8A05E03E868F8703FE /* Pods-RNTesterIntegrationTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTesterIntegrationTests.release.xcconfig"; path = "Target Support Files/Pods-RNTesterIntegrationTests/Pods-RNTesterIntegrationTests.release.xcconfig"; sourceTree = "<group>"; };
106+
BF63BFE8602F7E7F36F33FB1 /* RNTesterTabsIOSComponentView.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; name = RNTesterTabsIOSComponentView.mm; path = RNTester/RNTesterTabsIOSComponentView.mm; sourceTree = "<group>"; };
104107
CA59C9994B1822826D8983F0 /* Pods-RNTester.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTester.debug.xcconfig"; path = "Target Support Files/Pods-RNTester/Pods-RNTester.debug.xcconfig"; sourceTree = "<group>"; };
105108
CD10C7A4290BD4EB0033E1ED /* RCTEventEmitterTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTEventEmitterTests.m; sourceTree = "<group>"; };
106109
D134EB89DD98253FCF879A47 /* Pods-RNTesterUnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTesterUnitTests.debug.xcconfig"; path = "Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests.debug.xcconfig"; sourceTree = "<group>"; };
@@ -220,6 +223,8 @@
220223
680759612239798500290469 /* Fabric */,
221224
272E6B3A1BEA846C001FCF37 /* NativeExampleViews */,
222225
1323F18D1C04ABAC0091BED0 /* Supporting Files */,
226+
A66247115FF189C6CBAFC68C /* RNTesterTabsIOSComponentView.h */,
227+
BF63BFE8602F7E7F36F33FB1 /* RNTesterTabsIOSComponentView.mm */,
223228
);
224229
name = RNTester;
225230
sourceTree = "<group>";
@@ -739,6 +744,7 @@
739744
832F45BB2A8A6E1F0097B4E6 /* SwiftTest.swift in Sources */,
740745
5C60EB1C226440DB0018C04F /* AppDelegate.mm in Sources */,
741746
13B07FC11A68108700A75B9A /* main.m in Sources */,
747+
381050A32103CEBAB7922F8C /* RNTesterTabsIOSComponentView.mm in Sources */,
742748
);
743749
runOnlyForDeploymentPostprocessing = 0;
744750
};

0 commit comments

Comments
 (0)