From 6a81ed99bc0040fdec757ba6c9f7eeccce1a2c46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20Gaczo=C5=82?= Date: Wed, 29 Apr 2026 21:34:39 +0200 Subject: [PATCH 1/5] add direction prop to SplitHost --- ios/conversion/RNSConversions-SplitView.mm | 17 +++++++++++++ ios/conversion/RNSConversions.h | 3 +++ .../split/RNSSplitAppearanceApplicator.swift | 18 +++++++++++++ .../split/RNSSplitAppearanceUpdateFlags.swift | 1 + ios/gamma/split/RNSSplitHostComponentView.h | 1 + ios/gamma/split/RNSSplitHostComponentView.mm | 19 ++++++++++++++ ios/gamma/split/RNSSplitHostController.swift | 22 +++++++++++++++- src/components/gamma/split/SplitHost.tsx | 3 ++- src/components/gamma/split/SplitHost.types.ts | 25 ++++++++++++++++++- src/components/gamma/split/index.ts | 1 + .../gamma/split/SplitHostNativeComponent.ts | 3 +++ 11 files changed, 110 insertions(+), 3 deletions(-) diff --git a/ios/conversion/RNSConversions-SplitView.mm b/ios/conversion/RNSConversions-SplitView.mm index c9a18a8f0b..b2a6d3115b 100644 --- a/ios/conversion/RNSConversions-SplitView.mm +++ b/ios/conversion/RNSConversions-SplitView.mm @@ -115,6 +115,23 @@ UISplitViewControllerDisplayModeButtonVisibility SplitViewDisplayModeButtonVisib } } +UITraitEnvironmentLayoutDirection UITraitEnvironmentLayoutDirectionFromSplitHostCppEquivalent( + react::RNSSplitHostLayoutDirection layoutDirection) +{ + using enum facebook::react::RNSSplitHostLayoutDirection; + switch (layoutDirection) { + case Inherit: + return UITraitEnvironmentLayoutDirectionUnspecified; + case Ltr: + return UITraitEnvironmentLayoutDirectionLeftToRight; + case Rtl: + return UITraitEnvironmentLayoutDirectionRightToLeft; + default: + RCTLogError(@"[RNScreens] unsupported layout direction"); + return UITraitEnvironmentLayoutDirectionUnspecified; + } +} + RNSOrientation RNSOrientationFromRNSSplitHostOrientation(react::RNSSplitHostOrientation orientation) { using enum facebook::react::RNSSplitHostOrientation; diff --git a/ios/conversion/RNSConversions.h b/ios/conversion/RNSConversions.h index 9f28007079..a4bac299fe 100644 --- a/ios/conversion/RNSConversions.h +++ b/ios/conversion/RNSConversions.h @@ -134,6 +134,9 @@ std::optional SplitViewTopColumnForCollapsingFromHo RNSOrientation RNSOrientationFromRNSSplitHostOrientation(react::RNSSplitHostOrientation orientation); +UITraitEnvironmentLayoutDirection UITraitEnvironmentLayoutDirectionFromSplitHostCppEquivalent( + react::RNSSplitHostLayoutDirection layoutDirection); + #pragma mark SplitScreen props RNSSplitScreenColumnType RNSSplitScreenColumnTypeFromScreenProp(react::RNSSplitScreenColumnType columnType); diff --git a/ios/gamma/split/RNSSplitAppearanceApplicator.swift b/ios/gamma/split/RNSSplitAppearanceApplicator.swift index 617da145ef..805749f906 100644 --- a/ios/gamma/split/RNSSplitAppearanceApplicator.swift +++ b/ios/gamma/split/RNSSplitAppearanceApplicator.swift @@ -48,6 +48,10 @@ class RNSSplitAppearanceApplicator { appearanceCoordinator.updateIfNeeded(.orientationUpdate) { [] in RNSScreenWindowTraits.enforceDesiredDeviceOrientation() } + + appearanceCoordinator.updateIfNeeded(.layoutDirectionUpdateBelowIOS17) { + updateLayoutDirectionBelowIOS17(splitHost, splitHostController) + } } /// @@ -191,4 +195,18 @@ class RNSSplitAppearanceApplicator { "[RNScreens] Split column constraints are invalid: minWidth \(minWidth) cannot be greater than maxWidth \(maxWidth)" ) } + + public func updateLayoutDirectionBelowIOS17( + _ splitHost: RNSSplitHostComponentView, + _ splitHostController: RNSSplitHostController, + ) { + assert( + splitHostController.parent != nil, + "[RNScreens] Expected non-null parent view controller for layout direction update." + ) + splitHostController.parent?.setOverrideTraitCollection( + UITraitCollection(layoutDirection: splitHost.layoutDirection), + forChild: splitHostController + ) + } } diff --git a/ios/gamma/split/RNSSplitAppearanceUpdateFlags.swift b/ios/gamma/split/RNSSplitAppearanceUpdateFlags.swift index dfbd9043dd..aff82567bb 100644 --- a/ios/gamma/split/RNSSplitAppearanceUpdateFlags.swift +++ b/ios/gamma/split/RNSSplitAppearanceUpdateFlags.swift @@ -8,4 +8,5 @@ struct RNSSplitAppearanceUpdateFlags: OptionSet { static let secondaryScreenNavBarUpdate = RNSSplitAppearanceUpdateFlags(rawValue: 1 << 1) static let displayModeUpdate = RNSSplitAppearanceUpdateFlags(rawValue: 1 << 2) static let orientationUpdate = RNSSplitAppearanceUpdateFlags(rawValue: 1 << 3) + static let layoutDirectionUpdateBelowIOS17 = RNSSplitAppearanceUpdateFlags(rawValue: 1 << 4) } diff --git a/ios/gamma/split/RNSSplitHostComponentView.h b/ios/gamma/split/RNSSplitHostComponentView.h index 0538aa09ac..9bea19c7fb 100644 --- a/ios/gamma/split/RNSSplitHostComponentView.h +++ b/ios/gamma/split/RNSSplitHostComponentView.h @@ -62,6 +62,7 @@ NS_ASSUME_NONNULL_BEGIN #endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) @property (nonatomic, readonly) RNSOrientation orientation; +@property (nonatomic, readonly) UITraitEnvironmentLayoutDirection layoutDirection; @end diff --git a/ios/gamma/split/RNSSplitHostComponentView.mm b/ios/gamma/split/RNSSplitHostComponentView.mm index 721c480be0..c8ae713fc3 100644 --- a/ios/gamma/split/RNSSplitHostComponentView.mm +++ b/ios/gamma/split/RNSSplitHostComponentView.mm @@ -30,6 +30,7 @@ @implementation RNSSplitHostComponentView { bool _needsSplitSecondaryScreenNavBarUpdate; bool _needsSplitDisplayModeUpdate; bool _needsSplitOrientationUpdate; + bool _needsSplitLayoutDirectionUpdate; // We need this information to warn users about dynamic changes to behavior being currently unsupported. bool _isShowSecondaryToggleButtonSet; } @@ -53,6 +54,7 @@ - (void)initState _needsSplitSecondaryScreenNavBarUpdate = false; _needsSplitDisplayModeUpdate = false; _needsSplitOrientationUpdate = false; + _needsSplitLayoutDirectionUpdate = false; _reactSubviews = [NSMutableArray new]; } @@ -92,6 +94,7 @@ - (void)resetProps _topColumnForCollapsingColumn = UISplitViewControllerColumnPrimary; _orientation = RNSOrientationInherit; + _layoutDirection = UITraitEnvironmentLayoutDirectionUnspecified; _isShowSecondaryToggleButtonSet = false; } @@ -330,6 +333,12 @@ - (void)updateProps:(const facebook::react::Props::Shared &)props _orientation = rnscreens::conversion::RNSOrientationFromRNSSplitHostOrientation(newComponentProps.orientation); } + if (oldComponentProps.layoutDirection != newComponentProps.layoutDirection) { + _needsSplitLayoutDirectionUpdate = true; + _layoutDirection = rnscreens::conversion::UITraitEnvironmentLayoutDirectionFromSplitHostCppEquivalent( + newComponentProps.layoutDirection); + } + // This flag is set to true when showsSecondaryOnlyButton prop is assigned for the first time. // This allows us to identify any subsequent changes to this prop, // enabling us to warn users that dynamic changes are not supported. @@ -365,6 +374,16 @@ - (void)requestSplitHostControllerForAppearanceUpdate _needsSplitOrientationUpdate = false; [_controller setNeedsOrientationUpdate]; } + + if (_needsSplitLayoutDirectionUpdate && _controller != nil) { + _needsSplitLayoutDirectionUpdate = false; +#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 setNeedsLayoutDirectionUpdateBelowIOS17]; + } } #pragma mark - RCTMountingTransactionObserving diff --git a/ios/gamma/split/RNSSplitHostController.swift b/ios/gamma/split/RNSSplitHostController.swift index e40fb42d3e..8220728ce3 100644 --- a/ios/gamma/split/RNSSplitHostController.swift +++ b/ios/gamma/split/RNSSplitHostController.swift @@ -11,6 +11,7 @@ public class RNSSplitHostController: UISplitViewController, ReactMountingTransac RNSOrientationProvidingSwift { private var needsChildViewControllersUpdate = false + private var isLayoutDirectionUpdatePending = false private var splitAppearanceCoordinator: RNSSplitAppearanceCoordinator private var splitAppearanceApplicator: RNSSplitAppearanceApplicator @@ -93,6 +94,15 @@ public class RNSSplitHostController: UISplitViewController, ReactMountingTransac public func setNeedsOrientationUpdate() { splitAppearanceCoordinator.needs(.orientationUpdate) } + + @objc + public func setNeedsLayoutDirectionUpdateBelowIOS17() { + if self.parent != nil { + splitAppearanceCoordinator.needs(.layoutDirectionUpdateBelowIOS17) + } else { + isLayoutDirectionUpdatePending = true + } + } // MARK: Updating @@ -140,7 +150,7 @@ public class RNSSplitHostController: UISplitViewController, ReactMountingTransac needsChildViewControllersUpdate = false } - + func updateSplitAppearanceIfNeeded() { splitAppearanceApplicator.updateAppearanceIfNeeded( self.splitHostComponentView, self, self.splitAppearanceCoordinator) @@ -552,4 +562,14 @@ extension RNSSplitHostController: UISplitViewControllerDelegate { } return proposedTopColumn } + + public override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + + if parent != nil && isLayoutDirectionUpdatePending { + isLayoutDirectionUpdatePending = false + splitAppearanceApplicator.updateLayoutDirectionBelowIOS17( + self.splitHostComponentView, self) + } + } } diff --git a/src/components/gamma/split/SplitHost.tsx b/src/components/gamma/split/SplitHost.tsx index 7a5de71374..9a33639cdb 100644 --- a/src/components/gamma/split/SplitHost.tsx +++ b/src/components/gamma/split/SplitHost.tsx @@ -43,7 +43,7 @@ const isValidDisplayModeForSplitBehavior = ( * EXPERIMENTAL API, MIGHT CHANGE W/O ANY NOTICE */ function SplitHost({ ref, ...props }: SplitHostProps) { - const { preferredDisplayMode, preferredSplitBehavior } = props; + const { direction, preferredDisplayMode, preferredSplitBehavior } = props; const nativeRef = React.useRef(null); React.useImperativeHandle( @@ -101,6 +101,7 @@ function SplitHost({ ref, ...props }: SplitHostProps) { // This enables us to fully recreate the Split when necessary, ensuring the correct column configuration is always applied. key={`columns-${columns.length}-inspectors-${inspectors.length}`} {...props} + layoutDirection={direction} style={styles.container}> {props.children} diff --git a/src/components/gamma/split/SplitHost.types.ts b/src/components/gamma/split/SplitHost.types.ts index 83ba3785ff..0e69786077 100644 --- a/src/components/gamma/split/SplitHost.types.ts +++ b/src/components/gamma/split/SplitHost.types.ts @@ -1,5 +1,5 @@ import type { NativeSyntheticEvent, ViewProps } from 'react-native'; -import type { InterfaceOrientation } from '../../shared/types'; +import type { Direction, InterfaceOrientation } from '../../shared/types'; // eslint-disable-next-line @typescript-eslint/ban-types type GenericEmptyEvent = Readonly<{}>; @@ -26,6 +26,8 @@ export type SplitDisplayMode = | 'twoOverSecondary' | 'twoDisplaceSecondary'; +export type SplitHostDirection = Direction | 'inherit'; + export type SplitHostOrientation = InterfaceOrientation | 'inherit'; export interface SplitColumnMetrics { @@ -116,6 +118,27 @@ export interface SplitHostProps extends ViewProps { children?: React.ReactNode; ref?: React.Ref; + /** + * @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 iOS, this property sets `layoutDirection` trait override for the + * native split view 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 ios + */ + direction?: SplitHostDirection; /** * @summary An object describing bounds for column widths. * diff --git a/src/components/gamma/split/index.ts b/src/components/gamma/split/index.ts index 1f79f6787d..eaa7067854 100644 --- a/src/components/gamma/split/index.ts +++ b/src/components/gamma/split/index.ts @@ -8,6 +8,7 @@ export type { SplitPrimaryEdge, SplitPrimaryBackgroundStyle, SplitDisplayMode, + SplitHostDirection, SplitHostOrientation, SplitColumnMetrics, SplitNavigableColumn, diff --git a/src/fabric/gamma/split/SplitHostNativeComponent.ts b/src/fabric/gamma/split/SplitHostNativeComponent.ts index 7a70092723..e7a24cfa0a 100644 --- a/src/fabric/gamma/split/SplitHostNativeComponent.ts +++ b/src/fabric/gamma/split/SplitHostNativeComponent.ts @@ -43,6 +43,8 @@ type SplitViewOrientation = type SplitViewPrimaryBackgroundStyle = 'default' | 'none' | 'sidebar'; +type LayoutDirection = 'inherit' | 'ltr' | 'rtl'; + type SplitViewTopColumnForCollapsing = | 'default' | 'primary' @@ -68,6 +70,7 @@ interface ColumnMetrics { interface NativeProps extends ViewProps { // Appearance + layoutDirection?: CT.WithDefault; preferredDisplayMode?: CT.WithDefault; preferredSplitBehavior?: CT.WithDefault; primaryEdge?: CT.WithDefault; From c70b61b96281bb1b65c5ef5a60a932644746c1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Szymon=20Gaczo=C5=82?= Date: Wed, 29 Apr 2026 21:50:20 +0200 Subject: [PATCH 2/5] add test-direction.tsx --- .../tests/single-feature-tests/split/index.ts | 7 +- .../split/test-direction.tsx | 107 ++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 apps/src/tests/single-feature-tests/split/test-direction.tsx diff --git a/apps/src/tests/single-feature-tests/split/index.ts b/apps/src/tests/single-feature-tests/split/index.ts index fb71522122..9d06ad5f80 100644 --- a/apps/src/tests/single-feature-tests/split/index.ts +++ b/apps/src/tests/single-feature-tests/split/index.ts @@ -1,8 +1,13 @@ import type { ScenarioGroup } from '@apps/tests/shared/helpers'; import TestTopColumnForCollapsing from './test-top-column-for-collapsing'; import TestCommandShowColumn from './test-command-show-column'; +import TestDirection from './test-direction'; -const scenarios = { TestTopColumnForCollapsing, TestCommandShowColumn }; +const scenarios = { + TestTopColumnForCollapsing, + TestCommandShowColumn, + TestDirection, +}; const SplitScenarioGroup: ScenarioGroup = { name: 'Split', diff --git a/apps/src/tests/single-feature-tests/split/test-direction.tsx b/apps/src/tests/single-feature-tests/split/test-direction.tsx new file mode 100644 index 0000000000..eaabd6e497 --- /dev/null +++ b/apps/src/tests/single-feature-tests/split/test-direction.tsx @@ -0,0 +1,107 @@ +import React, { useState } from 'react'; +import type { ScenarioDescription } from '@apps/tests/shared/helpers'; +import { createScenario } from '@apps/tests/shared/helpers'; +import { Split } from 'react-native-screens/experimental'; +import { StyleSheet, Text, View, Button } from 'react-native'; + +const scenarioDescription: ScenarioDescription = { + name: 'Prop: layoutDirection', + key: 'test-split-layout-direction', + details: ` + Test the direction prop in Split component. + `, + platforms: ['ios'], +}; + +type LayoutDirection = 'ltr' | 'rtl' | 'inherit'; + +export function App() { + const [direction, setDirection] = useState('inherit'); + + return ( + + + + + + + + + + + + ); +} + +export function ColumnContent(props: { + columnTitle: string; + currentDirection?: LayoutDirection; + onChangeDirection?: (dir: LayoutDirection) => void; +}) { + return ( + + {props.columnTitle} + + {props.onChangeDirection && ( + + + Current direction:{' '} + {props.currentDirection} + + + +