diff --git a/packages/rn-tester/Podfile b/packages/rn-tester/Podfile index 93e41d495a92..24b30cffa59d 100644 --- a/packages/rn-tester/Podfile +++ b/packages/rn-tester/Podfile @@ -51,6 +51,7 @@ def pods(target_name, options = {}) # RNTester native modules and components pod 'ScreenshotManager', :path => "NativeModuleExample" pod 'MyNativeView', :path => "NativeComponentExample" + pod 'RNTesterTabsIOS', :path => "RNTesterTabsIOS" pod 'NativeCxxModuleExample', :path => "NativeCxxModuleExample" end diff --git a/packages/rn-tester/Podfile.lock b/packages/rn-tester/Podfile.lock index 030d0acba633..e05fb962dc81 100644 --- a/packages/rn-tester/Podfile.lock +++ b/packages/rn-tester/Podfile.lock @@ -2645,6 +2645,35 @@ PODS: - React-perflogger (= 1000.0.0) - React-utils (= 1000.0.0) - SocketRocket + - RNTesterTabsIOS (0.87.0-main): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - ScreenshotManager (0.87.0-main): - boost - DoubleConversion @@ -2765,6 +2794,7 @@ DEPENDENCIES: - ReactCodegen (from `build/generated/ios/ReactCodegen`) - ReactCommon-Samples (from `../react-native/ReactCommon/react/nativemodule/samples`) - ReactCommon/turbomodule/core (from `../react-native/ReactCommon`) + - RNTesterTabsIOS (from `RNTesterTabsIOS`) - ScreenshotManager (from `NativeModuleExample`) - SocketRocket (~> 0.7.1) - Yoga (from `../react-native/ReactCommon/yoga`) @@ -2948,6 +2978,8 @@ EXTERNAL SOURCES: :path: "../react-native/ReactCommon" ReactCommon-Samples: :path: "../react-native/ReactCommon/react/nativemodule/samples" + RNTesterTabsIOS: + :path: RNTesterTabsIOS ScreenshotManager: :path: NativeModuleExample Yoga: @@ -3041,10 +3073,11 @@ SPEC CHECKSUMS: ReactCodegen: 19447115b4e0b8eec131ae256e7000a9a0910dc4 ReactCommon: 1376b66d6ef10d128c76c2d59fef49e706832ca6 ReactCommon-Samples: b74ec150a8210d62be6cc899d28a24e7b795edd3 + RNTesterTabsIOS: a0e709192adb16ce7cd9a2485d958ee5d5b12fab ScreenshotManager: c005d764f11c09fb4a5640a3157024dddce859e9 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: a9afa834491f5b80bc3b10d2d91e8bfb15ab1100 -PODFILE CHECKSUM: 59f1e7d58cffbfafbeb3df31151743dd4c9a52e8 +PODFILE CHECKSUM: abfcf138a255569f3324d5118cecdcbc64bdb8c6 COCOAPODS: 1.16.2 diff --git a/packages/rn-tester/RNTester/SceneDelegate.mm b/packages/rn-tester/RNTester/SceneDelegate.mm index b9483633871c..26c4b217677f 100644 --- a/packages/rn-tester/RNTester/SceneDelegate.mm +++ b/packages/rn-tester/RNTester/SceneDelegate.mm @@ -124,6 +124,9 @@ - (NSURL *)bundleURL if (!dict[@"RNTMyNativeView"]) { dict[@"RNTMyNativeView"] = NSClassFromString(@"RNTMyNativeViewComponentView"); } + if (!dict[@"RNTesterTabsIOS"]) { + dict[@"RNTesterTabsIOS"] = NSClassFromString(@"RNTesterTabsIOSComponentView"); + } if (!dict[@"SampleNativeComponent"]) { dict[@"SampleNativeComponent"] = NSClassFromString(@"RCTSampleNativeComponentComponentView"); } diff --git a/packages/rn-tester/RNTesterTabsIOS/RNTesterTabsIOS.podspec b/packages/rn-tester/RNTesterTabsIOS/RNTesterTabsIOS.podspec new file mode 100644 index 000000000000..c2def81df501 --- /dev/null +++ b/packages/rn-tester/RNTesterTabsIOS/RNTesterTabsIOS.podspec @@ -0,0 +1,29 @@ +# 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. + +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "../" "package.json"))) + +Pod::Spec.new do |s| + s.name = "RNTesterTabsIOS" + s.version = package["version"] + s.summary = package["description"] + s.description = "RNTester's native tab bar" + s.homepage = "https://github.com/react/react-native" + s.license = "MIT" + s.platforms = min_supported_versions + s.author = "Meta Platforms, Inc. and its affiliates" + s.source = { :git => "https://github.com/react/react-native.git", :tag => "#{s.version}" } + s.pod_target_xcconfig = { + "HEADER_SEARCH_PATHS" => "\"${PODS_CONFIGURATION_BUILD_DIR}/ReactCodegen/ReactCodegen.framework/Headers\"", + "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard() + } + + s.source_files = "ios/**/*.{h,mm}" + s.requires_arc = true + + install_modules_dependencies(s) +end diff --git a/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.h b/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.h new file mode 100644 index 000000000000..7520d71d5ad8 --- /dev/null +++ b/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.h @@ -0,0 +1,23 @@ +/* + * 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 + +NS_ASSUME_NONNULL_BEGIN + +/** + * Hosts RNTester's tabs in a UITabBarController, so the tab bar adapts to the + * device (a bottom bar, or a sidebar on wide layouts) while the app stays a + * single React Native root. + * + * The React children are the selected tab's content: they are mounted into a + * single container view, which moves into the selected tab's view controller. + */ +@interface RNTesterTabsIOSComponentView : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm b/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm new file mode 100644 index 000000000000..ce7f2d1b9665 --- /dev/null +++ b/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm @@ -0,0 +1,228 @@ +/* + * 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 "RNTesterTabsIOSComponentView.h" + +#import +#import +#import +#import +#import + +#include + +using namespace facebook::react; + +/** + * A tab's view controller, which reports when the tab bar or window changes + * the area its content needs to clear. + */ +@interface RNTesterTabViewController : UIViewController +@property (nonatomic, copy) void (^safeAreaInsetsDidChange)(UIEdgeInsets insets); +@end + +@implementation RNTesterTabViewController + +- (void)viewSafeAreaInsetsDidChange +{ + [super viewSafeAreaInsetsDidChange]; + if (self.safeAreaInsetsDidChange != nil) { + self.safeAreaInsetsDidChange(self.view.safeAreaInsets); + } +} + +@end + +@interface RNTesterTabsIOSComponentView () +@end + +@implementation RNTesterTabsIOSComponentView { + UITabBarController *_tabBarController; + // Holds the mounted React children, and moves into the selected tab. + UIView *_containerView; + std::vector _tabKeys; + UIEdgeInsets _contentInsets; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider +{ + return concreteComponentDescriptorProvider(); +} + +// The view owns a view controller, which is not worth resetting for reuse. ++ (BOOL)shouldBeRecycled +{ + return NO; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + + _containerView = [UIView new]; + _containerView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + + _tabBarController = [UITabBarController new]; + _tabBarController.delegate = self; + self.contentView = _tabBarController.view; + } + return self; +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if (self.window == nil) { + [_tabBarController willMoveToParentViewController:nil]; + [_tabBarController removeFromParentViewController]; + return; + } + + UIViewController *parentViewController = self.reactViewController; + if (parentViewController != nil && _tabBarController.parentViewController == nil) { + [parentViewController addChildViewController:_tabBarController]; + [_tabBarController didMoveToParentViewController:parentViewController]; + } +} + +#pragma mark - Children + +// Children are laid out against this view's bounds, which the tab's view shares, so they keep their +// frames wherever the container moves. +- (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index +{ + [_containerView insertSubview:childComponentView atIndex:index]; +} + +- (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index +{ + [childComponentView removeFromSuperview]; +} + +#pragma mark - Props + +- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps +{ + const auto &oldTabsProps = static_cast(*_props); + const auto &newTabsProps = static_cast(*props); + + // RNTester's tabs are a constant, so they are built once, from the first props. + bool tabsCreated = _tabKeys.empty(); + if (tabsCreated) { + [self createTabs:newTabsProps.tabs]; + } + if (tabsCreated || oldTabsProps.selectedTab != newTabsProps.selectedTab) { + [self selectTab:newTabsProps.selectedTab]; + } + if (oldTabsProps.tabBarHidden != newTabsProps.tabBarHidden) { + if (@available(iOS 18.0, tvOS 18.0, *)) { + [_tabBarController setTabBarHidden:newTabsProps.tabBarHidden animated:NO]; + } else { + _tabBarController.tabBar.hidden = newTabsProps.tabBarHidden; + } + } + + [super updateProps:props oldProps:oldProps]; +} + +- (void)createTabs:(const std::vector &)tabs +{ + __weak RNTesterTabsIOSComponentView *weakSelf = self; + NSMutableArray *viewControllers = [NSMutableArray arrayWithCapacity:tabs.size()]; + _tabKeys.clear(); + + for (const auto &tab : tabs) { + auto *viewController = [RNTesterTabViewController new]; + viewController.tabBarItem = + [[UITabBarItem alloc] initWithTitle:RCTNSStringFromString(tab.title) + image:[UIImage systemImageNamed:RCTNSStringFromString(tab.systemImage)] + tag:0]; + viewController.tabBarItem.accessibilityIdentifier = RCTNSStringFromString(tab.testID); + viewController.safeAreaInsetsDidChange = ^(UIEdgeInsets insets) { + [weakSelf tabSafeAreaInsetsDidChange:insets]; + }; + [viewControllers addObject:viewController]; + _tabKeys.push_back(tab.key); + } + + _tabBarController.viewControllers = viewControllers; +} + +- (void)selectTab:(const std::string &)key +{ + auto it = std::find(_tabKeys.begin(), _tabKeys.end(), key); + if (it == _tabKeys.end()) { + return; + } + _tabBarController.selectedIndex = std::distance(_tabKeys.begin(), it); + + UIView *tabView = _tabBarController.selectedViewController.view; + _containerView.frame = tabView.bounds; + [tabView addSubview:_containerView]; +} + +#pragma mark - UITabBarControllerDelegate + +- (BOOL)tabBarController:(UITabBarController *)tabBarController + shouldSelectViewController:(UIViewController *)viewController +{ + NSUInteger index = [tabBarController.viewControllers indexOfObject:viewController]; + if (index != NSNotFound && _eventEmitter != nullptr) { + static_cast(*_eventEmitter).onTabPress({.key = _tabKeys[index]}); + } + // JS selects the tab by updating `selectedTab`. + return NO; +} + +#pragma mark - Insets + +- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter +{ + [super updateEventEmitter:eventEmitter]; + // The insets can settle before the first event emitter arrives. + UIViewController *selectedViewController = _tabBarController.selectedViewController; + if (selectedViewController.isViewLoaded && selectedViewController.view.window != nil) { + _contentInsets = UIEdgeInsetsZero; + [self tabSafeAreaInsetsDidChange:selectedViewController.view.safeAreaInsets]; + } +} + +- (void)tabSafeAreaInsetsDidChange:(UIEdgeInsets)insets +{ + // The home indicator's inset only applies beneath a tab bar along the bottom edge, which already + // clears it. With the tab bar at the side, content runs to the bottom edge. + CGRect tabBarFrame = _tabBarController.tabBar.frame; + CGRect bounds = _tabBarController.view.bounds; + if (CGRectGetWidth(tabBarFrame) < CGRectGetWidth(bounds) || CGRectGetMaxY(tabBarFrame) < CGRectGetMaxY(bounds)) { + insets.bottom = 0; + } + // With no status bar above it, UINavigationBar still sits 24pt below the top edge, clear of the + // display's corners. Content leaves the same gap, since the device reports no top inset there. + if (insets.top == 0) { + insets.top = 24; + } + if (UIEdgeInsetsEqualToEdgeInsets(insets, _contentInsets) || _eventEmitter == nullptr) { + return; + } + _contentInsets = insets; + static_cast(*_eventEmitter) + .onContentInsetsChange({ + .top = insets.top, + .left = insets.left, + .bottom = insets.bottom, + .right = insets.right, + }); +} + +@end + +Class RNTesterTabsIOSCls(void) +{ + return RNTesterTabsIOSComponentView.class; +} diff --git a/packages/rn-tester/js/RNTesterAppShared.js b/packages/rn-tester/js/RNTesterAppShared.js index b48d430f8f99..715ac18fb1de 100644 --- a/packages/rn-tester/js/RNTesterAppShared.js +++ b/packages/rn-tester/js/RNTesterAppShared.js @@ -8,12 +8,14 @@ * @format */ +import type {ContentInsets} from './components/RNTesterTabsIOSNativeComponent'; import type {RNTesterModuleInfo, ScreenTypes} from './types/RNTesterTypes'; import ReportFullyDrawnView from '../ReportFullyDrawnView/ReportFullyDrawnView'; import RNTesterModuleContainer from './components/RNTesterModuleContainer'; import RNTesterModuleList from './components/RNTesterModuleList'; import RNTesterNavBar, {navBarHeight} from './components/RNTesterNavbar'; +import RNTesterTabsIOS from './components/RNTesterTabsIOSNativeComponent'; import {RNTesterThemeContext, themes} from './components/RNTesterTheme'; import RNTTitleBar from './components/RNTTitleBar'; import {title as PlaygroundTitle} from './examples/Playground/PlaygroundExample'; @@ -28,7 +30,7 @@ import { initialNavigationState, } from './utils/testerStateUtils'; import * as React from 'react'; -import {useCallback, useEffect, useMemo, useReducer} from 'react'; +import {useCallback, useEffect, useMemo, useReducer, useState} from 'react'; import { BackHandler, Button, @@ -54,6 +56,30 @@ if (global.RN$Bridgeless === true && __DEV__) { // RNTester App currently uses in memory storage for storing navigation state +// On iOS, the tabs are native, see RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm +const IOS_TABS = [ + { + key: Screens.COMPONENTS, + title: 'Components', + systemImage: 'square.stack.3d.up', + testID: 'components-tab', + }, + { + key: Screens.APIS, + title: 'APIs', + systemImage: 'curlybraces', + testID: 'apis-tab', + }, + { + key: Screens.PLAYGROUNDS, + title: 'Playground', + systemImage: 'play.rectangle', + testID: 'playground-tab', + }, +]; + +const NO_INSETS: ContentInsets = {top: 0, left: 0, bottom: 0, right: 0}; + type BackButton = ({onBack: () => void}) => React.Node; const RNTesterApp = ({ @@ -71,6 +97,7 @@ const RNTesterApp = ({ initialNavigationState, ); const colorScheme = useColorScheme(); + const [contentInsets, setContentInsets] = useState(NO_INSETS); const { activeModuleKey, @@ -274,36 +301,88 @@ const RNTesterApp = ({ // Hide chrome if we don't have much screen space and are showing UI for tests const shouldHideChrome = isScreenTiny && hadDeepLink; + const titleBar = shouldHideChrome ? null : ( + + {activeModule && BackButtonComponent ? ( + + ) : undefined} + + ); + + const content = ( + + {activeModule != null ? ( + + ) : ( + + )} + + ); + + if (Platform.OS === 'ios') { + return ( + + { + const tab = IOS_TABS.find(t => t.key === event.nativeEvent.key); + if (tab != null) { + handleNavBarPress({screen: tab.key}); + } + }} + onContentInsetsChange={event => setContentInsets(event.nativeEvent)}> + + + {titleBar} + + + {content} + + + + + + ); + } + return ( {Platform.OS === 'android' ? : null} - {!shouldHideChrome && ( - - {activeModule && BackButtonComponent ? ( - - ) : undefined} - - )} - - {activeModule != null ? ( - - ) : ( - - )} - + {titleBar} + {content} {!shouldHideChrome && ( 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/components/RNTesterTabsIOSNativeComponent.js b/packages/rn-tester/js/components/RNTesterTabsIOSNativeComponent.js new file mode 100644 index 000000000000..155064f84f6a --- /dev/null +++ b/packages/rn-tester/js/components/RNTesterTabsIOSNativeComponent.js @@ -0,0 +1,48 @@ +/** + * 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 {CodegenTypes, HostComponent, ViewProps} from 'react-native'; + +import {codegenNativeComponent} from 'react-native'; + +type Tab = Readonly<{ + key: string, + title: string, + // An SF Symbol name. + systemImage: string, + testID: string, +}>; + +type TabPressEvent = Readonly<{ + key: string, +}>; + +export type ContentInsets = Readonly<{ + top: CodegenTypes.Double, + left: CodegenTypes.Double, + bottom: CodegenTypes.Double, + right: CodegenTypes.Double, +}>; + +type NativeProps = Readonly<{ + ...ViewProps, + tabs: ReadonlyArray, + selectedTab: string, + tabBarHidden?: boolean, + // Selection is controlled: pressing a tab only reports it, and the tab is + // selected once `selectedTab` changes. + onTabPress?: ?CodegenTypes.DirectEventHandler, + // The area the tab bar and window cutouts cover, which content should clear. + onContentInsetsChange?: ?CodegenTypes.DirectEventHandler, +}>; + +export default codegenNativeComponent('RNTesterTabsIOS', { + excludedPlatforms: ['android'], +}) as HostComponent; diff --git a/packages/rn-tester/package.json b/packages/rn-tester/package.json index 0f4a2b6e888e..66357a54979f 100644 --- a/packages/rn-tester/package.json +++ b/packages/rn-tester/package.json @@ -54,6 +54,9 @@ "components": { "RNTMyNativeView": { "className": "RNTMyNativeViewComponentView" + }, + "RNTesterTabsIOS": { + "className": "RNTesterTabsIOSComponentView" } } } @@ -88,6 +91,10 @@ "name": "MyNativeView", "path": "NativeComponentExample/ios" }, + { + "name": "RNTesterTabsIOS", + "path": "RNTesterTabsIOS/ios" + }, { "name": "NativeCxxModuleExample", "path": "NativeCxxModuleExample"