Skip to content

Commit 8e1f4ce

Browse files
committed
[RNTester][iOS] Add tab bar controller, minimally support iPhone Duo
1 parent e8f50cd commit 8e1f4ce

17 files changed

Lines changed: 751 additions & 85 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 "RNTesterTabBarController.h"
9+
10+
#import <React/RCTBridgeModule.h>
11+
#import <React/RCTLog.h>
12+
#import <React/RCTUtils.h>
13+
14+
/**
15+
* Lets JS push screens onto the native navigator, see js/utils/RNTesterNavigator.js.
16+
*/
17+
@interface RNTesterNavigator : NSObject <RCTBridgeModule>
18+
@end
19+
20+
@implementation RNTesterNavigator
21+
22+
RCT_EXPORT_MODULE()
23+
24+
- (dispatch_queue_t)methodQueue
25+
{
26+
return dispatch_get_main_queue();
27+
}
28+
29+
+ (BOOL)requiresMainQueueSetup
30+
{
31+
return NO;
32+
}
33+
34+
RCT_EXPORT_METHOD(push : (NSDictionary *)route)
35+
{
36+
[[self tabBarController] pushRoute:route];
37+
}
38+
39+
RCT_EXPORT_METHOD(pushFromDeepLink : (NSDictionary *)route)
40+
{
41+
[[self tabBarController] pushRouteFromDeepLink:route];
42+
}
43+
44+
- (RNTesterTabBarController *)tabBarController
45+
{
46+
UIViewController *rootViewController = RCTKeyWindow().rootViewController;
47+
if (![rootViewController isKindOfClass:[RNTesterTabBarController class]]) {
48+
RCTLogError(@"RNTesterNavigator requires an RNTesterTabBarController as the root view controller");
49+
return nil;
50+
}
51+
return (RNTesterTabBarController *)rootViewController;
52+
}
53+
54+
@end
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 <UIKit/UIKit.h>
9+
10+
@class RCTReactNativeFactory;
11+
12+
NS_ASSUME_NONNULL_BEGIN
13+
14+
/**
15+
* The native navigator for RNTester: a tab for each of Components, Playground,
16+
* and APIs, each a navigation stack of React Native surfaces.
17+
*/
18+
@interface RNTesterTabBarController : UITabBarController
19+
20+
- (instancetype)initWithReactNativeFactory:(RCTReactNativeFactory *)reactNativeFactory
21+
initialProperties:(NSDictionary *)initialProperties
22+
launchOptions:(nullable NSDictionary *)launchOptions NS_DESIGNATED_INITIALIZER;
23+
24+
- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil
25+
bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE;
26+
- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;
27+
28+
/**
29+
* Pushes the screen for a route onto the selected tab's stack.
30+
*
31+
* A route has a `moduleKey` and `title`, and optionally an `exampleKey`, a
32+
* `documentationURL`, and `hidesChrome` to hide the navigation and tab bars.
33+
*/
34+
- (void)pushRoute:(NSDictionary *)route;
35+
36+
/**
37+
* Selects the Components tab, pops it to its root, and pushes the route.
38+
*/
39+
- (void)pushRouteFromDeepLink:(NSDictionary *)route;
40+
41+
@end
42+
43+
NS_ASSUME_NONNULL_END
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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 "RNTesterTabBarController.h"
9+
10+
#if __has_include(<React/RCTReactNativeFactory.h>)
11+
#import <React/RCTReactNativeFactory.h>
12+
#else
13+
#import <RCTReactNativeFactory.h>
14+
#endif
15+
16+
static NSString *const kScreenModuleName = @"RNTesterScreen";
17+
18+
@interface RNTesterScreenViewController : UIViewController
19+
@end
20+
21+
@implementation RNTesterScreenViewController {
22+
RCTReactNativeFactory *_reactNativeFactory;
23+
NSDictionary *_initialProperties;
24+
NSDictionary *_launchOptions;
25+
NSURL *_documentationURL;
26+
BOOL _hidesChrome;
27+
NSLayoutConstraint *_rootViewBottomConstraint;
28+
}
29+
30+
- (instancetype)initWithReactNativeFactory:(RCTReactNativeFactory *)reactNativeFactory
31+
title:(NSString *)title
32+
initialProperties:(NSDictionary *)initialProperties
33+
launchOptions:(NSDictionary *)launchOptions
34+
documentationURL:(NSURL *)documentationURL
35+
hidesChrome:(BOOL)hidesChrome
36+
{
37+
if (self = [super initWithNibName:nil bundle:nil]) {
38+
_reactNativeFactory = reactNativeFactory;
39+
_initialProperties = initialProperties;
40+
_launchOptions = launchOptions;
41+
_documentationURL = documentationURL;
42+
_hidesChrome = hidesChrome;
43+
44+
self.title = title;
45+
self.hidesBottomBarWhenPushed = hidesChrome;
46+
47+
if (documentationURL != nil) {
48+
UIBarButtonItem *documentationItem =
49+
[[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"book"]
50+
style:UIBarButtonItemStylePlain
51+
target:self
52+
action:@selector(openDocumentation)];
53+
documentationItem.accessibilityLabel = @"Documentation";
54+
self.navigationItem.rightBarButtonItem = documentationItem;
55+
}
56+
}
57+
return self;
58+
}
59+
60+
- (void)viewDidLoad
61+
{
62+
[super viewDidLoad];
63+
// Matches the screen's GroupedBackgroundColor, showing through outside the safe area.
64+
self.view.backgroundColor = [UIColor systemGroupedBackgroundColor];
65+
66+
// Draws the header's background behind the transparent navigation bar, spanning only the safe area's
67+
// width so it stays clear of side cutouts and a side tab bar.
68+
UIView *headerBackgroundView = [UIView new];
69+
headerBackgroundView.backgroundColor = [UIColor systemBackgroundColor];
70+
headerBackgroundView.translatesAutoresizingMaskIntoConstraints = NO;
71+
[self.view addSubview:headerBackgroundView];
72+
73+
// React Native scroll views don't adjust for bars or cutouts they sit under, so the surface is
74+
// kept clear of them.
75+
UIView *rootView = [_reactNativeFactory.rootViewFactory viewWithModuleName:kScreenModuleName
76+
initialProperties:_initialProperties
77+
launchOptions:_launchOptions
78+
bundleConfiguration:_reactNativeFactory.bundleConfiguration
79+
devMenuConfiguration:_reactNativeFactory.devMenuConfiguration];
80+
rootView.translatesAutoresizingMaskIntoConstraints = NO;
81+
[self.view addSubview:rootView];
82+
83+
UILayoutGuide *safeArea = self.view.safeAreaLayoutGuide;
84+
_rootViewBottomConstraint = [rootView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor];
85+
[NSLayoutConstraint activateConstraints:@[
86+
[headerBackgroundView.topAnchor constraintEqualToAnchor:self.view.topAnchor],
87+
[headerBackgroundView.bottomAnchor constraintEqualToAnchor:safeArea.topAnchor],
88+
[headerBackgroundView.leadingAnchor constraintEqualToAnchor:safeArea.leadingAnchor],
89+
[headerBackgroundView.trailingAnchor constraintEqualToAnchor:safeArea.trailingAnchor],
90+
[rootView.topAnchor constraintEqualToAnchor:safeArea.topAnchor],
91+
_rootViewBottomConstraint,
92+
[rootView.leadingAnchor constraintEqualToAnchor:safeArea.leadingAnchor],
93+
[rootView.trailingAnchor constraintEqualToAnchor:safeArea.trailingAnchor],
94+
]];
95+
}
96+
97+
- (void)viewSafeAreaInsetsDidChange
98+
{
99+
[super viewSafeAreaInsetsDidChange];
100+
// The window's own bottom inset (the home indicator) only applies beneath a bottom tab bar, which
101+
// already clears it. With the tab bar at the side or hidden, the surface runs to the bottom edge.
102+
CGFloat bottomInset = self.view.safeAreaInsets.bottom;
103+
BOOL hasBottomBar = bottomInset > self.view.window.safeAreaInsets.bottom;
104+
_rootViewBottomConstraint.constant = hasBottomBar ? -bottomInset : 0;
105+
}
106+
107+
- (void)viewWillAppear:(BOOL)animated
108+
{
109+
[super viewWillAppear:animated];
110+
[self.navigationController setNavigationBarHidden:_hidesChrome animated:animated];
111+
}
112+
113+
- (void)openDocumentation
114+
{
115+
[[UIApplication sharedApplication] openURL:_documentationURL options:@{} completionHandler:nil];
116+
}
117+
118+
@end
119+
120+
@implementation RNTesterTabBarController {
121+
RCTReactNativeFactory *_reactNativeFactory;
122+
UINavigationController *_componentsNavigationController;
123+
}
124+
125+
- (instancetype)initWithReactNativeFactory:(RCTReactNativeFactory *)reactNativeFactory
126+
initialProperties:(NSDictionary *)initialProperties
127+
launchOptions:(NSDictionary *)launchOptions
128+
{
129+
if (self = [super initWithNibName:nil bundle:nil]) {
130+
_reactNativeFactory = reactNativeFactory;
131+
132+
_componentsNavigationController = [self navigationControllerForScreen:@"components"
133+
title:@"Components"
134+
image:@"square.stack.3d.up"
135+
testID:@"components-tab"
136+
initialProperties:initialProperties
137+
launchOptions:launchOptions];
138+
self.viewControllers = @[
139+
_componentsNavigationController,
140+
[self navigationControllerForScreen:@"apis"
141+
title:@"APIs"
142+
image:@"curlybraces"
143+
testID:@"apis-tab"
144+
initialProperties:initialProperties
145+
launchOptions:launchOptions],
146+
[self navigationControllerForScreen:@"playgrounds"
147+
title:@"Playground"
148+
image:@"play.rectangle"
149+
testID:@"playground-tab"
150+
initialProperties:initialProperties
151+
launchOptions:launchOptions],
152+
];
153+
}
154+
return self;
155+
}
156+
157+
- (UINavigationController *)navigationControllerForScreen:(NSString *)screen
158+
title:(NSString *)title
159+
image:(NSString *)imageName
160+
testID:(NSString *)testID
161+
initialProperties:(NSDictionary *)initialProperties
162+
launchOptions:(NSDictionary *)launchOptions
163+
{
164+
NSMutableDictionary *screenProperties = [initialProperties mutableCopy];
165+
screenProperties[@"screen"] = screen;
166+
167+
RNTesterScreenViewController *rootViewController =
168+
[[RNTesterScreenViewController alloc] initWithReactNativeFactory:_reactNativeFactory
169+
title:title
170+
initialProperties:screenProperties
171+
launchOptions:launchOptions
172+
documentationURL:nil
173+
hidesChrome:NO];
174+
UINavigationController *navigationController =
175+
[[UINavigationController alloc] initWithRootViewController:rootViewController];
176+
// Each screen draws the header's background itself, see RNTesterScreenViewController.
177+
UINavigationBarAppearance *navigationBarAppearance = [UINavigationBarAppearance new];
178+
[navigationBarAppearance configureWithTransparentBackground];
179+
navigationController.navigationBar.standardAppearance = navigationBarAppearance;
180+
navigationController.navigationBar.scrollEdgeAppearance = navigationBarAppearance;
181+
navigationController.tabBarItem = [[UITabBarItem alloc] initWithTitle:title
182+
image:[UIImage systemImageNamed:imageName]
183+
tag:0];
184+
navigationController.tabBarItem.accessibilityIdentifier = testID;
185+
return navigationController;
186+
}
187+
188+
- (void)pushRoute:(NSDictionary *)route
189+
{
190+
auto *navigationController = (UINavigationController *)self.selectedViewController;
191+
[navigationController pushViewController:[self viewControllerForRoute:route] animated:YES];
192+
}
193+
194+
- (void)pushRouteFromDeepLink:(NSDictionary *)route
195+
{
196+
self.selectedViewController = _componentsNavigationController;
197+
[_componentsNavigationController popToRootViewControllerAnimated:NO];
198+
[_componentsNavigationController pushViewController:[self viewControllerForRoute:route] animated:NO];
199+
}
200+
201+
- (UIViewController *)viewControllerForRoute:(NSDictionary *)route
202+
{
203+
NSMutableDictionary *screenProperties = [NSMutableDictionary dictionary];
204+
screenProperties[@"moduleKey"] = route[@"moduleKey"];
205+
if ([route[@"exampleKey"] isKindOfClass:[NSString class]]) {
206+
screenProperties[@"exampleKey"] = route[@"exampleKey"];
207+
}
208+
209+
NSURL *documentationURL = nil;
210+
if ([route[@"documentationURL"] isKindOfClass:[NSString class]]) {
211+
documentationURL = [NSURL URLWithString:route[@"documentationURL"]];
212+
}
213+
214+
return [[RNTesterScreenViewController alloc] initWithReactNativeFactory:_reactNativeFactory
215+
title:route[@"title"]
216+
initialProperties:screenProperties
217+
launchOptions:nil
218+
documentationURL:documentationURL
219+
hidesChrome:[route[@"hidesChrome"] boolValue]];
220+
}
221+
222+
@end

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

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
#import "NativeExampleViews/FlexibleSizeExampleView.h"
1111
#import "NativeExampleViews/UpdatePropertiesExampleView.h"
12+
#import "RNTesterTabBarController.h"
1213

