Skip to content

Commit 4630ac1

Browse files
ahmedawaad1804Ahmed Awaadkligarski
authored
feat(Tabs): add native RTL support for bottom tabs on iOS & Android (#3613)
## Description This PR adds proper RTL (Right-to-Left) layout support for native bottom tabs on both iOS and Android in react-native-screens. Previously, bottom tabs did not support the RTL direction, which caused incorrect tab ordering and layout in RTL locales (e.g. Arabic, Hebrew). With this change bottom tabs now support changing layout direction. This brings native behavior in line with expected platform RTL handling and React Native layout conventions. ### Details @kligarski: #### Android On Android, the direction works out-of-the-box as it's propagated through view hierarchy. We pass the value of the `direction` prop directly to `TabsHost` view. #### iOS ##### Badges Setting `semanticContentAttribute` for `_controller.tabBar` and `_controller.view` is not enough, e.g. badges visible through liquid glass lens are still in LTR. https://github.com/user-attachments/assets/0a96b18b-ddfe-4dd3-99c1-4a08a89ad86d To handle this, we tried using the same approach as in native stack - we set `UIView`'s `appearanceWhenContainedInInstancesOfClasses` of the tab bar ([details how it's handled in the header are here](https://github.com/software-mansion/react-native-screens/pull/2185/changes#diff-e5ef5b6e29f17bca80b51bc0c5faef1a44bac24e00952b30ac822520213dc6a5R504)). However, this does not work for tab bar & sidebar on iPad starting from iOS 18 as it is not a part of `controller.tabBar`. `_UITabContentView` is mounted under `controller.view`. Using `appearanceWhenContainedInInstancesOfClasses` for `_UITabContentView` (which is already sketchy as this is an internal UIKit class) helps with the order of items in the tab bar but the sidebar appears on the wrong side of the screen. That's why I decided to use modern way to handle direction via trait overrides. For iOS prior to 17, you need to apply overrides on parent view controller ([see here](https://developer.apple.com/documentation/uikit/uiviewcontroller/setoverridetraitcollection(_:forchild:)?language=objc)). This isn't the cleanest solution as the controller changes property of the other controller which might not be a controller belonging to `react-native-screens` but I think that this is the lesser evil. If this turns out to be problematic, we can consider introducing some kind of `ScreensRootView` that will ensure that there is a top controller from `screens`. For iOS 17+, we can use [`traitOverrides`](https://developer.apple.com/documentation/uikit/uiviewcontroller/traitoverrides-8u19n?language=objc) directly on the controller - this works with the top tab bar/sidebar on iPadOS 18+. ##### ScrollView On iOS, there is a bug with content of the ScrollView being moved off screen after tab changes. I've reported the issue to `react-native`: facebook/react-native#55768. The fix has been merged (facebook/react-native#55804) and should be available in next `react-native` release. https://github.com/user-attachments/assets/b033d4c7-bfbe-415b-a02c-66fcfed6d0d6 ##### Bottom Accessory There seems to be a bug with bottom accessory in RTL when search role is NOT used for one of the tabs (Apple Music and Apple Podcasts use search role so the bug isn't visible). | no search role (bug) | search role (no bug) | | --- | --- | | <video src="https://github.com/user-attachments/assets/f5a154c0-4e52-40ca-8263-add687c23e65" /> | <video src="https://github.com/user-attachments/assets/cddcfab4-90b5-4387-8bea-e9c97c3b29e7" /> This bug is reproducible in bare UIKit app on iOS 26.2. https://github.com/user-attachments/assets/d0a36a07-f6a9-4bfd-9ac9-b5721a70e134 I've added this to our internal board (https://github.com/software-mansion/react-native-screens-labs/issues/986) and we'll check whether it has been fixed in iOS 26.3/26.4 beta. ##### Top tab bar badges On iPadOS 18+, there seems to be a bug with the initial position of the badges. They move to correct position after a tab change. I added a ticket on our internal board to check whether this is a native bug: https://github.com/software-mansion/react-native-screens-labs/issues/991. https://github.com/user-attachments/assets/09c25789-b67c-40a1-a3e0-688043cbe17e ##### Native localization vs `react-native` on iOS > [!IMPORTANT] > > When RTL is forced via `I18nManager.forceRTL(true)` but the language of the native app isn't an RTL language, the views related to containers such as the tab bar/sidebar by default will remain in LTR on iOS. This is because we want to rely on native mechanism for layout direction which is the trait system instead of `semanticContentAttribute` (used by regular `react-native` views) which should only define whether the view should be flipped in RTL & does not propagate down the hierarchy. `forceRTL` does not change the trait therefore containers use layout direction of the native app. In order to support `forceRTL`, you should use `direction={I18nManager.isRTL ? 'rtl' : 'ltr'}` (see our example implementation in `BottomTabsContainer.tsx`). This will override the trait from the app with layout direction from `react-native` and propagate it down the hierarchy. ## Changes - add `direction` prop to `TabsHost` and implement it for both platforms - add `Test3598.tsx` ### Before (iOS only because Android works out of the box). <img width="132" height="286" alt="Simulator Screenshot - iPhone 17 Pro Max - 2026-02-03 at 18 39 51" src="https://github.com/user-attachments/assets/19082dc4-49c7-433c-8656-37113be18d9a" /> ### After #### Android | Android Test3598 | | --- | | <video src="https://github.com/user-attachments/assets/07fa151c-94b8-465e-892b-af1aa74deb83" /> | #### iOS | iOS Test3598 | iOS TestBottomTabs | iOS Test3288 | | --- | --- | --- | | <video src="https://github.com/user-attachments/assets/6ab88d38-df51-4869-9698-97d1b2ac2079" /> | <video src="https://github.com/user-attachments/assets/91d4dffc-c7a3-458c-a973-c61fefc6a2e7" /> | <video src="https://github.com/user-attachments/assets/dc775be4-bc07-4a82-8b42-93f54683ba84" /> | ## Test plan Use `Test3598`, `TestBottomTabs`, `Test3288` (iOS). Tested on: - Android (RTL system language enabled) - iOS (RTL simulator + device) ## Checklist - [x] Included code example that can be used to test this change. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] Ensured that CI passes --------- Co-authored-by: Ahmed Awaad <ashahin@aljazirabank.com.sa> Co-authored-by: Krzysztof Ligarski <krzysztof.ligarski@swmansion.com>
1 parent c962866 commit 4630ac1

File tree

17 files changed

+383
-10
lines changed

17 files changed

+383
-10
lines changed

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ class TabsHostViewManager :
8787
value: String?,
8888
) = Unit
8989

