From 136bc6dc24dbee05cb82927caf3f4aa93bb3d304 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Thu, 24 Sep 2026 11:54:18 +0200 Subject: [PATCH] [RNTester][iOS] Add tab bar controller, minimally support iPhone Duo --- .../rn-tester/RNTester/RNTesterNavigator.mm | 54 +++++ .../RNTester/RNTesterTabBarController.h | 43 ++++ .../RNTester/RNTesterTabBarController.mm | 221 ++++++++++++++++++ packages/rn-tester/RNTester/SceneDelegate.mm | 38 ++- .../RNTesterPods.xcodeproj/project.pbxproj | 10 + packages/rn-tester/js/RNTesterApp.ios.js | 2 + packages/rn-tester/js/RNTesterAppShared.js | 72 +----- packages/rn-tester/js/RNTesterScreen.js | 183 +++++++++++++++ .../js/components/RNTesterExampleFilter.js | 3 +- .../rn-tester/js/components/RNTesterLayout.js | 16 ++ .../js/components/RNTesterModuleContainer.js | 7 +- .../js/components/RNTesterModuleList.js | 2 + .../rn-tester/js/components/RNTesterNavbar.js | 8 +- .../js/utils/RNTesterNavigationReducer.js | 4 +- .../rn-tester/js/utils/RNTesterNavigator.js | 43 ++++ .../js/utils/RNTesterRecentlyUsed.js | 37 +++ .../rn-tester/js/utils/resolveExampleURL.js | 92 ++++++++ 17 files changed, 750 insertions(+), 85 deletions(-) create mode 100644 packages/rn-tester/RNTester/RNTesterNavigator.mm create mode 100644 packages/rn-tester/RNTester/RNTesterTabBarController.h create mode 100644 packages/rn-tester/RNTester/RNTesterTabBarController.mm create mode 100644 packages/rn-tester/js/RNTesterScreen.js create mode 100644 packages/rn-tester/js/components/RNTesterLayout.js create mode 100644 packages/rn-tester/js/utils/RNTesterNavigator.js create mode 100644 packages/rn-tester/js/utils/RNTesterRecentlyUsed.js create mode 100644 packages/rn-tester/js/utils/resolveExampleURL.js diff --git a/packages/rn-tester/RNTester/RNTesterNavigator.mm b/packages/rn-tester/RNTester/RNTesterNavigator.mm new file mode 100644 index 000000000000..b82f1ad03cbd --- /dev/null +++ b/packages/rn-tester/RNTester/RNTesterNavigator.mm @@ -0,0 +1,54 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RNTesterTabBarController.h" + +#import +#import +#import + +/** + * Lets JS push screens onto the native navigator, see js/utils/RNTesterNavigator.js. + */ +@interface RNTesterNavigator : NSObject +@end + +@implementation RNTesterNavigator + +RCT_EXPORT_MODULE() + +- (dispatch_queue_t)methodQueue +{ + return dispatch_get_main_queue(); +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +RCT_EXPORT_METHOD(push : (NSDictionary *)route) +{ + [[self tabBarController] pushRoute:route]; +} + +RCT_EXPORT_METHOD(pushFromDeepLink : (NSDictionary *)route) +{ + [[self tabBarController] pushRouteFromDeepLink:route]; +} + +- (RNTesterTabBarController *)tabBarController +{ + UIViewController *rootViewController = RCTKeyWindow().rootViewController; + if (![rootViewController isKindOfClass:[RNTesterTabBarController class]]) { + RCTLogError(@"RNTesterNavigator requires an RNTesterTabBarController as the root view controller"); + return nil; + } + return (RNTesterTabBarController *)rootViewController; +} + +@end diff --git a/packages/rn-tester/RNTester/RNTesterTabBarController.h b/packages/rn-tester/RNTester/RNTesterTabBarController.h new file mode 100644 index 000000000000..1243b9da3759 --- /dev/null +++ b/packages/rn-tester/RNTester/RNTesterTabBarController.h @@ -0,0 +1,43 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +@class RCTReactNativeFactory; + +NS_ASSUME_NONNULL_BEGIN + +/** + * The native navigator for RNTester: a tab for each of Components, Playground, + * and APIs, each a navigation stack of React Native surfaces. + */ +@interface RNTesterTabBarController : UITabBarController + +- (instancetype)initWithReactNativeFactory:(RCTReactNativeFactory *)reactNativeFactory + initialProperties:(NSDictionary *)initialProperties + launchOptions:(nullable NSDictionary *)launchOptions NS_DESIGNATED_INITIALIZER; + +- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil + bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE; +- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; + +/** + * Pushes the screen for a route onto the selected tab's stack. + * + * A route has a `moduleKey` and `title`, and optionally an `exampleKey`, a + * `documentationURL`, and `hidesChrome` to hide the navigation and tab bars. + */ +- (void)pushRoute:(NSDictionary *)route; + +/** + * Selects the Components tab, pops it to its root, and pushes the route. + */ +- (void)pushRouteFromDeepLink:(NSDictionary *)route; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/rn-tester/RNTester/RNTesterTabBarController.mm b/packages/rn-tester/RNTester/RNTesterTabBarController.mm new file mode 100644 index 000000000000..8f1b1822816b --- /dev/null +++ b/packages/rn-tester/RNTester/RNTesterTabBarController.mm @@ -0,0 +1,221 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RNTesterTabBarController.h" + +#if __has_include() +#import +#else +#import +#endif + +static NSString *const kScreenModuleName = @"RNTesterScreen"; + +@interface RNTesterScreenViewController : UIViewController +@end + +@implementation RNTesterScreenViewController { + RCTReactNativeFactory *_reactNativeFactory; + NSDictionary *_initialProperties; + NSDictionary *_launchOptions; + NSURL *_documentationURL; + BOOL _hidesChrome; + NSLayoutConstraint *_rootViewBottomConstraint; +} + +- (instancetype)initWithReactNativeFactory:(RCTReactNativeFactory *)reactNativeFactory + title:(NSString *)title + initialProperties:(NSDictionary *)initialProperties + launchOptions:(NSDictionary *)launchOptions + documentationURL:(NSURL *)documentationURL + hidesChrome:(BOOL)hidesChrome +{ + if (self = [super initWithNibName:nil bundle:nil]) { + _reactNativeFactory = reactNativeFactory; + _initialProperties = initialProperties; + _launchOptions = launchOptions; + _documentationURL = documentationURL; + _hidesChrome = hidesChrome; + + self.title = title; + self.hidesBottomBarWhenPushed = hidesChrome; + + if (documentationURL != nil) { + UIBarButtonItem *documentationItem = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"book"] + style:UIBarButtonItemStylePlain + target:self + action:@selector(openDocumentation)]; + documentationItem.accessibilityLabel = @"Documentation"; + self.navigationItem.rightBarButtonItem = documentationItem; + } + } + return self; +} + +- (void)viewDidLoad +{ + [super viewDidLoad]; + // Matches the screen's GroupedBackgroundColor, showing through outside the safe area. + self.view.backgroundColor = [UIColor systemGroupedBackgroundColor]; + + // Draws the header's background behind the transparent navigation bar, spanning only the safe area's + // width so it stays clear of side cutouts and a side tab bar. + UIView *headerBackgroundView = [UIView new]; + headerBackgroundView.backgroundColor = [UIColor systemBackgroundColor]; + headerBackgroundView.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:headerBackgroundView]; + + // React Native scroll views don't adjust for bars or cutouts they sit under, so the surface is + // kept clear of them. + UIView *rootView = [_reactNativeFactory.rootViewFactory viewWithModuleName:kScreenModuleName + initialProperties:_initialProperties + launchOptions:_launchOptions + bundleConfiguration:_reactNativeFactory.bundleConfiguration + devMenuConfiguration:_reactNativeFactory.devMenuConfiguration]; + rootView.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:rootView]; + + UILayoutGuide *safeArea = self.view.safeAreaLayoutGuide; + _rootViewBottomConstraint = [rootView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor]; + [NSLayoutConstraint activateConstraints:@[ + [headerBackgroundView.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [headerBackgroundView.bottomAnchor constraintEqualToAnchor:safeArea.topAnchor], + [headerBackgroundView.leadingAnchor constraintEqualToAnchor:safeArea.leadingAnchor], + [headerBackgroundView.trailingAnchor constraintEqualToAnchor:safeArea.trailingAnchor], + [rootView.topAnchor constraintEqualToAnchor:safeArea.topAnchor], + _rootViewBottomConstraint, + [rootView.leadingAnchor constraintEqualToAnchor:safeArea.leadingAnchor], + [rootView.trailingAnchor constraintEqualToAnchor:safeArea.trailingAnchor], + ]]; +} + +- (void)viewSafeAreaInsetsDidChange +{ + [super viewSafeAreaInsetsDidChange]; + // The window's own bottom inset (the home indicator) only applies beneath a bottom tab bar, which + // already clears it. With the tab bar at the side or hidden, the surface runs to the bottom edge. + CGFloat bottomInset = self.view.safeAreaInsets.bottom; + BOOL hasBottomBar = bottomInset > self.view.window.safeAreaInsets.bottom; + _rootViewBottomConstraint.constant = hasBottomBar ? -bottomInset : 0; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + [self.navigationController setNavigationBarHidden:_hidesChrome animated:animated]; +} + +- (void)openDocumentation +{ + [[UIApplication sharedApplication] openURL:_documentationURL options:@{} completionHandler:nil]; +} + +@end + +@implementation RNTesterTabBarController { + RCTReactNativeFactory *_reactNativeFactory; + UINavigationController *_componentsNavigationController; +} + +- (instancetype)initWithReactNativeFactory:(RCTReactNativeFactory *)reactNativeFactory + initialProperties:(NSDictionary *)initialProperties + launchOptions:(NSDictionary *)launchOptions +{ + if (self = [super initWithNibName:nil bundle:nil]) { + _reactNativeFactory = reactNativeFactory; + + _componentsNavigationController = [self navigationControllerForScreen:@"components" + title:@"Components" + image:@"square.stack.3d.up" + testID:@"components-tab" + initialProperties:initialProperties + launchOptions:launchOptions]; + self.viewControllers = @[ + _componentsNavigationController, + [self navigationControllerForScreen:@"apis" + title:@"APIs" + image:@"curlybraces" + testID:@"apis-tab" + initialProperties:initialProperties + launchOptions:launchOptions], + [self navigationControllerForScreen:@"playgrounds" + title:@"Playground" + image:@"play.rectangle" + testID:@"playground-tab" + initialProperties:initialProperties + launchOptions:launchOptions], + ]; + } + return self; +} + +- (UINavigationController *)navigationControllerForScreen:(NSString *)screen + title:(NSString *)title + image:(NSString *)imageName + testID:(NSString *)testID + initialProperties:(NSDictionary *)initialProperties + launchOptions:(NSDictionary *)launchOptions +{ + NSMutableDictionary *screenProperties = [initialProperties mutableCopy]; + screenProperties[@"screen"] = screen; + + RNTesterScreenViewController *rootViewController = + [[RNTesterScreenViewController alloc] initWithReactNativeFactory:_reactNativeFactory + title:title + initialProperties:screenProperties + launchOptions:launchOptions + documentationURL:nil + hidesChrome:NO]; + UINavigationController *navigationController = + [[UINavigationController alloc] initWithRootViewController:rootViewController]; + // Each screen draws the header's background itself, see RNTesterScreenViewController. + UINavigationBarAppearance *navigationBarAppearance = [UINavigationBarAppearance new]; + [navigationBarAppearance configureWithTransparentBackground]; + navigationController.navigationBar.standardAppearance = navigationBarAppearance; + navigationController.navigationBar.scrollEdgeAppearance = navigationBarAppearance; + navigationController.tabBarItem = [[UITabBarItem alloc] initWithTitle:title + image:[UIImage systemImageNamed:imageName] + tag:0]; + navigationController.tabBarItem.accessibilityIdentifier = testID; + return navigationController; +} + +- (void)pushRoute:(NSDictionary *)route +{ + auto *navigationController = (UINavigationController *)self.selectedViewController; + [navigationController pushViewController:[self viewControllerForRoute:route] animated:YES]; +} + +- (void)pushRouteFromDeepLink:(NSDictionary *)route +{ + self.selectedViewController = _componentsNavigationController; + [_componentsNavigationController popToRootViewControllerAnimated:NO]; + [_componentsNavigationController pushViewController:[self viewControllerForRoute:route] animated:NO]; +} + +- (UIViewController *)viewControllerForRoute:(NSDictionary *)route +{ + NSMutableDictionary *screenProperties = [NSMutableDictionary dictionary]; + screenProperties[@"moduleKey"] = route[@"moduleKey"]; + if ([route[@"exampleKey"] isKindOfClass:[NSString class]]) { + screenProperties[@"exampleKey"] = route[@"exampleKey"]; + } + + NSURL *documentationURL = nil; + if ([route[@"documentationURL"] isKindOfClass:[NSString class]]) { + documentationURL = [NSURL URLWithString:route[@"documentationURL"]]; + } + + return [[RNTesterScreenViewController alloc] initWithReactNativeFactory:_reactNativeFactory + title:route[@"title"] + initialProperties:screenProperties + launchOptions:nil + documentationURL:documentationURL + hidesChrome:[route[@"hidesChrome"] boolValue]]; +} + +@end diff --git a/packages/rn-tester/RNTester/SceneDelegate.mm b/packages/rn-tester/RNTester/SceneDelegate.mm index b9483633871c..95395ec48f74 100644 --- a/packages/rn-tester/RNTester/SceneDelegate.mm +++ b/packages/rn-tester/RNTester/SceneDelegate.mm @@ -9,6 +9,7 @@ #import "NativeExampleViews/FlexibleSizeExampleView.h" #import "NativeExampleViews/UpdatePropertiesExampleView.h" +#import "RNTesterTabBarController.h" #import #import @@ -31,6 +32,27 @@ static NSString *const kBundlePath = @"js/RNTesterApp.ios"; +// Mirrors the conversion RCTReactNativeFactory applies when it creates the root view itself. +static NSDictionary *launchOptionsFromConnectionOptions(UISceneConnectionOptions *connectionOptions) +{ + NSMutableDictionary *launchOptions = [NSMutableDictionary dictionary]; + + NSURL *url = connectionOptions.URLContexts.anyObject.URL; + if (url != nil) { + launchOptions[UIApplicationLaunchOptionsURLKey] = url; + } + + NSUserActivity *activity = connectionOptions.userActivities.anyObject; + if (activity != nil) { + launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey] = @{ + UIApplicationLaunchOptionsUserActivityTypeKey : activity.activityType, + @"UIApplicationLaunchOptionsUserActivityKey" : activity, + }; + } + + return launchOptions; +} + @implementation SceneDelegate - (NSDictionary *)prepareInitialProps @@ -58,20 +80,20 @@ - (void)scene:(UIScene *)scene self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self releaseLevel:[self releaseLevel]]; - auto *windowScene = (UIWindowScene *)scene; - self.window = [[UIWindow alloc] initWithWindowScene:windowScene]; - - [self.reactNativeFactory startReactNativeWithModuleName:@"RNTesterApp" - inWindow:self.window - initialProperties:[self prepareInitialProps] - connectionOptions:connectionOptions]; - #if RCT_DEV_MENU RCTDevMenuConfiguration *devMenuConfiguration = [[RCTDevMenuConfiguration alloc] initWithDevMenuEnabled:true shakeGestureEnabled:true keyboardShortcutsEnabled:true]; [self.reactNativeFactory setDevMenuConfiguration:devMenuConfiguration]; #endif + + auto *windowScene = (UIWindowScene *)scene; + self.window = [[UIWindow alloc] initWithWindowScene:windowScene]; + self.window.rootViewController = [[RNTesterTabBarController alloc] + initWithReactNativeFactory:self.reactNativeFactory + initialProperties:[self prepareInitialProps] + launchOptions:launchOptionsFromConnectionOptions(connectionOptions)]; + [self.window makeKeyAndVisible]; } - (RCTReleaseLevel)releaseLevel diff --git a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj index 8248d57b2b2a..0099d996b89e 100644 --- a/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj +++ b/packages/rn-tester/RNTesterPods.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 0EA618032BE537D3001875EF /* RNTesterBundle.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 0EA618022BE537D3001875EF /* RNTesterBundle.bundle */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; 2DDEF0101F84BF7B00DBDF73 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2DDEF00F1F84BF7B00DBDF73 /* Images.xcassets */; }; + 381050A32103CEBAB7922F8C /* RNTesterTabBarController.mm in Sources */ = {isa = PBXBuildFile; fileRef = BF63BFE8602F7E7F36F33FB1 /* RNTesterTabBarController.mm */; }; 383889DA23A7398900D06C3E /* RCTConvert_UIColorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 383889D923A7398900D06C3E /* RCTConvert_UIColorTests.m */; }; 3D2AFAF51D646CF80089D1A3 /* legacy_image@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 3D2AFAF41D646CF80089D1A3 /* legacy_image@2x.png */; }; 3F4D148C63BBF774A25488A6 /* libPods-RNTesterIntegrationTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 93A243F0D4D5C54911E811C4 /* libPods-RNTesterIntegrationTests.a */; }; @@ -20,6 +21,7 @@ 832F45BB2A8A6E1F0097B4E6 /* SwiftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */; }; A975CA6C2C05EADF0043F72A /* RCTNetworkTaskTests.m in Sources */ = {isa = PBXBuildFile; fileRef = A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */; }; C175B6D9ED9336FB66637943 /* libPods-RNTester.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C706D402EE4AF9BE838CBA9 /* libPods-RNTester.a */; }; + C77A476FE6A05D2B813E99EC /* RNTesterNavigator.mm in Sources */ = {isa = PBXBuildFile; fileRef = 4F6FBEB975D93B422CC3FB00 /* RNTesterNavigator.mm */; }; CD10C7A5290BD4EB0033E1ED /* RCTEventEmitterTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CD10C7A4290BD4EB0033E1ED /* RCTEventEmitterTests.m */; }; E62F11832A5C6580000BF1C8 /* FlexibleSizeExampleView.mm in Sources */ = {isa = PBXBuildFile; fileRef = 27F441E81BEBE5030039B79C /* FlexibleSizeExampleView.mm */; }; E62F11842A5C6584000BF1C8 /* UpdatePropertiesExampleView.mm in Sources */ = {isa = PBXBuildFile; fileRef = 272E6B3C1BEA849E001FCF37 /* UpdatePropertiesExampleView.mm */; }; @@ -91,6 +93,7 @@ 3D2AFAF41D646CF80089D1A3 /* legacy_image@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "legacy_image@2x.png"; path = "RNTester/legacy_image@2x.png"; sourceTree = ""; }; 3FF60722627F93D8F62FA1E3 /* Pods-RNTesterUnitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTesterUnitTests.release.xcconfig"; path = "Target Support Files/Pods-RNTesterUnitTests/Pods-RNTesterUnitTests.release.xcconfig"; sourceTree = ""; }; 4C706D402EE4AF9BE838CBA9 /* libPods-RNTester.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNTester.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 4F6FBEB975D93B422CC3FB00 /* RNTesterNavigator.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; name = RNTesterNavigator.mm; path = RNTester/RNTesterNavigator.mm; sourceTree = ""; }; 51BC9297B6C3163C14532020 /* Pods-RNTester.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RNTester.release.xcconfig"; path = "Target Support Files/Pods-RNTester/Pods-RNTester.release.xcconfig"; sourceTree = ""; }; 5C60EB1B226440DB0018C04F /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = RNTester/AppDelegate.mm; sourceTree = ""; }; 79B29C2C2E607A99007612A5 /* SceneDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = SceneDelegate.h; path = RNTester/SceneDelegate.h; sourceTree = ""; }; @@ -98,9 +101,11 @@ 8145AE05241172D900A3F8DA /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = RNTester/LaunchScreen.storyboard; sourceTree = ""; }; 832F45BA2A8A6E1F0097B4E6 /* SwiftTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SwiftTest.swift; path = RNTester/SwiftTest.swift; sourceTree = ""; }; 93A243F0D4D5C54911E811C4 /* libPods-RNTesterIntegrationTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-RNTesterIntegrationTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + A66247115FF189C6CBAFC68C /* RNTesterTabBarController.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = RNTesterTabBarController.h; path = RNTester/RNTesterTabBarController.h; sourceTree = ""; }; A975CA6B2C05EADE0043F72A /* RCTNetworkTaskTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RCTNetworkTaskTests.m; sourceTree = ""; }; AC474BFB29BBD4A1002BDAED /* RNTester.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = RNTester.xctestplan; path = RNTester/RNTester.xctestplan; sourceTree = ""; }; 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 = ""; }; + BF63BFE8602F7E7F36F33FB1 /* RNTesterTabBarController.mm */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.cpp.objcpp; name = RNTesterTabBarController.mm; path = RNTester/RNTesterTabBarController.mm; sourceTree = ""; }; 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 = ""; }; CD10C7A4290BD4EB0033E1ED /* RCTEventEmitterTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTEventEmitterTests.m; sourceTree = ""; }; 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 = ""; }; @@ -220,6 +225,9 @@ 680759612239798500290469 /* Fabric */, 272E6B3A1BEA846C001FCF37 /* NativeExampleViews */, 1323F18D1C04ABAC0091BED0 /* Supporting Files */, + A66247115FF189C6CBAFC68C /* RNTesterTabBarController.h */, + BF63BFE8602F7E7F36F33FB1 /* RNTesterTabBarController.mm */, + 4F6FBEB975D93B422CC3FB00 /* RNTesterNavigator.mm */, ); name = RNTester; sourceTree = ""; @@ -739,6 +747,8 @@ 832F45BB2A8A6E1F0097B4E6 /* SwiftTest.swift in Sources */, 5C60EB1C226440DB0018C04F /* AppDelegate.mm in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */, + 381050A32103CEBAB7922F8C /* RNTesterTabBarController.mm in Sources */, + C77A476FE6A05D2B813E99EC /* RNTesterNavigator.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/packages/rn-tester/js/RNTesterApp.ios.js b/packages/rn-tester/js/RNTesterApp.ios.js index 7e587ebbaecc..e8aae3ccc6c6 100644 --- a/packages/rn-tester/js/RNTesterApp.ios.js +++ b/packages/rn-tester/js/RNTesterApp.ios.js @@ -14,6 +14,7 @@ import type {Node} from 'react'; import RNTesterModuleContainer from './components/RNTesterModuleContainer'; import SnapshotViewIOS from './examples/Snapshot/SnapshotViewIOS.ios'; import RNTesterApp from './RNTesterAppShared'; +import RNTesterScreen from './RNTesterScreen'; import RNTesterList from './utils/RNTesterList'; import * as React from 'react'; import {AppRegistry} from 'react-native'; @@ -25,6 +26,7 @@ AppRegistry.registerComponent('RootViewSizeFlexibilityExampleApp', () => require('./examples/RootViewSizeFlexibilityExample/RootViewSizeFlexibilityExampleApp'), ); AppRegistry.registerComponent('RNTesterApp', () => RNTesterApp); +AppRegistry.registerComponent('RNTesterScreen', () => RNTesterScreen); // Register suitable examples for snapshot tests RNTesterList.Components.concat(RNTesterList.APIs).forEach( diff --git a/packages/rn-tester/js/RNTesterAppShared.js b/packages/rn-tester/js/RNTesterAppShared.js index b48d430f8f99..fa457a6f460f 100644 --- a/packages/rn-tester/js/RNTesterAppShared.js +++ b/packages/rn-tester/js/RNTesterAppShared.js @@ -17,6 +17,7 @@ import RNTesterNavBar, {navBarHeight} from './components/RNTesterNavbar'; import {RNTesterThemeContext, themes} from './components/RNTesterTheme'; import RNTTitleBar from './components/RNTTitleBar'; import {title as PlaygroundTitle} from './examples/Playground/PlaygroundExample'; +import resolveExampleURL from './utils/resolveExampleURL'; import RNTesterList from './utils/RNTesterList'; import { RNTesterNavigationActionsType, @@ -155,78 +156,13 @@ const RNTesterApp = ({ // Setup Linking event subscription const handleOpenUrlRequest = useCallback( ({url}: {url: string, ...}) => { - // Supported URL pattern(s): - // * rntester://example/ - // * rntester://example// - const match = - /^rntester(-legacy)?:\/\/example\/([a-zA-Z0-9_-]+)(?:\/([a-zA-Z0-9_-]+))?$/.exec( - url, - ); - if (!match) { - console.warn( - `handleOpenUrlRequest: Received unsupported URL: '${url}'`, - ); + const target = resolveExampleURL(url); + if (target == null) { return; } - - const rawModuleKey = match[2]; - const exampleKey = match[3]; - - // For tooling compatibility, allow all these variants for each module key: - const validModuleKeys = [ - rawModuleKey, - `${rawModuleKey}Index`, - `${rawModuleKey}Example`, - // $FlowFixMe[invalid-computed-prop] - ].filter(k => RNTesterList.Modules[k] != null); - if (validModuleKeys.length !== 1) { - if (validModuleKeys.length === 0) { - console.error( - `handleOpenUrlRequest: Unable to find requested module with key: '${rawModuleKey}'`, - ); - } else { - console.error( - `handleOpenUrlRequest: Found multiple matching module with key: '${rawModuleKey}', unable to resolve`, - ); - } - return; - } - - const resolvedModuleKey = validModuleKeys[0]; - // $FlowFixMe[invalid-computed-prop] - const exampleModule = RNTesterList.Modules[resolvedModuleKey]; - - if (exampleKey != null) { - const validExampleKeys = exampleModule.examples.filter( - e => e.name === exampleKey, - ); - if (validExampleKeys.length !== 1) { - if (validExampleKeys.length === 0) { - console.error( - `handleOpenUrlRequest: Unable to find requested example with key: '${exampleKey}' within module: '${resolvedModuleKey}'`, - ); - } else { - console.error( - `handleOpenUrlRequest: Found multiple matching example with key: '${exampleKey}' within module: '${resolvedModuleKey}', unable to resolve`, - ); - } - return; - } - } - - console.log( - `handleOpenUrlRequest: Opening module: '${resolvedModuleKey}', example: '${ - exampleKey || 'null' - }'`, - ); - dispatch({ type: RNTesterNavigationActionsType.EXAMPLE_OPEN_URL_REQUEST, - data: { - key: resolvedModuleKey, - title: exampleModule.title || resolvedModuleKey, - exampleKey, - }, + data: target, }); }, [dispatch], diff --git a/packages/rn-tester/js/RNTesterScreen.js b/packages/rn-tester/js/RNTesterScreen.js new file mode 100644 index 000000000000..c30354f273ec --- /dev/null +++ b/packages/rn-tester/js/RNTesterScreen.js @@ -0,0 +1,183 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +import RNTesterModuleContainer from './components/RNTesterModuleContainer'; +import RNTesterModuleList from './components/RNTesterModuleList'; +import {RNTesterThemeContext, themes} from './components/RNTesterTheme'; +import resolveExampleURL from './utils/resolveExampleURL'; +import RNTesterList from './utils/RNTesterList'; +import {push, pushFromDeepLink} from './utils/RNTesterNavigator'; +import {addRecentlyUsed, useRecentlyUsed} from './utils/RNTesterRecentlyUsed'; +import {getExamplesListWithRecentlyUsed} from './utils/testerStateUtils'; +import * as React from 'react'; +import {useCallback, useEffect, useMemo} from 'react'; +import { + Dimensions, + Linking, + StyleSheet, + View, + useColorScheme, +} from 'react-native'; + +type Props = Readonly<{ + // Set on the root screen of each tab. + screen?: 'components' | 'apis' | 'playgrounds', + // Set on screens pushed onto a tab's stack. + moduleKey?: string, + exampleKey?: string, +}>; + +/** + * A single screen hosted by the native navigator on iOS. Each tab root and + * each pushed module or example mounts this as its own surface. + */ +export default function RNTesterScreen({ + screen, + moduleKey, + exampleKey, +}: Props): React.Node { + const colorScheme = useColorScheme(); + const theme = colorScheme === 'dark' ? themes.dark : themes.light; + + let content; + if (moduleKey != null) { + content = ; + } else if (screen === 'playgrounds') { + content = ; + } else { + content = ; + } + + return ( + + + {content} + + + ); +} + +function ModuleListScreen({ + exampleType, +}: { + exampleType: 'components' | 'apis', +}): React.Node { + const recentlyUsed = useRecentlyUsed(); + const sections = useMemo( + () => getExamplesListWithRecentlyUsed({recentlyUsed})?.[exampleType], + [exampleType, recentlyUsed], + ); + + const handleModuleCardPress = useCallback( + ({ + exampleType: type, + key, + title, + }: { + exampleType: 'components' | 'apis', + key: string, + title: string, + }) => { + addRecentlyUsed(type, key); + push({ + moduleKey: key, + title, + // $FlowFixMe[invalid-computed-prop] + documentationURL: RNTesterList.Modules[key]?.documentationURL, + }); + }, + [], + ); + + useEffect(() => { + // Deep links always land on the Components tab, so only its root listens. + if (exampleType !== 'components') { + return; + } + + const handleOpenUrlRequest = ({url}: {url: string, ...}) => { + const target = resolveExampleURL(url); + if (target == null) { + return; + } + pushFromDeepLink({ + moduleKey: target.key, + title: target.title, + exampleKey: target.exampleKey, + // $FlowFixMe[invalid-computed-prop] + documentationURL: RNTesterList.Modules[target.key]?.documentationURL, + // Hide chrome if we don't have much screen space and are showing UI + // for tests + hidesChrome: Dimensions.get('window').height < 600, + }); + }; + + Linking.getInitialURL() + .then(url => url != null && handleOpenUrlRequest({url})) + .catch(_ => {}); + const subscription = Linking.addEventListener('url', handleOpenUrlRequest); + return () => subscription.remove(); + }, [exampleType]); + + if (sections == null) { + return null; + } + + return ( + + ); +} + +function ModuleScreen({ + moduleKey, + exampleKey, +}: { + moduleKey: string, + exampleKey?: string, +}): React.Node { + // $FlowFixMe[invalid-computed-prop] + const module = RNTesterList.Modules[moduleKey]; + const example = + exampleKey != null + ? module.examples.find(e => e.name === exampleKey) + : null; + + const handleExampleCardPress = useCallback( + (exampleName: string) => { + push({ + moduleKey, + title: module.title, + exampleKey: exampleName, + documentationURL: module.documentationURL, + }); + }, + [module, moduleKey], + ); + + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/packages/rn-tester/js/components/RNTesterExampleFilter.js b/packages/rn-tester/js/components/RNTesterExampleFilter.js index 4dc4c556a9a4..2cc9b6a0279b 100644 --- a/packages/rn-tester/js/components/RNTesterExampleFilter.js +++ b/packages/rn-tester/js/components/RNTesterExampleFilter.js @@ -10,6 +10,7 @@ import type {SectionData} from '../types/RNTesterTypes'; +import {maxContentWidthStyle} from './RNTesterLayout'; import {RNTesterThemeContext} from './RNTesterTheme'; const RNTesterListFilters = require('./RNTesterListFilters'); @@ -118,7 +119,7 @@ class RNTesterExampleFilter extends React.Component, State> { : theme.BackgroundColor, }, ]}> - + module.showIndividualExamples === true ? ( - filteredSections[0].data.map(renderExample) + + {filteredSections[0].data.map(renderExample)} + ) : ( - + {filteredSections[0].data.map(renderExample)} ) diff --git a/packages/rn-tester/js/components/RNTesterModuleList.js b/packages/rn-tester/js/components/RNTesterModuleList.js index 5a9220e8d6b5..089799b446ce 100644 --- a/packages/rn-tester/js/components/RNTesterModuleList.js +++ b/packages/rn-tester/js/components/RNTesterModuleList.js @@ -8,6 +8,7 @@ * @format */ +import {maxContentWidthStyle} from './RNTesterLayout'; import {RNTesterThemeContext} from './RNTesterTheme'; import RNTPressableRow from './RNTPressableRow'; import {memo} from 'react'; @@ -95,6 +96,7 @@ const RNTesterModuleList: React.ComponentType = memo( sections={filteredSections} extraData={filteredSections} renderItem={renderListItem} + contentContainerStyle={maxContentWidthStyle} keyboardShouldPersistTaps="handled" automaticallyAdjustContentInsets={false} keyboardDismissMode="on-drag" diff --git a/packages/rn-tester/js/components/RNTesterNavbar.js b/packages/rn-tester/js/components/RNTesterNavbar.js index 67d95b265b84..1dcdf8c0b6d6 100644 --- a/packages/rn-tester/js/components/RNTesterNavbar.js +++ b/packages/rn-tester/js/components/RNTesterNavbar.js @@ -141,13 +141,13 @@ const RNTesterNavbar = ({ handleNavBarPress={handleNavBarPress} theme={theme} /> - - diff --git a/packages/rn-tester/js/utils/RNTesterNavigationReducer.js b/packages/rn-tester/js/utils/RNTesterNavigationReducer.js index df7080288e8d..345c2feb613b 100644 --- a/packages/rn-tester/js/utils/RNTesterNavigationReducer.js +++ b/packages/rn-tester/js/utils/RNTesterNavigationReducer.js @@ -22,7 +22,7 @@ export const RNTesterNavigationActionsType = { NAVBAR_OPEN_MODULE_PRESS: 'NAVBAR_OPEN_MODULE_PRESS', } as const; -const getUpdatedRecentlyUsed = ({ +export const getUpdatedRecentlyUsed = ({ exampleType, key, recentlyUsed, @@ -30,7 +30,7 @@ const getUpdatedRecentlyUsed = ({ exampleType: 'apis' | 'components' | null, key: string | null, recentlyUsed: ComponentList, -}) => { +}): NonNullable => { const updatedRecentlyUsed = recentlyUsed ? {...recentlyUsed} : // $FlowFixMe[missing-empty-array-annot] diff --git a/packages/rn-tester/js/utils/RNTesterNavigator.js b/packages/rn-tester/js/utils/RNTesterNavigator.js new file mode 100644 index 000000000000..e14877d2ffc1 --- /dev/null +++ b/packages/rn-tester/js/utils/RNTesterNavigator.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + */ + +import type {TurboModule} from 'react-native'; + +import {TurboModuleRegistry} from 'react-native'; + +export type RNTesterRoute = { + moduleKey: string, + title: string, + exampleKey?: ?string, + documentationURL?: ?string, + hidesChrome?: boolean, +}; + +interface Spec extends TurboModule { + push(route: RNTesterRoute): void; + pushFromDeepLink(route: RNTesterRoute): void; +} + +const NativeModule = + TurboModuleRegistry.getEnforcing('RNTesterNavigator'); + +/** + * Pushes a module or example screen onto the selected tab's native stack. + */ +export function push(route: RNTesterRoute): void { + NativeModule.push(route); +} + +/** + * Switches to the Components tab, resets its stack, and pushes the route. + */ +export function pushFromDeepLink(route: RNTesterRoute): void { + NativeModule.pushFromDeepLink(route); +} diff --git a/packages/rn-tester/js/utils/RNTesterRecentlyUsed.js b/packages/rn-tester/js/utils/RNTesterRecentlyUsed.js new file mode 100644 index 000000000000..8bfb6c33d5eb --- /dev/null +++ b/packages/rn-tester/js/utils/RNTesterRecentlyUsed.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +import type {ComponentList} from '../types/RNTesterTypes'; + +import {getUpdatedRecentlyUsed} from './RNTesterNavigationReducer'; +import {useSyncExternalStore} from 'react'; + +// Shared across every surface, since each native screen mounts its own root. +let recentlyUsed: NonNullable = {components: [], apis: []}; +const listeners: Set<() => void> = new Set(); + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function addRecentlyUsed( + exampleType: 'apis' | 'components', + key: string, +): void { + recentlyUsed = getUpdatedRecentlyUsed({exampleType, key, recentlyUsed}); + listeners.forEach(listener => listener()); +} + +export function useRecentlyUsed(): NonNullable { + return useSyncExternalStore(subscribe, () => recentlyUsed); +} diff --git a/packages/rn-tester/js/utils/resolveExampleURL.js b/packages/rn-tester/js/utils/resolveExampleURL.js new file mode 100644 index 000000000000..97889c9a308b --- /dev/null +++ b/packages/rn-tester/js/utils/resolveExampleURL.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + * @format + */ + +import RNTesterList from './RNTesterList'; + +export type ExampleURLTarget = { + key: string, + title: string, + exampleKey: ?string, +}; + +/** + * Resolves an example URL to the module (and example) it opens. + * + * Supported URL pattern(s): + * * rntester://example/ + * * rntester://example// + */ +export default function resolveExampleURL(url: string): ?ExampleURLTarget { + const match = + /^rntester(-legacy)?:\/\/example\/([a-zA-Z0-9_-]+)(?:\/([a-zA-Z0-9_-]+))?$/.exec( + url, + ); + if (!match) { + console.warn(`handleOpenUrlRequest: Received unsupported URL: '${url}'`); + return null; + } + + const rawModuleKey = match[2]; + const exampleKey = match[3]; + + // For tooling compatibility, allow all these variants for each module key: + const validModuleKeys = [ + rawModuleKey, + `${rawModuleKey}Index`, + `${rawModuleKey}Example`, + // $FlowFixMe[invalid-computed-prop] + ].filter(k => RNTesterList.Modules[k] != null); + if (validModuleKeys.length !== 1) { + if (validModuleKeys.length === 0) { + console.error( + `handleOpenUrlRequest: Unable to find requested module with key: '${rawModuleKey}'`, + ); + } else { + console.error( + `handleOpenUrlRequest: Found multiple matching module with key: '${rawModuleKey}', unable to resolve`, + ); + } + return null; + } + + const resolvedModuleKey = validModuleKeys[0]; + // $FlowFixMe[invalid-computed-prop] + const exampleModule = RNTesterList.Modules[resolvedModuleKey]; + + if (exampleKey != null) { + const validExampleKeys = exampleModule.examples.filter( + e => e.name === exampleKey, + ); + if (validExampleKeys.length !== 1) { + if (validExampleKeys.length === 0) { + console.error( + `handleOpenUrlRequest: Unable to find requested example with key: '${exampleKey}' within module: '${resolvedModuleKey}'`, + ); + } else { + console.error( + `handleOpenUrlRequest: Found multiple matching example with key: '${exampleKey}' within module: '${resolvedModuleKey}', unable to resolve`, + ); + } + return null; + } + } + + console.log( + `handleOpenUrlRequest: Opening module: '${resolvedModuleKey}', example: '${ + exampleKey || 'null' + }'`, + ); + + return { + key: resolvedModuleKey, + title: exampleModule.title || resolvedModuleKey, + exampleKey, + }; +}