diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt index afd550f128..a7273c5892 100644 --- a/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt @@ -87,6 +87,11 @@ class TabsHostViewManager : value: String?, ) = Unit + override fun setLayoutDirection( + view: TabsHost, + value: String?, + ) = Unit + override fun setColorScheme( view: TabsHost?, value: String?, diff --git a/apps/src/shared/gamma/containers/bottom-tabs/BottomTabsContainer.tsx b/apps/src/shared/gamma/containers/bottom-tabs/BottomTabsContainer.tsx index a40c395b64..3e90ceb242 100644 --- a/apps/src/shared/gamma/containers/bottom-tabs/BottomTabsContainer.tsx +++ b/apps/src/shared/gamma/containers/bottom-tabs/BottomTabsContainer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Platform, type NativeSyntheticEvent } from 'react-native'; +import { I18nManager, Platform, type NativeSyntheticEvent } from 'react-native'; import { Tabs, TabsHostProps, @@ -81,6 +81,7 @@ export function BottomTabsContainer(props: BottomTabsContainerProps) { experimentalControlNavigationStateInJS={ configWrapper.config.controlledBottomTabs } + direction={I18nManager.isRTL ? 'rtl' : 'ltr'} {...restProps}> {tabConfigs.map(tabConfig => { const tabKey = tabConfig.tabScreenProps.tabKey; diff --git a/apps/src/tests/single-feature-tests/tabs/index.ts b/apps/src/tests/single-feature-tests/tabs/index.ts index 2952ae321f..4bfc760974 100644 --- a/apps/src/tests/single-feature-tests/tabs/index.ts +++ b/apps/src/tests/single-feature-tests/tabs/index.ts @@ -6,6 +6,7 @@ import TabBarHiddenScenario from './tab-bar-hidden'; import TabsScreenOrientationScenario from './tabs-screen-orientation'; import TabBarAppearanceDefinedBySelectedTabScenario from './test-tabs-appearance-defined-by-selected-tab'; import TestTabsColorScheme from './test-tabs-color-scheme'; +import TestTabsLayoutDirection from './test-tabs-layout-direction'; const scenarios = { BottomAccessoryScenario, @@ -14,6 +15,7 @@ const scenarios = { TabBarHiddenScenario, TabsScreenOrientationScenario, TestTabsColorScheme, + TestTabsLayoutDirection, }; const TabsScenarioGroup: ScenarioGroup = { diff --git a/apps/src/tests/single-feature-tests/tabs/test-tabs-layout-direction.tsx b/apps/src/tests/single-feature-tests/tabs/test-tabs-layout-direction.tsx new file mode 100644 index 0000000000..663400fbae --- /dev/null +++ b/apps/src/tests/single-feature-tests/tabs/test-tabs-layout-direction.tsx @@ -0,0 +1,180 @@ +import { + I18nManager, + Platform, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { Scenario } from '../../shared/helpers'; +import { createAutoConfiguredTabs } from '../../shared/tabs'; +import React, { useEffect, useState } from 'react'; +import { SettingsPicker, SettingsSwitch } from '../../../shared'; +import { TabsHostProps } from 'react-native-screens'; +import useTabsConfigState from '../../shared/hooks/tabs-config'; +import { DummyScreen } from '../../shared/DummyScreens'; + +const SCENARIO: Scenario = { + name: 'Layout Direction', + key: 'test-tabs-layout-direction', + details: + 'Tests how tabs handle system, React Native and prop layout direction.', + platforms: ['android', 'ios'], + AppComponent: App, +}; + +export default SCENARIO; + +type TabsParamList = { + Config: undefined; + Tab2: undefined; +}; + +function ConfigScreen() { + const [config, dispatch] = useTabsConfigState(); + const [reactForceRtl, setReactForceRtl] = useState(false); + const [reactAllowRtl, setReactAllowRtl] = useState(true); + + // TODO: Tabs.Autoconfig should allow initial prop configuration. + useEffect(() => { + dispatch({ + type: 'tabScreen', + tabKey: 'Config', + config: { + safeAreaConfiguration: { + edges: { + bottom: true, + }, + }, + }, + }); + }, [dispatch]); + + useEffect(() => { + I18nManager.forceRTL(reactForceRtl); + }, [reactForceRtl]); + + useEffect(() => { + I18nManager.allowRTL(reactAllowRtl); + }, [reactAllowRtl]); + + return ( + + + + There are 3 sources of layout direction: system, React Native and our + property on TabsHost. + + + + + System layout direction + + System layout direction depends on the language of the device + (Android/iOS) and supportRtl in app manifest (Android) or available + localizations in Xcode (iOS). In Xcode remember that you must select + the language as default or provide at least 1 localization file (e.g. + empty ar.lproj/InfoPlist.strings). + + + + + React Native's isRTL + + {'I18nManager.isRTL == ' + (I18nManager.isRTL ? 'true' : 'false')} + + + + + React Native's forceRTL + + Initial value might be incorrect. Remember to restart the app after + the change! + + + + + + React Native's allowRTL + + Initial value might be incorrect. Remember to restart the app after + the change! + + + + + + TabsHost layout direction + > + label={'direction'} + value={config.direction ?? 'inherit'} + onValueChange={function (value: TabsHostProps['direction']): void { + dispatch({ + type: 'tabBar', + config: { + direction: value, + }, + }); + }} + items={['inherit', 'ltr', 'rtl']} + /> + + + ); +} + +const Tabs = createAutoConfiguredTabs({ + Config: ConfigScreen, + Tab2: DummyScreen, +}); + +export function App() { + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + containerCenter: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + content: { + padding: 20, + paddingTop: Platform.OS === 'android' ? 60 : undefined, + }, + heading: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 5, + }, + description: { + marginBottom: 5, + }, + rtlInfo: { + fontWeight: 'bold', + textAlign: 'center', + marginVertical: 5, + }, + section: { + marginBottom: 10, + }, +}); diff --git a/common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.cpp b/common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.cpp index b4957d70f6..cb17ae7e9f 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.cpp +++ b/common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.cpp @@ -11,4 +11,21 @@ Point RNSTabsBottomAccessoryShadowNode::getContentOriginOffset( return stateData.contentOffset; } +void RNSTabsBottomAccessoryShadowNode::layout( + facebook::react::LayoutContext layoutContext) { + YogaLayoutableShadowNode::layout(layoutContext); + applyFrameCorrections(); +} + +// When calculating content origin offset for bottom accessory we rely on the +// fact that it's positioned at (0,0). In RTL, this is not the case. As we don't +// want to change `direction` (as this change would propagate further down the +// hierarchy), we force x=0 in the shadow node. If this approach turns out to be +// problematic, we can consider adjusting content origin offset to account for +// the "incorrect" layout in RTL. +void RNSTabsBottomAccessoryShadowNode::applyFrameCorrections() { + ensureUnsealed(); + layoutMetrics_.frame.origin.x = 0; +} + } // namespace facebook::react diff --git a/common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.h b/common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.h index 5f9dd0a1d9..b57b66a4b2 100644 --- a/common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.h +++ b/common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.h @@ -4,6 +4,7 @@ #include #include #include +#include #include "RNSTabsBottomAccessoryState.h" namespace facebook::react { @@ -20,7 +21,15 @@ class JSI_EXPORT RNSTabsBottomAccessoryShadowNode final using ConcreteViewShadowNode::ConcreteViewShadowNode; using StateData = ConcreteViewShadowNode::ConcreteStateData; +#pragma mark - ShadowNode overrides + Point getContentOriginOffset(bool includeTransform) const override; + + void layout(LayoutContext layoutContext) override; + +#pragma mark - Custom interface + private: + void applyFrameCorrections(); }; } // namespace facebook::react diff --git a/ios/conversion/RNSConversions-Tabs.mm b/ios/conversion/RNSConversions-Tabs.mm index 9506eca1b1..85fb3b5302 100644 --- a/ios/conversion/RNSConversions-Tabs.mm +++ b/ios/conversion/RNSConversions-Tabs.mm @@ -484,6 +484,23 @@ UIUserInterfaceStyle UIUserInterfaceStyleFromTabsScreenCppEquivalent( } } +UITraitEnvironmentLayoutDirection UITraitEnvironmentLayoutDirectionFromTabsHostCppEquivalent( + react::RNSTabsHostLayoutDirection layoutDirection) +{ + using enum facebook::react::RNSTabsHostLayoutDirection; + switch (layoutDirection) { + case Inherit: + return UITraitEnvironmentLayoutDirectionUnspecified; + case Ltr: + return UITraitEnvironmentLayoutDirectionLeftToRight; + case Rtl: + return UITraitEnvironmentLayoutDirectionRightToLeft; + default: + RCTLogError(@"[RNScreens] unsupported layout direction"); + return UITraitEnvironmentLayoutDirectionUnspecified; + } +} + UIUserInterfaceStyle UIUserInterfaceStyleFromHostProp(react::RNSTabsHostColorScheme colorScheme) { using enum facebook::react::RNSTabsHostColorScheme; diff --git a/ios/conversion/RNSConversions.h b/ios/conversion/RNSConversions.h index 4a26945fbe..79934eb392 100644 --- a/ios/conversion/RNSConversions.h +++ b/ios/conversion/RNSConversions.h @@ -108,6 +108,9 @@ UIInterfaceOrientationMask UIInterfaceOrientationMaskFromRNSOrientation(RNSOrien RNSOrientation RNSOrientationFromUIInterfaceOrientationMask(UIInterfaceOrientationMask orientationMask); #endif // !TARGET_OS_TV +UITraitEnvironmentLayoutDirection UITraitEnvironmentLayoutDirectionFromTabsHostCppEquivalent( + react::RNSTabsHostLayoutDirection layoutDirection); + UIUserInterfaceStyle UIUserInterfaceStyleFromHostProp(react::RNSTabsHostColorScheme colorScheme); #pragma mark SplitHost props diff --git a/ios/tabs/host/RNSTabBarController.h b/ios/tabs/host/RNSTabBarController.h index 6b1f6b1be8..0bf5617341 100644 --- a/ios/tabs/host/RNSTabBarController.h +++ b/ios/tabs/host/RNSTabBarController.h @@ -118,6 +118,30 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)updateOrientation; +/** + * Updates the layout direction based on property on host view. + * + * This method does nothing if the update has not been previously requested. + * If needed, the requested update is performed immediately. If you do not need this, consider just raising an + * appropriate invalidation signal & let the controller decide when to flush the updates. + * + * This method is necessary only on iOS versions prior to 17. + * On iOS 17+, use `traitOverrides.layoutDirection` on the controller directly. + */ +- (void)updateLayoutDirectionBelowIOS17IfNeeded; + +/** + * Updates the layout direction based on property on host view. + * + * The requested update is performed immediately. If you do not need this, consider just raising an appropriate + * invalidation signal & let the controller decide when to flush the updates. + * + * This method is necessary only on iOS versions prior to 17. + * On iOS 17+, use `traitOverrides.layoutDirection` on the controller directly. + * + * This method can only be called when `parentViewController` is not nil. + */ +- (void)updateLayoutDirectionBelowIOS17; @end #pragma mark - Signals @@ -161,6 +185,14 @@ NS_ASSUME_NONNULL_BEGIN */ @property (nonatomic, readwrite) bool needsOrientationUpdate; +/** + * Tell the controller that some configuration regarding layout direction has changed & it requires update. + * + * This flag is necessary only on iOS versions prior to 17. + * On iOS 17+, use `traitOverrides.layoutDirection` on the controller directly. + */ +@property (nonatomic, readwrite) bool needsLayoutDirectionUpdateBelowIOS17; + @end NS_ASSUME_NONNULL_END diff --git a/ios/tabs/host/RNSTabBarController.mm b/ios/tabs/host/RNSTabBarController.mm index bb9ad9f4a1..24f1393e7a 100644 --- a/ios/tabs/host/RNSTabBarController.mm +++ b/ios/tabs/host/RNSTabBarController.mm @@ -34,6 +34,17 @@ - (instancetype)initWithTabsHostComponentView:(nullable RNSTabsHostComponentView return self; } +#pragma mark - UIKit callbacks + +- (void)didMoveToParentViewController:(UIViewController *)parent +{ + [super didMoveToParentViewController:parent]; + + if (parent != nil) { + [self updateLayoutDirectionBelowIOS17IfNeeded]; + } +} + - (void)tabBar:(UITabBar *)tabBar didSelectItem:(UITabBarItem *)item { RNSLog(@"TabBar: %@ didSelectItem: %@", tabBar, item); @@ -84,6 +95,11 @@ - (void)setNeedsOrientationUpdate:(bool)needsOrientationUpdate #endif // !RCT_NEW_ARCH_ENABLED } +- (void)setNeedsLayoutDirectionUpdateBelowIOS17:(bool)needsLayoutDirectionUpdate +{ + _needsLayoutDirectionUpdateBelowIOS17 = needsLayoutDirectionUpdate; +} + #pragma mark-- RNSReactTransactionObserving - (void)reactMountingTransactionWillMount @@ -254,6 +270,32 @@ - (void)updateOrientation [RNSScreenWindowTraits enforceDesiredDeviceOrientation]; } +- (void)updateLayoutDirectionBelowIOS17IfNeeded +{ + if (_needsLayoutDirectionUpdateBelowIOS17) { + [self updateLayoutDirectionBelowIOS17]; + } +} + +- (void)updateLayoutDirectionBelowIOS17 +{ + _needsLayoutDirectionUpdateBelowIOS17 = false; + +#if RNS_IPHONE_OS_VERSION_AVAILABLE(17_0) + if (@available(iOS 17.0, *)) { + return; + } +#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(17_0) + + RCTAssert( + self.parentViewController != nil, + @"[RNScreens] Expected non-null parent view controller for layout direction update."); + [self.parentViewController + setOverrideTraitCollection:[UITraitCollection + traitCollectionWithLayoutDirection:self.tabsHostComponentView.layoutDirection] + forChildViewController:self]; +} + #pragma mark - RNSOrientationProviding #if !TARGET_OS_TV diff --git a/ios/tabs/host/RNSTabsHostComponentView.h b/ios/tabs/host/RNSTabsHostComponentView.h index 74d1dbb159..6be9771e19 100644 --- a/ios/tabs/host/RNSTabsHostComponentView.h +++ b/ios/tabs/host/RNSTabsHostComponentView.h @@ -58,6 +58,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly) BOOL experimental_controlNavigationStateInJS; +@property (nonatomic, readonly) UITraitEnvironmentLayoutDirection layoutDirection; + #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) @property (nonatomic, readonly) UITabBarMinimizeBehavior tabBarMinimizeBehavior API_AVAILABLE(ios(26.0)); #endif // Check for iOS >= 26 diff --git a/ios/tabs/host/RNSTabsHostComponentView.mm b/ios/tabs/host/RNSTabsHostComponentView.mm index d73102f800..06a33d69ee 100644 --- a/ios/tabs/host/RNSTabsHostComponentView.mm +++ b/ios/tabs/host/RNSTabsHostComponentView.mm @@ -113,6 +113,7 @@ - (void)resetProps _props = defaultProps; #endif _tabBarTintColor = nil; + _layoutDirection = UITraitEnvironmentLayoutDirectionUnspecified; _colorScheme = UIUserInterfaceStyleUnspecified; #if !TARGET_OS_TV _nativeContainerBackgroundColor = [UIColor systemBackgroundColor]; @@ -353,6 +354,11 @@ - (void)updateProps:(const facebook::react::Props::Shared &)props } } + if (newComponentProps.layoutDirection != oldComponentProps.layoutDirection) { + [self setLayoutDirection:rnscreens::conversion::UITraitEnvironmentLayoutDirectionFromTabsHostCppEquivalent( + newComponentProps.layoutDirection)]; + } + if (newComponentProps.colorScheme != oldComponentProps.colorScheme) { _colorScheme = rnscreens::conversion::UIUserInterfaceStyleFromHostProp(newComponentProps.colorScheme); _controller.overrideUserInterfaceStyle = _colorScheme; @@ -588,6 +594,25 @@ - (void)validateAndHandleReactSubview:(UIView *)subview atIndex:(NSInteger)index } } +- (void)setLayoutDirection:(UITraitEnvironmentLayoutDirection)layoutDirection +{ + _layoutDirection = layoutDirection; +#if RNS_IPHONE_OS_VERSION_AVAILABLE(17_0) + if (@available(iOS 17.0, *)) { + _controller.traitOverrides.layoutDirection = _layoutDirection; + } else +#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(17_0) + { + _controller.needsLayoutDirectionUpdateBelowIOS17 = YES; + + // If controller is already attached to parent VC, we should update layout direction + // immediately as controller updates layoutDirection only on `didMoveToParentViewController`. + if (_controller.parentViewController != nil) { + [_controller updateLayoutDirectionBelowIOS17IfNeeded]; + } + } +} + #pragma mark - React Image Loader - (nullable RCTImageLoader *)reactImageLoader diff --git a/src/components/ScreenStackItem.tsx b/src/components/ScreenStackItem.tsx index 43db4ae76d..a3169cb483 100644 --- a/src/components/ScreenStackItem.tsx +++ b/src/components/ScreenStackItem.tsx @@ -22,6 +22,7 @@ import { FooterComponent } from './ScreenFooter'; import { SafeAreaViewProps } from './safe-area/SafeAreaView.types'; import SafeAreaView from './safe-area/SafeAreaView'; import { featureFlags } from '../flags'; +import { isIOS26OrHigher } from './helpers/PlatformUtils'; type Props = Omit< ScreenProps, @@ -87,10 +88,7 @@ function ScreenStackItem( headerConfig.blurEffect !== 'none'; warnOnce( - hasEdgeEffects && - hasBlurEffect && - Platform.OS === 'ios' && - parseInt(Platform.Version, 10) >= 26, + hasEdgeEffects && hasBlurEffect && isIOS26OrHigher, '[RNScreens] Using both `blurEffect` and `scrollEdgeEffects` simultaneously may cause overlapping effects.', ); @@ -114,8 +112,7 @@ function ScreenStackItem( contentStyle = contentWrapperStyles; } - const shouldUseSafeAreaView = - Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 26; + const shouldUseSafeAreaView = isIOS26OrHigher; const content = ( <> diff --git a/src/components/helpers/PlatformUtils.ts b/src/components/helpers/PlatformUtils.ts new file mode 100644 index 0000000000..35616b6921 --- /dev/null +++ b/src/components/helpers/PlatformUtils.ts @@ -0,0 +1,4 @@ +import { Platform } from 'react-native'; + +export const isIOS26OrHigher = + Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 26; diff --git a/src/components/tabs/TabsHost.tsx b/src/components/tabs/TabsHost.tsx index 5d94eff963..1ae8495164 100644 --- a/src/components/tabs/TabsHost.tsx +++ b/src/components/tabs/TabsHost.tsx @@ -16,6 +16,7 @@ import { bottomTabsDebugLog } from '../../private/logging'; import TabsBottomAccessory from './TabsBottomAccessory'; import { TabsBottomAccessoryEnvironment } from './TabsBottomAccessory.types'; import TabsBottomAccessoryContent from './TabsBottomAccessoryContent'; +import { isIOS26OrHigher } from '../helpers/PlatformUtils'; /** * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE @@ -29,6 +30,7 @@ function TabsHost(props: TabsHostProps) { .controlledBottomTabs, bottomAccessory, nativeContainerStyle, + direction, ...filteredProps } = props; @@ -62,17 +64,18 @@ function TabsHost(props: TabsHostProps) { return ( {filteredProps.children} {bottomAccessory && - Platform.OS === 'ios' && - parseInt(Platform.Version, 10) >= 26 && + isIOS26OrHigher && (Platform.constants.reactNativeVersion.minor >= 82 ? ( diff --git a/src/components/tabs/TabsHost.types.ts b/src/components/tabs/TabsHost.types.ts index cf0e5a2e0a..55796d76e1 100644 --- a/src/components/tabs/TabsHost.types.ts +++ b/src/components/tabs/TabsHost.types.ts @@ -11,6 +11,8 @@ export type NativeFocusChangeEvent = { repeatedSelectionHandledBySpecialEffect: boolean; }; +export type TabsHostDirection = 'inherit' | 'ltr' | 'rtl'; + // iOS-specific export type TabBarMinimizeBehavior = | 'automatic' @@ -64,6 +66,32 @@ export interface TabsHostProps { * @platform android, ios */ nativeContainerStyle?: TabsHostNativeContainerStyleProps; + /** + * @summary Specifies the layout direction of the native container, its views and child containers. + * + * The following values are currently supported: + * + * - `inherit` - uses parent's layout direction, + * - `ltr` - forces left-to-right layout direction, + * - `rtl` - forces right-to-left layout direction. + * + * On Android, this property relies on `react-native`'s `style.direction` + * (which sets native Android `layoutDirection` View property). Property is + * propagated via the view hierarchy. The value will fallback to direction + * set on one of the parent views. + * + * On iOS, this property sets `layoutDirection` trait override for the + * native tab bar controller. Property is propagated via the native trait + * system. The value will fallback to direction of the **native** app + * (`userInterfaceLayoutDirection`), potentially ignoring `react-native`'s + * override (e.g. when `forceRTL` is used). To mitigate this, you can pass + * `ltr`/`rtl` to this property depending on the value of `I18nManager.isRTL`. + * + * @default inherit + * + * @platform android, ios + */ + direction?: TabsHostDirection; // #endregion General // #region iOS-only diff --git a/src/fabric/tabs/TabsHostNativeComponent.ts b/src/fabric/tabs/TabsHostNativeComponent.ts index 81e4e13cba..1a50e413cd 100644 --- a/src/fabric/tabs/TabsHostNativeComponent.ts +++ b/src/fabric/tabs/TabsHostNativeComponent.ts @@ -16,6 +16,8 @@ type TabBarMinimizeBehavior = type TabBarControllerMode = 'automatic' | 'tabBar' | 'tabSidebar'; +type LayoutDirection = 'inherit' | 'ltr' | 'rtl'; + type TabsHostColorScheme = 'inherit' | 'light' | 'dark'; export interface NativeProps extends ViewProps { @@ -26,6 +28,10 @@ export interface NativeProps extends ViewProps { tabBarHidden?: CT.WithDefault; nativeContainerBackgroundColor?: ColorValue; + // We can't use `direction` name for this prop as it's also used by + // direction style View prop. + layoutDirection?: CT.WithDefault; + // iOS-specific tabBarTintColor?: ColorValue; tabBarMinimizeBehavior?: CT.WithDefault;