Skip to content

Commit 9b0be6b

Browse files
authored
fix(Android, Stack, Fabric): Fix jumping content in nested stack for Fabric (#3442)
## Description This PR introduces a set of fixes and manual layout adjustments to address layout/content jumps in Fabric. I listed them under `Changes` section with some motivations. > [!CAUTION] > This PR aims to resolve the issue only on Fabric; on Paper some additional effort is needed and (potentially) would require adapting `ScreenDummyLayoutHelper` to old arch. The work will be continued in a separate PR. ## Changes - Added `CustomAppBarLayout`: This custom implementation triggers a layout refresh on the `AppBarLayout` when we apply padding adjustments to the `CustomToolbar`. `AppBarLayout` just dispatches insets to `Toolbar`; therefore, I'm still handling them at the `Toolbar` level. - Ensured early initialization of `ActionMenuView` in the `Toolbar`: Initializing the `ActionMenuView` early in the layout process prevents inconsistencies and avoids fallback to the system default toolbar height in `onMeasure`, ensuring a consistent layout. - Manual shadow offset correction in the `Screen`: I'm applying some temporary padding corrections at the `Screen` level until insets are applied. - Layout correction for `ScreenDummyLayoutHelper`: A similar early correction approach is used for `ScreenDummyLayoutHelper` to keep the consistency for the header size before full layout pass (this one with insets applied) is complete. - Added `DecorViewInsetHelper`: React's layout arrives before insets have been applied, leading to content jumps. To counter this, we rely on insets available from the `DecorView` (child of `RootView`). A dedicated helper class extracts these inset values. Additionally, it ensures that the insets are applied only on the topmost Screen with Toolbar, preventing the unnecessary padding on the nested Toolbars. ## Screenshots / GIFs Here you can add screenshots / GIFs documenting your change. You can add before / after section if you're changing some behavior. ## Fabric ### Before <table> <tr> <td width="50%"> <video src="https://github.com/user-attachments/assets/8c16b90c-2809-4ed3-8a8e-f2fc2a4a3827"></video> </td> <td width="50%"> <video src="https://github.com/user-attachments/assets/12e82a10-1ace-462a-a1cb-3fed9b17d246"></video> </td> </tr> </table> ### After <table> <tr> <td width="50%"> <video src="https://github.com/user-attachments/assets/1706b298-5a93-4175-acec-832341598db8"></video> </td> <td width="50%"> <video src="https://github.com/user-attachments/assets/596c6797-94a0-49aa-913e-42f80036a22d"></video> </td> </tr> </table> ## Test code and steps to reproduce Added Test3006, tested with API levels above and below 30, with both Status Bar and Display Cutout. As mentioned earlier only testing on FabricExample is in the scope of this PR. ## Checklist - [x] Included code example that can be used to test this change - [x] Ensured that CI passes
1 parent 92a0048 commit 9b0be6b

File tree

8 files changed

+226
-5
lines changed

8 files changed

+226
-5
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.swmansion.rnscreens
2+
3+
import android.annotation.SuppressLint
4+
import android.content.Context
5+
import com.google.android.material.appbar.AppBarLayout
6+
7+
@SuppressLint("ViewConstructor")
8+
class CustomAppBarLayout(
9+
context: Context,
10+
) : AppBarLayout(context) {
11+
/**
12+
* Handles the layout correction from the child Toolbar.
13+
*/
14+
internal fun applyToolbarLayoutCorrection(toolbarPaddingTop: Int) {
15+
applyFrameCorrectionByTopInset(toolbarPaddingTop)
16+
}
17+
18+
private fun applyFrameCorrectionByTopInset(topInset: Int) {
19+
measure(
20+
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
21+
MeasureSpec.makeMeasureSpec(height + topInset, MeasureSpec.EXACTLY),
22+
)
23+
layout(left, top, right, bottom + topInset)
24+
}
25+
}

android/src/main/java/com/swmansion/rnscreens/CustomToolbar.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,31 @@ open class CustomToolbar(
3434

3535
private val shouldApplyTopInset = true
3636

37+
private var shouldApplyLayoutCorrectionForTopInset = false
38+
3739
private var lastInsets = InsetsCompat.NONE
3840

3941
private var isForceShadowStateUpdateOnLayoutRequested = false
4042

4143
private var isLayoutEnqueued = false
44+
45+
init {
46+
// Ensure ActionMenuView is initialized as soon as the Toolbar is created.
47+
//
48+
// Android measures Toolbar height based on the tallest child view.
49+
// During the first measurement:
50+
// 1. The Toolbar is created but not yet added to the action bar via `activity.setSupportActionBar(toolbar)`
51+
// (typically called in `onUpdate` method from `ScreenStackHeaderConfig`).
52+
// 2. At this moment, the title view may exist, but ActionMenuView (which may be taller) hasn't been added yet.
53+
// 3. This causes the initial height calculation to be based on the title view, potentially too small.
54+
// 4. When ActionMenuView is eventually attached, the Toolbar might need to re-layout due to the size change.
55+
//
56+
// By referencing the menu here, we trigger `ensureMenu`, which creates and attaches ActionMenuView early.
57+
// This guarantees that all size-dependent children are present during the first layout pass,
58+
// resulting in correct height determination from the beginning.
59+
menu
60+
}
61+
4262
private val layoutCallback: Choreographer.FrameCallback =
4363
object : Choreographer.FrameCallback {
4464
override fun doFrame(frameTimeNanos: Long) {
@@ -55,6 +75,17 @@ open class CustomToolbar(
5575

5676
override fun requestLayout() {
5777
super.requestLayout()
78+
79+
val maybeAppBarLayout = parent as? CustomAppBarLayout
80+
maybeAppBarLayout?.let {
81+
if (shouldApplyLayoutCorrectionForTopInset && !it.isInLayout) {
82+
// In `applyToolbarLayoutCorrection`, we call and immediate layout on AppBarLayout
83+
// to update it right away and avoid showing a potentially wrong UI state.
84+
it.applyToolbarLayoutCorrection(paddingTop)
85+
shouldApplyLayoutCorrectionForTopInset = false
86+
}
87+
}
88+
5889
val softInputMode =
5990
(context as ThemedReactContext)
6091
.currentActivity
@@ -161,6 +192,7 @@ open class CustomToolbar(
161192
right: Int,
162193
bottom: Int,
163194
) {
195+
shouldApplyLayoutCorrectionForTopInset = true
164196
requestForceShadowStateUpdateOnLayout()
165197
setPadding(left, top, right, bottom)
166198
}

android/src/main/java/com/swmansion/rnscreens/Screen.kt

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import android.util.SparseArray
88
import android.view.MotionEvent
99
import android.view.View
1010
import android.view.ViewGroup
11+
import android.view.WindowInsets
1112
import android.view.WindowManager
1213
import android.webkit.WebView
1314
import android.widget.ImageView
@@ -35,6 +36,7 @@ import com.swmansion.rnscreens.events.SheetDetentChangedEvent
3536
import com.swmansion.rnscreens.ext.asScreenStackFragment
3637
import com.swmansion.rnscreens.ext.parentAsViewGroup
3738
import com.swmansion.rnscreens.gamma.common.FragmentProviding
39+
import com.swmansion.rnscreens.utils.getDecorViewTopInset
3840
import kotlin.math.max
3941

4042
@SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated.
@@ -52,6 +54,8 @@ class Screen(
5254
val reactEventDispatcher: EventDispatcher?
5355
get() = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id)
5456

57+
var insetsApplied = false
58+
5559
var fragmentWrapper: ScreenFragmentWrapper? = null
5660
var container: ScreenContainer? = null
5761
var activityState: ActivityState? = null
@@ -191,7 +195,20 @@ class Screen(
191195
val width = r - l
192196
val height = b - t
193197

194-
dispatchShadowStateUpdate(width, height, t)
198+
if (!insetsApplied && headerConfig?.isHeaderHidden == false && headerConfig?.isHeaderTranslucent == false) {
199+
val topLevelDecorView =
200+
requireNotNull(
201+
reactContext.currentActivity?.window?.decorView,
202+
) { "[RNScreens] DecorView is required for applying inset correction, but was null." }
203+
204+
val topInset = getDecorViewTopInset(topLevelDecorView)
205+
val correctedHeight = height - topInset
206+
val correctedOffsetY = t + topInset
207+
208+
dispatchShadowStateUpdate(width, correctedHeight, correctedOffsetY)
209+
} else {
210+
dispatchShadowStateUpdate(width, height, t)
211+
}
195212
}
196213
}
197214

@@ -502,6 +519,12 @@ class Screen(
502519
}
503520
}
504521

522+
override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets? {
523+
insetsApplied = true
524+
525+
return super.onApplyWindowInsets(insets)
526+
}
527+
505528
override fun onAttachedToWindow() {
506529
super.onAttachedToWindow()
507530

android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class KeyboardVisible(
5252
class ScreenStackFragment :
5353
ScreenFragment,
5454
ScreenStackFragmentWrapper {
55-
private var appBarLayout: AppBarLayout? = null
55+
private var appBarLayout: CustomAppBarLayout? = null
5656
private var toolbar: Toolbar? = null
5757
private var isToolbarShadowHidden = false
5858
private var isToolbarTranslucent = false
@@ -204,7 +204,7 @@ class ScreenStackFragment :
204204

205205
if (!screen.usesFormSheetPresentation()) {
206206
appBarLayout =
207-
context?.let { AppBarLayout(it) }?.apply {
207+
context?.let { CustomAppBarLayout(it) }?.apply {
208208
// By default AppBarLayout will have a background color set but since we cover the whole layout
209209
// with toolbar (that can be semi-transparent) the bar layout background color does not pay a
210210
// role. On top of that it breaks screens animations when alfa offscreen compositing is off
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.swmansion.rnscreens.utils
2+
3+
import android.view.View
4+
import androidx.core.view.ViewCompat
5+
import androidx.core.view.WindowInsetsCompat
6+
7+
/**
8+
* Retrieves the top system inset (such as status bar or display cutout) from the given decor view.
9+
*
10+
* @param decorView The top-level window decor view.
11+
* @return The top inset in pixels.
12+
*/
13+
internal fun getDecorViewTopInset(decorView: View): Int {
14+
val insetsCompat = ViewCompat.getRootWindowInsets(decorView) ?: return 0
15+
16+
return getTopInset(insetsCompat)
17+
}
18+
19+
private fun getTopInset(insetsCompat: WindowInsetsCompat): Int =
20+
insetsCompat
21+
.getInsets(
22+
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
23+
).top

android/src/main/java/com/swmansion/rnscreens/utils/ScreenDummyLayoutHelper.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,9 @@ internal class ScreenDummyLayoutHelper(
179179
}
180180

181181
val topLevelDecorView = requireActivity().window.decorView
182+
val topInset = getDecorViewTopInset(topLevelDecorView)
182183

183-
// These dimensions are not accurate, as they do include status bar & navigation bar, however
184+
// These dimensions are not accurate, as they do include navigation bar, however
184185
// it is ok for our purposes.
185186
val decorViewWidth = topLevelDecorView.width
186187
val decorViewHeight = topLevelDecorView.height
@@ -208,7 +209,10 @@ internal class ScreenDummyLayoutHelper(
208209
// scenarios when layout violates measured dimensions.
209210
coordinatorLayout.layout(0, 0, decorViewWidth, decorViewHeight)
210211

211-
val headerHeight = PixelUtil.toDIPFromPixel(appBarLayout.height.toFloat())
212+
// Include the top inset to account for the extra padding manually applied to the CustomToolbar.
213+
val totalAppBarLayoutHeight = appBarLayout.height.toFloat() + topInset
214+
215+
val headerHeight = PixelUtil.toDIPFromPixel(totalAppBarLayoutHeight)
212216
cache = CacheEntry(CacheKey(fontSize, isTitleEmpty), headerHeight)
213217
return headerHeight
214218
}

apps/src/tests/Test3006.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React from 'react';
2+
import { View, Text, StyleSheet, Button } from 'react-native';
3+
import { NavigationContainer } from '@react-navigation/native';
4+
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
5+
import { createNativeStackNavigator } from '@react-navigation/native-stack';
6+
import { SafeAreaProvider } from 'react-native-safe-area-context';
7+
import Colors from '../shared/styling/Colors';
8+
9+
type RootTabParamList = {
10+
First: undefined;
11+
Second: undefined;
12+
Third: undefined;
13+
};
14+
15+
type InnerScreenName =
16+
| 'Inner11' | 'Inner12' | 'Inner13'
17+
| 'Inner21' | 'Inner22' | 'Inner23'
18+
| 'Inner31' | 'Inner32' | 'Inner33';
19+
20+
const Tab = createBottomTabNavigator<RootTabParamList>();
21+
const Stack = createNativeStackNavigator<any>();
22+
23+
type ScreenProps = {
24+
current: InnerScreenName;
25+
next?: InnerScreenName;
26+
};
27+
28+
const Screen = ({ current, next, navigation }: ScreenProps & { navigation: any }) => (
29+
<View style={styles.container}>
30+
<Text>{`${current} Screen`}</Text>
31+
{next && (
32+
<Button title="Forward" onPress={() => navigation.navigate(next)} />
33+
)}
34+
</View>
35+
);
36+
37+
const createScreen = (current: InnerScreenName, next?: InnerScreenName) =>
38+
function ScreenWrapper({ navigation }: { navigation: any }) {
39+
return <Screen current={current} next={next} navigation={navigation} />;
40+
};
41+
42+
const screenGroups: Record<string, { screens: InnerScreenName[]; headerColor: string; contentColor: string }> = {
43+
First: {
44+
screens: ['Inner11', 'Inner12', 'Inner13'],
45+
headerColor: Colors.BlueLight80,
46+
contentColor: Colors.BlueLight40,
47+
},
48+
Second: {
49+
screens: ['Inner21', 'Inner22', 'Inner23'],
50+
headerColor: Colors.GreenLight80,
51+
contentColor: Colors.GreenLight40,
52+
},
53+
Third: {
54+
screens: ['Inner31', 'Inner32', 'Inner33'],
55+
headerColor: Colors.YellowLight80,
56+
contentColor: Colors.YellowLight40,
57+
},
58+
};
59+
60+
const createStack = (groupKey: keyof typeof screenGroups) => {
61+
const { screens, headerColor, contentColor } = screenGroups[groupKey];
62+
63+
return () => (
64+
<Stack.Navigator
65+
screenOptions={{
66+
title: groupKey,
67+
headerStyle: { backgroundColor: headerColor },
68+
contentStyle: { backgroundColor: contentColor },
69+
}}
70+
>
71+
{screens.map((screen, index) => {
72+
const nextScreen = screens[index + 1];
73+
return (
74+
<Stack.Screen
75+
key={screen}
76+
name={screen}
77+
component={createScreen(screen, nextScreen)}
78+
/>
79+
);
80+
})}
81+
</Stack.Navigator>
82+
);
83+
};
84+
85+
const tabOptions = {
86+
tabBarIcon: () => null,
87+
};
88+
89+
const RootTabs = () => (
90+
<Tab.Navigator screenOptions={{ headerShown: false }}>
91+
<Tab.Screen name="First" component={createStack('First')} options={tabOptions} />
92+
<Tab.Screen name="Second" component={createStack('Second')} options={tabOptions} />
93+
<Tab.Screen name="Third" component={createStack('Third')} options={tabOptions} />
94+
</Tab.Navigator>
95+
);
96+
97+
export default function App() {
98+
return (
99+
<SafeAreaProvider>
100+
<NavigationContainer>
101+
<RootTabs />
102+
</NavigationContainer>
103+
</SafeAreaProvider>
104+
);
105+
}
106+
107+
const styles = StyleSheet.create({
108+
container: {
109+
flex: 1,
110+
alignItems: 'center',
111+
justifyContent: 'center',
112+
},
113+
});

apps/src/tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ export { default as Test2926 } from './Test2926'; // [E2E created](iOS): PR rela
143143
export { default as Test2949 } from './Test2949'; // [E2E skipped]: can't check system bars styles
144144
export { default as Test2963 } from './Test2963'; // [E2E created](iOS): issue related to iOS
145145
export { default as Test3004 } from './Test3004';
146+
export { default as Test3006 } from './Test3006';
146147
export { default as Test3045 } from './Test3045';
147148
export { default as Test3074 } from './Test3074';
148149
export { default as Test3093 } from './Test3093';

0 commit comments

Comments
 (0)