1314
#import <React/RCTBundleURLProvider.h>
1415
#import <React/RCTDefines.h>
@@ -31,6 +32,27 @@
3132

3233
static NSString *const kBundlePath = @"js/RNTesterApp.ios";
3334

35+
// Mirrors the conversion RCTReactNativeFactory applies when it creates the root view itself.
36+
static NSDictionary *launchOptionsFromConnectionOptions(UISceneConnectionOptions *connectionOptions)
37+
{
38+
NSMutableDictionary *launchOptions = [NSMutableDictionary dictionary];
39+
40+
NSURL *url = connectionOptions.URLContexts.anyObject.URL;
41+
if (url != nil) {
42+
launchOptions[UIApplicationLaunchOptionsURLKey] = url;
43+
}
44+
45+
NSUserActivity *activity = connectionOptions.userActivities.anyObject;
46+
if (activity != nil) {
47+
launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey] = @{
48+
UIApplicationLaunchOptionsUserActivityTypeKey : activity.activityType,
49+
@"UIApplicationLaunchOptionsUserActivityKey" : activity,
50+
};
51+
}
52+
53+
return launchOptions;
54+
}
55+
3456
@implementation SceneDelegate
3557

3658
- (NSDictionary *)prepareInitialProps
@@ -58,20 +80,20 @@ - (void)scene:(UIScene *)scene
5880

