From d9961192ed3c2fe0d8982b573d4ace9fafe76b38 Mon Sep 17 00:00:00 2001 From: HakimMohamed Date: Thu, 16 Apr 2026 16:32:35 +0200 Subject: [PATCH 1/3] fix(Android): RTL layout for center-aligned custom header title The shadow tree expects physical left/right padding, but the toolbar APIs return logical start/end values which swap sides in RTL. This caused the center-aligned custom header title (headerTitle as function) to receive near-zero width in RTL, making it invisible. Fixes #3438 --- .../rnscreens/ScreenStackHeaderConfig.kt | 32 ++++++---- apps/src/tests/issue-tests/Test3438.tsx | 58 +++++++++++++++++++ apps/src/tests/issue-tests/index.ts | 1 + 3 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 apps/src/tests/issue-tests/Test3438.tsx diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt index 8ab37c8869..b5f522ecf9 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt @@ -138,30 +138,40 @@ class ScreenStackHeaderConfig( } val isBackButtonDisplayed = toolbar.navigationIcon != null + val isRtl = toolbar.layoutDirection == LAYOUT_DIRECTION_RTL - val contentInsetStartEstimation = + val startInsetEstimation = if (isBackButtonDisplayed) { toolbar.currentContentInsetStart + toolbar.paddingStart } else { max(toolbar.currentContentInsetStart, toolbar.paddingStart) } - // Assuming that there is nothing to the left of back button here, the content - // offset we're interested in in ShadowTree is the `left` of the subview left. - // In case it is not available we fallback to approximation. - val contentInsetStart = - configSubviews.firstOrNull { it.type === ScreenStackHeaderSubview.Type.LEFT }?.left - ?: contentInsetStartEstimation - - val contentInsetEnd = toolbar.currentContentInsetEnd + toolbar.paddingEnd + val endInset = toolbar.currentContentInsetEnd + toolbar.paddingEnd + val leftSubview = + configSubviews.firstOrNull { it.type === ScreenStackHeaderSubview.Type.LEFT } + + // The shadow tree expects physical left/right padding, but the toolbar APIs + // return logical start/end values which swap sides in RTL. + val paddingLeft: Int + val paddingRight: Int + + if (!isRtl) { + paddingLeft = leftSubview?.left ?: startInsetEstimation + paddingRight = endInset + } else { + paddingLeft = endInset + paddingRight = + if (leftSubview != null) toolbar.width - leftSubview.left else startInsetEstimation + } headerHeightUpdateProxy.updateHeaderHeightIfNeeded(this, screen) updateHeaderConfigState( toolbar.width, toolbar.height, - contentInsetStart, - contentInsetEnd, + paddingLeft, + paddingRight, ) } diff --git a/apps/src/tests/issue-tests/Test3438.tsx b/apps/src/tests/issue-tests/Test3438.tsx new file mode 100644 index 0000000000..4d5000f61c --- /dev/null +++ b/apps/src/tests/issue-tests/Test3438.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { View, Text, StyleSheet, I18nManager } from 'react-native'; +import { NavigationContainer } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; + +const Stack = createNativeStackNavigator(); + +function HomeScreen() { + return ( + + Issue #3438 + headerTitleAlign: center + headerTitle as function + RTL mode: {I18nManager.isRTL ? 'YES' : 'NO'} + + The header title above should say "Custom Title". + {'\n'}If it's missing or there's extra space, the bug is present. + + + ); +} + +function App() { + return ( + + + ( + + Custom Title + + ), + }} + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + gap: 8, + }, + note: { + textAlign: 'center', + marginTop: 20, + color: 'gray', + }, +}); + +export default App; diff --git a/apps/src/tests/issue-tests/index.ts b/apps/src/tests/issue-tests/index.ts index 495dd4d88c..dfa2329973 100644 --- a/apps/src/tests/issue-tests/index.ts +++ b/apps/src/tests/issue-tests/index.ts @@ -167,6 +167,7 @@ export { default as Test3369 } from './Test3369'; export { default as Test3379 } from './Test3379'; export { default as Test3422 } from './Test3422'; export { default as Test3425 } from './Test3425'; +export { default as Test3438 } from './Test3438'; export { default as Test3443 } from './Test3443'; export { default as Test3446 } from './Test3446'; export { default as Test3450 } from './Test3450'; From a0fa71bb485a121c7f96a836a50271bb4a62619b Mon Sep 17 00:00:00 2001 From: HakimMohamed Date: Fri, 17 Apr 2026 13:18:30 +0200 Subject: [PATCH 2/3] revert unnecessary variable renames per review feedback --- .../rnscreens/ScreenStackHeaderConfig.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt index b5f522ecf9..b0d1a8a541 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt @@ -140,29 +140,29 @@ class ScreenStackHeaderConfig( val isBackButtonDisplayed = toolbar.navigationIcon != null val isRtl = toolbar.layoutDirection == LAYOUT_DIRECTION_RTL - val startInsetEstimation = + val contentInsetStartEstimation = if (isBackButtonDisplayed) { toolbar.currentContentInsetStart + toolbar.paddingStart } else { max(toolbar.currentContentInsetStart, toolbar.paddingStart) } - val endInset = toolbar.currentContentInsetEnd + toolbar.paddingEnd + val contentInsetEnd = toolbar.currentContentInsetEnd + toolbar.paddingEnd val leftSubview = configSubviews.firstOrNull { it.type === ScreenStackHeaderSubview.Type.LEFT } // The shadow tree expects physical left/right padding, but the toolbar APIs // return logical start/end values which swap sides in RTL. - val paddingLeft: Int - val paddingRight: Int + val contentInsetLeft: Int + val contentInsetRight: Int if (!isRtl) { - paddingLeft = leftSubview?.left ?: startInsetEstimation - paddingRight = endInset + contentInsetLeft = leftSubview?.left ?: contentInsetStartEstimation + contentInsetRight = contentInsetEnd } else { - paddingLeft = endInset - paddingRight = - if (leftSubview != null) toolbar.width - leftSubview.left else startInsetEstimation + contentInsetLeft = contentInsetEnd + contentInsetRight = + if (leftSubview != null) toolbar.width - leftSubview.left else contentInsetStartEstimation } headerHeightUpdateProxy.updateHeaderHeightIfNeeded(this, screen) @@ -170,8 +170,8 @@ class ScreenStackHeaderConfig( updateHeaderConfigState( toolbar.width, toolbar.height, - paddingLeft, - paddingRight, + contentInsetLeft, + contentInsetRight, ) } From 14096fb620eb3d59256a0e04f05680d685e051c2 Mon Sep 17 00:00:00 2001 From: HakimMohamed Date: Fri, 17 Apr 2026 13:54:35 +0200 Subject: [PATCH 3/3] use Yoga logical padding (Edge::Start/End) on shadow node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously paddingStart/paddingEnd were sent to setPadding() which maps to physical Edge::Left/Right — so the values were treated as physical, not logical, and Yoga never swapped them in RTL. Introduce setLogicalPadding() on the shadow node that sets padding on Edge::Start/End, letting Yoga handle RTL direction on its own. The Kotlin side now only converts leftSubview.left (physical) to a logical start-side distance in RTL; the rest is physical-free. --- .../rnscreens/ScreenStackHeaderConfig.kt | 28 +++++------- apps/src/tests/issue-tests/Test3438.tsx | 43 ++++++++++++++++--- ...reenStackHeaderConfigComponentDescriptor.h | 8 +--- .../RNSScreenStackHeaderConfigShadowNode.cpp | 12 ++++++ .../RNSScreenStackHeaderConfigShadowNode.h | 2 + 5 files changed, 63 insertions(+), 30 deletions(-) diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt index b0d1a8a541..3eb6cc66e1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackHeaderConfig.kt @@ -147,31 +147,25 @@ class ScreenStackHeaderConfig( max(toolbar.currentContentInsetStart, toolbar.paddingStart) } - val contentInsetEnd = toolbar.currentContentInsetEnd + toolbar.paddingEnd - val leftSubview = + // Assuming that there is nothing to the left of back button here, the content + // offset we're interested in in ShadowTree is the `left` of the subview left. + // In case it is not available we fallback to approximation. + // In RTL, the LEFT subview (Gravity.START) sits on the physical right, so we + // convert its physical `left` to a logical start-side distance. + val contentInsetStart = configSubviews.firstOrNull { it.type === ScreenStackHeaderSubview.Type.LEFT } + ?.let { if (!isRtl) it.left else toolbar.width - it.left } + ?: contentInsetStartEstimation - // The shadow tree expects physical left/right padding, but the toolbar APIs - // return logical start/end values which swap sides in RTL. - val contentInsetLeft: Int - val contentInsetRight: Int - - if (!isRtl) { - contentInsetLeft = leftSubview?.left ?: contentInsetStartEstimation - contentInsetRight = contentInsetEnd - } else { - contentInsetLeft = contentInsetEnd - contentInsetRight = - if (leftSubview != null) toolbar.width - leftSubview.left else contentInsetStartEstimation - } + val contentInsetEnd = toolbar.currentContentInsetEnd + toolbar.paddingEnd headerHeightUpdateProxy.updateHeaderHeightIfNeeded(this, screen) updateHeaderConfigState( toolbar.width, toolbar.height, - contentInsetLeft, - contentInsetRight, + contentInsetStart, + contentInsetEnd, ) } diff --git a/apps/src/tests/issue-tests/Test3438.tsx b/apps/src/tests/issue-tests/Test3438.tsx index 4d5000f61c..0b2ec80b67 100644 --- a/apps/src/tests/issue-tests/Test3438.tsx +++ b/apps/src/tests/issue-tests/Test3438.tsx @@ -1,20 +1,44 @@ import React from 'react'; -import { View, Text, StyleSheet, I18nManager } from 'react-native'; +import { + View, + Text, + StyleSheet, + I18nManager, + Button, + DevSettings, +} from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; const Stack = createNativeStackNavigator(); +function toggleRTL(forceRTL: boolean) { + if (I18nManager.isRTL === forceRTL) return; + I18nManager.forceRTL(forceRTL); + I18nManager.allowRTL(forceRTL); + DevSettings.reload(); +} + +function HeaderTitle() { + return ( + Custom Title + ); +} + function HomeScreen() { return ( - Issue #3438 + Issue #3438 headerTitleAlign: center + headerTitle as function RTL mode: {I18nManager.isRTL ? 'YES' : 'NO'} The header title above should say "Custom Title". {'\n'}If it's missing or there's extra space, the bug is present. + +