Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.swmansion.rnscreens

import android.annotation.SuppressLint
import android.content.Context
import com.google.android.material.appbar.AppBarLayout

@SuppressLint("ViewConstructor")
class CustomAppBarLayout(
context: Context,
) : AppBarLayout(context) {
/**
* Handles the layout correction from the child Toolbar.
*/
internal fun applyToolbarLayoutCorrection(toolbarPaddingTop: Int) {
applyFrameCorrectionByTopInset(toolbarPaddingTop)
}

private fun applyFrameCorrectionByTopInset(topInset: Int) {
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height + topInset, MeasureSpec.EXACTLY),
)
layout(left, top, right, bottom + topInset)
}
}
32 changes: 32 additions & 0 deletions android/src/main/java/com/swmansion/rnscreens/CustomToolbar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,31 @@ open class CustomToolbar(

private val shouldApplyTopInset = true

private var shouldApplyLayoutCorrectionForTopInset = false

private var lastInsets = InsetsCompat.NONE

private var isForceShadowStateUpdateOnLayoutRequested = false

private var isLayoutEnqueued = false

init {
// Ensure ActionMenuView is initialized as soon as the Toolbar is created.
//
// Android measures Toolbar height based on the tallest child view.
// During the first measurement:
// 1. The Toolbar is created but not yet added to the action bar via `activity.setSupportActionBar(toolbar)`
// (typically called in `onUpdate` method from `ScreenStackHeaderConfig`).
// 2. At this moment, the title view may exist, but ActionMenuView (which may be taller) hasn't been added yet.
// 3. This causes the initial height calculation to be based on the title view, potentially too small.
// 4. When ActionMenuView is eventually attached, the Toolbar might need to re-layout due to the size change.
//
// By referencing the menu here, we trigger `ensureMenu`, which creates and attaches ActionMenuView early.
// This guarantees that all size-dependent children are present during the first layout pass,
// resulting in correct height determination from the beginning.
menu
}

private val layoutCallback: Choreographer.FrameCallback =
object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
Expand All @@ -55,6 +75,17 @@ open class CustomToolbar(

override fun requestLayout() {
super.requestLayout()

val maybeAppBarLayout = parent as? CustomAppBarLayout
maybeAppBarLayout?.let {
if (shouldApplyLayoutCorrectionForTopInset && !it.isInLayout) {
// In `applyToolbarLayoutCorrection`, we call and immediate layout on AppBarLayout
// to update it right away and avoid showing a potentially wrong UI state.
it.applyToolbarLayoutCorrection(paddingTop)
shouldApplyLayoutCorrectionForTopInset = false
}
}

val softInputMode =
(context as ThemedReactContext)
.currentActivity
Expand Down Expand Up @@ -161,6 +192,7 @@ open class CustomToolbar(
right: Int,
bottom: Int,
) {
shouldApplyLayoutCorrectionForTopInset = true
requestForceShadowStateUpdateOnLayout()
setPadding(left, top, right, bottom)
}
Expand Down
25 changes: 24 additions & 1 deletion android/src/main/java/com/swmansion/rnscreens/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.util.SparseArray
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowManager
import android.webkit.WebView
import android.widget.ImageView
Expand Down Expand Up @@ -35,6 +36,7 @@ import com.swmansion.rnscreens.events.SheetDetentChangedEvent
import com.swmansion.rnscreens.ext.asScreenStackFragment
import com.swmansion.rnscreens.ext.parentAsViewGroup
import com.swmansion.rnscreens.gamma.common.FragmentProviding
import com.swmansion.rnscreens.utils.getDecorViewTopInset
import kotlin.math.max

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

var insetsApplied = false

var fragmentWrapper: ScreenFragmentWrapper? = null
var container: ScreenContainer? = null
var activityState: ActivityState? = null
Expand Down Expand Up @@ -191,7 +195,20 @@ class Screen(
val width = r - l
val height = b - t

dispatchShadowStateUpdate(width, height, t)
if (!insetsApplied && headerConfig?.isHeaderHidden == false && headerConfig?.isHeaderTranslucent == false) {
val topLevelDecorView =
requireNotNull(
reactContext.currentActivity?.window?.decorView,
) { "[RNScreens] DecorView is required for applying inset correction, but was null." }

val topInset = getDecorViewTopInset(topLevelDecorView)
val correctedHeight = height - topInset
val correctedOffsetY = t + topInset

dispatchShadowStateUpdate(width, correctedHeight, correctedOffsetY)
} else {
dispatchShadowStateUpdate(width, height, t)
}
}
}

Expand Down Expand Up @@ -502,6 +519,12 @@ class Screen(
}
}

override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets? {
insetsApplied = true

return super.onApplyWindowInsets(insets)
}

override fun onAttachedToWindow() {
super.onAttachedToWindow()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ class KeyboardVisible(
class ScreenStackFragment :
ScreenFragment,
ScreenStackFragmentWrapper {
private var appBarLayout: AppBarLayout? = null
private var appBarLayout: CustomAppBarLayout? = null
private var toolbar: Toolbar? = null
private var isToolbarShadowHidden = false
private var isToolbarTranslucent = false
Expand Down Expand Up @@ -204,7 +204,7 @@ class ScreenStackFragment :

if (!screen.usesFormSheetPresentation()) {
appBarLayout =
context?.let { AppBarLayout(it) }?.apply {
context?.let { CustomAppBarLayout(it) }?.apply {
// By default AppBarLayout will have a background color set but since we cover the whole layout
// with toolbar (that can be semi-transparent) the bar layout background color does not pay a
// role. On top of that it breaks screens animations when alfa offscreen compositing is off
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.swmansion.rnscreens.utils

import android.view.View
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

/**
* Retrieves the top system inset (such as status bar or display cutout) from the given decor view.
*
* @param decorView The top-level window decor view.
* @return The top inset in pixels.
*/
internal fun getDecorViewTopInset(decorView: View): Int {
val insetsCompat = ViewCompat.getRootWindowInsets(decorView) ?: return 0

return getTopInset(insetsCompat)
}

private fun getTopInset(insetsCompat: WindowInsetsCompat): Int =
insetsCompat
.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
).top
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,9 @@ internal class ScreenDummyLayoutHelper(
}

val topLevelDecorView = requireActivity().window.decorView
val topInset = getDecorViewTopInset(topLevelDecorView)

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

val headerHeight = PixelUtil.toDIPFromPixel(appBarLayout.height.toFloat())
// Include the top inset to account for the extra padding manually applied to the CustomToolbar.
val totalAppBarLayoutHeight = appBarLayout.height.toFloat() + topInset

val headerHeight = PixelUtil.toDIPFromPixel(totalAppBarLayoutHeight)
cache = CacheEntry(CacheKey(fontSize, isTitleEmpty), headerHeight)
return headerHeight
}
Expand Down
113 changes: 113 additions & 0 deletions apps/src/tests/Test3006.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React from 'react';
import { View, Text, StyleSheet, Button } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import Colors from '../shared/styling/Colors';

type RootTabParamList = {
First: undefined;
Second: undefined;
Third: undefined;
};

type InnerScreenName =
| 'Inner11' | 'Inner12' | 'Inner13'
| 'Inner21' | 'Inner22' | 'Inner23'
| 'Inner31' | 'Inner32' | 'Inner33';

const Tab = createBottomTabNavigator<RootTabParamList>();
const Stack = createNativeStackNavigator<any>();

type ScreenProps = {
current: InnerScreenName;
next?: InnerScreenName;
};

const Screen = ({ current, next, navigation }: ScreenProps & { navigation: any }) => (
<View style={styles.container}>
<Text>{`${current} Screen`}</Text>
{next && (
<Button title="Forward" onPress={() => navigation.navigate(next)} />
)}
</View>
);

const createScreen = (current: InnerScreenName, next?: InnerScreenName) =>
function ScreenWrapper({ navigation }: { navigation: any }) {
return <Screen current={current} next={next} navigation={navigation} />;
};

const screenGroups: Record<string, { screens: InnerScreenName[]; headerColor: string; contentColor: string }> = {
First: {
screens: ['Inner11', 'Inner12', 'Inner13'],
headerColor: Colors.BlueLight80,
contentColor: Colors.BlueLight40,
},
Second: {
screens: ['Inner21', 'Inner22', 'Inner23'],
headerColor: Colors.GreenLight80,
contentColor: Colors.GreenLight40,
},
Third: {
screens: ['Inner31', 'Inner32', 'Inner33'],
headerColor: Colors.YellowLight80,
contentColor: Colors.YellowLight40,
},
};

const createStack = (groupKey: keyof typeof screenGroups) => {
const { screens, headerColor, contentColor } = screenGroups[groupKey];

return () => (
<Stack.Navigator
screenOptions={{
title: groupKey,
headerStyle: { backgroundColor: headerColor },
contentStyle: { backgroundColor: contentColor },
}}
>
{screens.map((screen, index) => {
const nextScreen = screens[index + 1];
return (
<Stack.Screen
key={screen}
name={screen}
component={createScreen(screen, nextScreen)}
/>
);
})}
</Stack.Navigator>
);
};

const tabOptions = {
tabBarIcon: () => null,
};

const RootTabs = () => (
<Tab.Navigator screenOptions={{ headerShown: false }}>
<Tab.Screen name="First" component={createStack('First')} options={tabOptions} />
<Tab.Screen name="Second" component={createStack('Second')} options={tabOptions} />
<Tab.Screen name="Third" component={createStack('Third')} options={tabOptions} />
</Tab.Navigator>
);

export default function App() {
return (
<SafeAreaProvider>
<NavigationContainer>
<RootTabs />
</NavigationContainer>
</SafeAreaProvider>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
1 change: 1 addition & 0 deletions apps/src/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export { default as Test2926 } from './Test2926'; // [E2E created](iOS): PR rela
export { default as Test2949 } from './Test2949'; // [E2E skipped]: can't check system bars styles
export { default as Test2963 } from './Test2963'; // [E2E created](iOS): issue related to iOS
export { default as Test3004 } from './Test3004';
export { default as Test3006 } from './Test3006';
export { default as Test3045 } from './Test3045';
export { default as Test3074 } from './Test3074';
export { default as Test3093 } from './Test3093';
Expand Down
Loading