Skip to content

Commit 136bc6d

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

17 files changed

Lines changed: 750 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: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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 = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"book"]
49+
style:UIBarButtonItemStylePlain
50+
target:self
51+
action:@selector(openDocumentation)];
52+
documentationItem.accessibilityLabel = @"Documentation";
53+
self.navigationItem.rightBarButtonItem = documentationItem;
54+
}
55+
}
56+
return self;
57+
}
58+
59+
- (void)viewDidLoad
60+
{
61+
[super viewDidLoad];
62+
// Matches the screen's GroupedBackgroundColor, showing through outside the safe area.
63+
self.view.backgroundColor = [UIColor systemGroupedBackgroundColor];
64+
65+
// Draws the header's background behind the transparent navigation bar, spanning only the safe area's
66+
// width so it stays clear of side cutouts and a side tab bar.
67+
UIView *headerBackgroundView = [UIView new];
68+
headerBackgroundView.backgroundColor = [UIColor systemBackgroundColor];
69+
headerBackgroundView.translatesAutoresizingMaskIntoConstraints = NO;
70+
[self.view addSubview:headerBackgroundView];
71+
72+
// React Native scroll views don't adjust for bars or cutouts they sit under, so the surface is
73+
// kept clear of them.
74+
UIView *rootView = [_reactNativeFactory.rootViewFactory viewWithModuleName:kScreenModuleName
75+
initialProperties:_initialProperties
76+
launchOptions:_launchOptions
77+
bundleConfiguration:_reactNativeFactory.bundleConfiguration
78+
devMenuConfiguration:_reactNativeFactory.devMenuConfiguration];
79+
rootView.translatesAutoresizingMaskIntoConstraints = NO;
80+
[self.view addSubview:rootView];
81+
82+
UILayoutGuide *safeArea = self.view.safeAreaLayoutGuide;
83+
_rootViewBottomConstraint = [rootView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor];
84+
[NSLayoutConstraint activateConstraints:@[
85+
[headerBackgroundView.topAnchor constraintEqualToAnchor:self.view.topAnchor],
86+
[headerBackgroundView.bottomAnchor constraintEqualToAnchor:safeArea.topAnchor],
87+
[headerBackgroundView.leadingAnchor constraintEqualToAnchor:safeArea.leadingAnchor],
88+
[headerBackgroundView.trailingAnchor constraintEqualToAnchor:safeArea.trailingAnchor],
89+
[rootView.topAnchor constraintEqualToAnchor:safeArea.topAnchor],
90+
_rootViewBottomConstraint,
91+
[rootView.leadingAnchor constraintEqualToAnchor:safeArea.leadingAnchor],
92+
[rootView.trailingAnchor constraintEqualToAnchor:safeArea.trailingAnchor],
93+
]];
94+
}
95+
96+
- (void)viewSafeAreaInsetsDidChange
97+
{
98+
[super viewSafeAreaInsetsDidChange];
99+
// The window's own bottom inset (the home indicator) only applies beneath a bottom tab bar, which
100+
// already clears it. With the tab bar at the side or hidden, the surface runs to the bottom edge.
101+
CGFloat bottomInset = self.view.safeAreaInsets.bottom;
102+
BOOL hasBottomBar = bottomInset > self.view.window.safeAreaInsets.bottom;
103+
_rootViewBottomConstraint.constant = hasBottomBar ? -bottomInset : 0;
104+
}
105+
106+
- (void)viewWillAppear:(BOOL)animated
107+
{
108+
[super viewWillAppear:animated];
109+
[self.navigationController setNavigationBarHidden:_hidesChrome animated:animated];
110+
}
111+
112+
- (void)openDocumentation
113+
{
114+
[[UIApplication sharedApplication] openURL:_documentationURL options:@{} completionHandler:nil];
115+
}
116+
117+
@end
118+
119+
@implementation RNTesterTabBarController {
120+
RCTReactNativeFactory *_reactNativeFactory;
121+
UINavigationController *_componentsNavigationController;
122+
}
123+
124+
- (instancetype)initWithReactNativeFactory:(RCTReactNativeFactory *)reactNativeFactory
125+
initialProperties:(NSDictionary *)initialProperties
126+
launchOptions:(NSDictionary *)launchOptions
127+
{
128+
if (self = [super initWithNibName:nil bundle:nil]) {
129+
_reactNativeFactory = reactNativeFactory;
130+
131+
_componentsNavigationController = [self navigationControllerForScreen:@"components"
132+
title:@"Components"
133+
image:@"square.stack.3d.up"
134+
testID:@"components-tab"
135+
initialProperties:initialProperties
136+
launchOptions:launchOptions];
137+
self.viewControllers = @[
138+
_componentsNavigationController,
139+
[self navigationControllerForScreen:@"apis"
140+
title:@"APIs"
141+
image:@"curlybraces"
142+
testID:@"apis-tab"
143+
initialProperties:initialProperties
144+
launchOptions:launchOptions],
145+
[self navigationControllerForScreen:@"playgrounds"
146+
title:@"Playground"
147+
image:@"play.rectangle"
148+
testID:@"playground-tab"
149+
initialProperties:initialProperties
150+
launchOptions:launchOptions],
151+
];
152+
}
153+
return self;
154+
}
155+
156+
- (UINavigationController *)navigationControllerForScreen:(NSString *)screen
157+
title:(NSString *)title
158+
image:(NSString *)imageName
159+
testID:(NSString *)testID
160+
initialProperties:(NSDictionary *)initialProperties
161+
launchOptions:(NSDictionary *)launchOptions
162+
{
163+
NSMutableDictionary *screenProperties = [initialProperties mutableCopy];
164+
screenProperties[@"screen"] = screen;
165+
166+
RNTesterScreenViewController *rootViewController =
167+
[[RNTesterScreenViewController alloc] initWithReactNativeFactory:_reactNativeFactory
168+
title:title
169+
initialProperties:screenProperties
170+
launchOptions:launchOptions
171+
documentationURL:nil
172+
hidesChrome:NO];
173+
UINavigationController *navigationController =
174+
[[UINavigationController alloc] initWithRootViewController:rootViewController];
175+
// Each screen draws the header's background itself, see RNTesterScreenViewController.
176+
UINavigationBarAppearance *navigationBarAppearance = [UINavigationBarAppearance new];
177+
[navigationBarAppearance configureWithTransparentBackground];
178+
navigationController.navigationBar.standardAppearance = navigationBarAppearance;
179+
navigationController.navigationBar.scrollEdgeAppearance = navigationBarAppearance;
180+
navigationController.tabBarItem = [[UITabBarItem alloc] initWithTitle:title
181+
image:[UIImage systemImageNamed:imageName]
182+
tag:0];
183+
navigationController.tabBarItem.accessibilityIdentifier = testID;
184+
return navigationController;
185+
}
186+
187+
- (void)pushRoute:(NSDictionary *)route
188+
{
189+
auto *navigationController = (UINavigationController *)self.selectedViewController;
190+
[navigationController pushViewController:[self viewControllerForRoute:route] animated:YES];
191+
}
192+
193+
- (void)pushRouteFromDeepLink:(NSDictionary *)route
194+
{
195+
self.selectedViewController = _componentsNavigationController;
196+
[_componentsNavigationController popToRootViewControllerAnimated:NO];
197+
[_componentsNavigationController pushViewController:[self viewControllerForRoute:route] animated:NO];
198+
}
199+
200+
- (UIViewController *)viewControllerForRoute:(NSDictionary *)route
201+
{
202+
NSMutableDictionary *screenProperties = [NSMutableDictionary dictionary];
203+
screenProperties[@"moduleKey"] = route[@"moduleKey"];
204+
if ([route[@"exampleKey"] isKindOfClass:[NSString class]]) {
205+
screenProperties[@"exampleKey"] = route[@"exampleKey"];
206+
}
207+
208+
NSURL *documentationURL = nil;
209+
if ([route[@"documentationURL"] isKindOfClass:[NSString class]]) {
210+
documentationURL = [NSURL URLWithString:route[@"documentationURL"]];
211+
}
212+
213+
return [[RNTesterScreenViewController alloc] initWithReactNativeFactory:_reactNativeFactory
214+
title:route[@"title"]
215+
initialProperties:screenProperties
216+
launchOptions:nil
217+
documentationURL:documentationURL
218+
hidesChrome:[route[@"hidesChrome"] boolValue]];
219+
}
220+
221+
@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)