90+
override fun setLayoutDirection(
91+
view: TabsHost,
92+
value: String?,
93+
) = Unit
94+
9095
override fun setColorScheme(
9196
view: TabsHost?,
9297
value: String?,

apps/src/shared/gamma/containers/bottom-tabs/BottomTabsContainer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react';
2-
import { Platform, type NativeSyntheticEvent } from 'react-native';
2+
import { I18nManager, Platform, type NativeSyntheticEvent } from 'react-native';
33
import {
44
Tabs,
55
TabsHostProps,
@@ -81,6 +81,7 @@ export function BottomTabsContainer(props: BottomTabsContainerProps) {
8181
experimentalControlNavigationStateInJS={
8282
configWrapper.config.controlledBottomTabs
8383
}
84+
direction={I18nManager.isRTL ? 'rtl' : 'ltr'}
8485
{...restProps}>
8586
{tabConfigs.map(tabConfig => {
8687
const tabKey = tabConfig.tabScreenProps.tabKey;

apps/src/tests/single-feature-tests/tabs/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import TabBarHiddenScenario from './tab-bar-hidden';
66
import TabsScreenOrientationScenario from './tabs-screen-orientation';
77
import TabBarAppearanceDefinedBySelectedTabScenario from './test-tabs-appearance-defined-by-selected-tab';
88
import TestTabsColorScheme from './test-tabs-color-scheme';
9+
import TestTabsLayoutDirection from './test-tabs-layout-direction';
910

1011
const scenarios = {
1112
BottomAccessoryScenario,
@@ -14,6 +15,7 @@ const scenarios = {
1415
TabBarHiddenScenario,
1516
TabsScreenOrientationScenario,
1617
TestTabsColorScheme,
18+
TestTabsLayoutDirection,
1719
};
1820

1921
const TabsScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import {
2+
I18nManager,
3+
Platform,
4+
ScrollView,
5+
StyleSheet,
6+
Text,
7+
View,
8+
} from 'react-native';
9+
import { Scenario } from '../../shared/helpers';
10+
import { createAutoConfiguredTabs } from '../../shared/tabs';
11+
import React, { useEffect, useState } from 'react';
12+
import { SettingsPicker, SettingsSwitch } from '../../../shared';
13+
import { TabsHostProps } from 'react-native-screens';
14+
import useTabsConfigState from '../../shared/hooks/tabs-config';
15+
import { DummyScreen } from '../../shared/DummyScreens';
16+
17+
const SCENARIO: Scenario = {
18+
name: 'Layout Direction',
19+
key: 'test-tabs-layout-direction',
20+
details:
21+
'Tests how tabs handle system, React Native and prop layout direction.',
22+
platforms: ['android', 'ios'],
23+
AppComponent: App,
24+
};
25+
26+
export default SCENARIO;
27+
28+
type TabsParamList = {
29+
Config: undefined;
30+
Tab2: undefined;
31+
};
32+
33+
function ConfigScreen() {
34+
const [config, dispatch] = useTabsConfigState<TabsParamList>();
35+
const [reactForceRtl, setReactForceRtl] = useState(false);
36+
const [reactAllowRtl, setReactAllowRtl] = useState(true);
37+
38+
// TODO: Tabs.Autoconfig should allow initial prop configuration.
39+
useEffect(() => {
40+
dispatch({
41+
type: 'tabScreen',
42+
tabKey: 'Config',
43+
config: {
44+
safeAreaConfiguration: {
45+
edges: {
46+
bottom: true,
47+
},
48+
},
49+
},
50+
});
51+
}, [dispatch]);
52+
53+
useEffect(() => {
54+
I18nManager.forceRTL(reactForceRtl);
55+
}, [reactForceRtl]);
56+
57+
useEffect(() => {
58+
I18nManager.allowRTL(reactAllowRtl);
59+
}, [reactAllowRtl]);
60+
61+
return (
62+
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
63+
<View style={styles.section}>
64+
<Text>
65+
There are 3 sources of layout direction: system, React Native and our
66+
property on TabsHost.
67+
</Text>
68+
</View>
69+
70+
<View style={styles.section}>
71+
<Text style={styles.heading}>System layout direction</Text>
72+
<Text>
73+
System layout direction depends on the language of the device
74+
(Android/iOS) and supportRtl in app manifest (Android) or available
75+
localizations in Xcode (iOS). In Xcode remember that you must select
76+
the language as default or provide at least 1 localization file (e.g.
77+
empty ar.lproj/InfoPlist.strings).
78+
</Text>
79+
</View>
80+
81+
<View style={styles.section}>
82+
<Text style={styles.heading}>React Native's isRTL</Text>
83+
<Text style={styles.rtlInfo}>
84+
{'I18nManager.isRTL == ' + (I18nManager.isRTL ? 'true' : 'false')}
85+
</Text>
86+
</View>
87+
88+
<View style={styles.section}>
89+
<Text style={styles.heading}>React Native's forceRTL</Text>
90+
<Text style={styles.description}>
91+
Initial value might be incorrect. Remember to restart the app after
92+
the change!
93+
</Text>
94+
<SettingsSwitch
95+
label={'forceRTL'}
96+
value={reactForceRtl}
97+
onValueChange={function (value: boolean): void {
98+
setReactForceRtl(value);
99+
}}
100+
/>
101+
</View>
102+
103+
<View style={styles.section}>
104+
<Text style={styles.heading}>React Native's allowRTL</Text>
105+
<Text style={styles.description}>
106+
Initial value might be incorrect. Remember to restart the app after
107+
the change!
108+
</Text>
109+
<SettingsSwitch
110+
label={'allowRTL'}
111+
value={reactAllowRtl}
112+
onValueChange={function (value: boolean): void {
113+
setReactAllowRtl(value);
114+
}}
115+
/>
116+
</View>
117+
118+
<View style={styles.section}>
119+
<Text style={styles.heading}>TabsHost layout direction</Text>
120+
<SettingsPicker<NonNullable<TabsHostProps['direction']>>
121+
label={'direction'}
122+
value={config.direction ?? 'inherit'}
123+
onValueChange={function (value: TabsHostProps['direction']): void {
124+
dispatch({
125+
type: 'tabBar',
126+
config: {
127+
direction: value,
128+
},
129+
});
130+
}}
131+
items={['inherit', 'ltr', 'rtl']}
132+
/>
133+
</View>
134+
</ScrollView>
135+
);
136+
}
137+
138+
const Tabs = createAutoConfiguredTabs<TabsParamList>({
139+
Config: ConfigScreen,
140+
Tab2: DummyScreen,
141+
});
142+
143+
export function App() {
144+
return (
145+
<Tabs.Provider>
146+
<Tabs.Autoconfig />
147+
</Tabs.Provider>
148+
);
149+
}
150+
151+
const styles = StyleSheet.create({
152+
container: {
153+
flex: 1,
154+
},
155+
containerCenter: {
156+
flex: 1,
157+
alignItems: 'center',
158+
justifyContent: 'center',
159+
},
160+
content: {
161+
padding: 20,
162+
paddingTop: Platform.OS === 'android' ? 60 : undefined,
163+
},
164+
heading: {
165+
fontSize: 24,
166+
fontWeight: 'bold',
167+
marginBottom: 5,
168+
},
169+
description: {
170+
marginBottom: 5,
171+
},
172+
rtlInfo: {
173+
fontWeight: 'bold',
174+
textAlign: 'center',
175+
marginVertical: 5,
176+
},
177+
section: {
178+
marginBottom: 10,
179+
},
180+
});

common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.cpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,21 @@ Point RNSTabsBottomAccessoryShadowNode::getContentOriginOffset(
1111
return stateData.contentOffset;
1212
}
1313

14+
void RNSTabsBottomAccessoryShadowNode::layout(
15+
facebook::react::LayoutContext layoutContext) {
16+
YogaLayoutableShadowNode::layout(layoutContext);
17+
applyFrameCorrections();
18+
}
19+
20+
// When calculating content origin offset for bottom accessory we rely on the
21+
// fact that it's positioned at (0,0). In RTL, this is not the case. As we don't
22+
// want to change `direction` (as this change would propagate further down the
23+
// hierarchy), we force x=0 in the shadow node. If this approach turns out to be
24+
// problematic, we can consider adjusting content origin offset to account for
25+
// the "incorrect" layout in RTL.
26+
void RNSTabsBottomAccessoryShadowNode::applyFrameCorrections() {
27+
ensureUnsealed();
28+
layoutMetrics_.frame.origin.x = 0;
29+
}
30+
1431
} // namespace facebook::react

common/cpp/react/renderer/components/rnscreens/RNSTabsBottomAccessoryShadowNode.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include <react/renderer/components/rnscreens/EventEmitters.h>
55
#include <react/renderer/components/rnscreens/Props.h>
66
#include <react/renderer/components/view/ConcreteViewShadowNode.h>
7+
#include <react/renderer/core/LayoutContext.h>
78
#include "RNSTabsBottomAccessoryState.h"
89

910
namespace facebook::react {
@@ -20,7 +21,15 @@ class JSI_EXPORT RNSTabsBottomAccessoryShadowNode final
2021
using ConcreteViewShadowNode::ConcreteViewShadowNode;
2122
using StateData = ConcreteViewShadowNode::ConcreteStateData;
2223

24+
#pragma mark - ShadowNode overrides
25+
2326
Point getContentOriginOffset(bool includeTransform) const override;
27+
28+
void layout(LayoutContext layoutContext) override;
29+
30+
#pragma mark - Custom interface
31+
private:
32+
void applyFrameCorrections();
2433
};
2534

2635
} // namespace facebook::react

ios/conversion/RNSConversions-Tabs.mm

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,23 @@ UIUserInterfaceStyle UIUserInterfaceStyleFromTabsScreenCppEquivalent(
484484
}
485485
}
486486

487+
UITraitEnvironmentLayoutDirection UITraitEnvironmentLayoutDirectionFromTabsHostCppEquivalent(
488+
react::RNSTabsHostLayoutDirection layoutDirection)
489+
{
490+
using enum facebook::react::RNSTabsHostLayoutDirection;
491+
switch (layoutDirection) {
492+
case Inherit:
493+
return UITraitEnvironmentLayoutDirectionUnspecified;
494+
case Ltr:
495+
return UITraitEnvironmentLayoutDirectionLeftToRight;
496+
case Rtl:
497+
return UITraitEnvironmentLayoutDirectionRightToLeft;
498+
default:
499+
RCTLogError(@"[RNScreens] unsupported layout direction");
500+
return UITraitEnvironmentLayoutDirectionUnspecified;
501+
}
502+
}
503+
487504
UIUserInterfaceStyle UIUserInterfaceStyleFromHostProp(react::RNSTabsHostColorScheme colorScheme)
488505
{
489506
using enum facebook::react::RNSTabsHostColorScheme;

ios/conversion/RNSConversions.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ UIInterfaceOrientationMask UIInterfaceOrientationMaskFromRNSOrientation(RNSOrien
108108
RNSOrientation RNSOrientationFromUIInterfaceOrientationMask(UIInterfaceOrientationMask orientationMask);
109109
#endif // !TARGET_OS_TV
110110

111+
UITraitEnvironmentLayoutDirection UITraitEnvironmentLayoutDirectionFromTabsHostCppEquivalent(
112+
react::RNSTabsHostLayoutDirection layoutDirection);
113+
111114
UIUserInterfaceStyle UIUserInterfaceStyleFromHostProp(react::RNSTabsHostColorScheme colorScheme);
112115

113116
#pragma mark SplitHost props

ios/tabs/host/RNSTabBarController.h

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,30 @@ NS_ASSUME_NONNULL_BEGIN
118118
*/
119119
- (void)updateOrientation;
120120

121+
/**
122+
* Updates the layout direction based on property on host view.
123+
*
124+
* This method does nothing if the update has not been previously requested.
125+
* If needed, the requested update is performed immediately. If you do not need this, consider just raising an
126+
* appropriate invalidation signal & let the controller decide when to flush the updates.
127+
*
128+
* This method is necessary only on iOS versions prior to 17.
129+
* On iOS 17+, use `traitOverrides.layoutDirection` on the controller directly.
130+
*/
131+
- (void)updateLayoutDirectionBelowIOS17IfNeeded;
132+
133+
/**
134+
* Updates the layout direction based on property on host view.
135+
*
136+
* The requested update is performed immediately. If you do not need this, consider just raising an appropriate
137+
* invalidation signal & let the controller decide when to flush the updates.
138+
*
139+
* This method is necessary only on iOS versions prior to 17.
140+
* On iOS 17+, use `traitOverrides.layoutDirection` on the controller directly.
141+
*
142+
* This method can only be called when `parentViewController` is not nil.
143+
*/
144+
- (void)updateLayoutDirectionBelowIOS17;
121145
@end
122146

123147
#pragma mark - Signals
@@ -161,6 +185,14 @@ NS_ASSUME_NONNULL_BEGIN
161185
*/
162186
@property (nonatomic, readwrite) bool needsOrientationUpdate;
163187

188+
/**
189+
* Tell the controller that some configuration regarding layout direction has changed & it requires update.
190+
*
191+
* This flag is necessary only on iOS versions prior to 17.
192+
* On iOS 17+, use `traitOverrides.layoutDirection` on the controller directly.
193+
*/
194+
@property (nonatomic, readwrite) bool needsLayoutDirectionUpdateBelowIOS17;
195+
164196
@end
165197

166198
NS_ASSUME_NONNULL_END

0 commit comments

Comments
 (0)