Skip to content

Commit 64c3cd2

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

15 files changed

Lines changed: 509 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: 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+
}

0 commit comments

Comments
 (0)