5981
self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self releaseLevel:[self releaseLevel]];
6082

61-
auto *windowScene = (UIWindowScene *)scene;
62-
self.window = [[UIWindow alloc] initWithWindowScene:windowScene];
63-
64-
[self.reactNativeFactory startReactNativeWithModuleName:@"RNTesterApp"
65-
inWindow:self.window
66-
initialProperties:[self prepareInitialProps]
67-
connectionOptions:connectionOptions];
68-
6983
#if RCT_DEV_MENU
7084
RCTDevMenuConfiguration *devMenuConfiguration = [[RCTDevMenuConfiguration alloc] initWithDevMenuEnabled:true
7185
shakeGestureEnabled:true
7286
keyboardShortcutsEnabled:true];
7387
[self.reactNativeFactory setDevMenuConfiguration:devMenuConfiguration];
7488
#endif
89+
90+
auto *windowScene = (UIWindowScene *)scene;
91+
self.window = [[UIWindow alloc] initWithWindowScene:windowScene];
92+
self.window.rootViewController = [[RNTesterTabBarController alloc]
93+
initWithReactNativeFactory:self.reactNativeFactory
94+
initialProperties:[self prepareInitialProps]
95+
launchOptions:launchOptionsFromConnectionOptions(connectionOptions)];
96+
[self.window makeKeyAndVisible];
7597
}
7698

7799
- (RCTReleaseLevel)releaseLevel

0 commit comments

Comments
 (0)