Properly handle android system bars (#9242)

This commit is contained in:
Elias Nahum 2025-10-29 09:11:11 +08:00 committed by GitHub
parent 5d47f062f3
commit c0eaeefbe0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 136 additions and 14 deletions

View file

@ -3,6 +3,7 @@ package com.mattermost.rnbeta
import android.content.res.Configuration
import android.os.Bundle
import android.view.KeyEvent
import androidx.core.view.WindowCompat
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactActivityDelegate
@ -37,6 +38,7 @@ class MainActivity : NavigationActivity() {
setHWKeyboardConnected()
lastOrientation = this.resources.configuration.orientation
foldableObserver.onCreate()
WindowCompat.setDecorFitsSystemWindows(window, false)
}
override fun onStart() {

View file

@ -7,5 +7,14 @@
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
<item name="android:navigationBarDividerColor">@android:color/transparent</item>
<!-- Avoid forced contrast paddings on some OEMs -->
<item name="android:enforceStatusBarContrast">false</item>
<item name="android:enforceNavigationBarContrast">false</item>
<!-- Support cutouts -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
</resources>

View file

@ -3,8 +3,9 @@
/* eslint-disable max-lines */
import RNUtils from '@mattermost/rnutils';
import merge from 'deepmerge';
import {Appearance, DeviceEventEmitter, StatusBar, Platform, Alert, type EmitterSubscription, Keyboard} from 'react-native';
import {Appearance, DeviceEventEmitter, Platform, Alert, type EmitterSubscription, Keyboard, StatusBar} from 'react-native';
import {type ComponentWillAppearEvent, type ImageResource, type LayoutOrientation, Navigation, type Options, OptionsModalPresentationStyle, type OptionsTopBarButton, type ScreenPoppedEvent, type EventSubscription} from 'react-native-navigation';
import tinyColor from 'tinycolor2';
@ -36,6 +37,23 @@ let subscriptions: Array<EmitterSubscription | EventSubscription> | undefined;
export const allOrientations: LayoutOrientation[] = ['sensor', 'sensorLandscape', 'sensorPortrait', 'landscape', 'portrait'];
export const portraitOrientation: LayoutOrientation[] = ['portrait'];
const loginFlowScreens = new Set<AvailableScreens>([
Screens.ONBOARDING,
Screens.SERVER,
Screens.LOGIN,
Screens.SSO,
Screens.MFA,
Screens.FORGOT_PASSWORD,
]);
function setNavigationBarColor(screen: AvailableScreens, th?: Theme) {
if (Platform.OS === 'android' && Platform.Version >= 34) {
const theme = th || getThemeFromState();
const color = loginFlowScreens.has(screen) ? theme.sidebarBg : theme.centerChannelBg;
RNUtils.setNavigationBarColor(color, tinyColor(color).isLight());
}
}
export function registerNavigationListeners() {
subscriptions?.forEach((v) => v.remove());
subscriptions = [
@ -88,14 +106,19 @@ function onCommandListener(name: string, params: any) {
}
}
if (NavigationStore.getVisibleScreen() === Screens.HOME) {
const screen = NavigationStore.getVisibleScreen();
if (screen === Screens.HOME) {
DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true);
}
setNavigationBarColor(screen);
}
function onPoppedListener({componentId}: ScreenPoppedEvent) {
// screen pop does not trigger registerCommandListener, but does trigger screenPoppedListener
NavigationStore.removeScreenFromStack(componentId as AvailableScreens);
const screen = componentId as AvailableScreens;
NavigationStore.removeScreenFromStack(screen);
setNavigationBarColor(screen);
}
function onScreenWillAppear(event: ComponentWillAppearEvent) {
@ -279,6 +302,32 @@ function isScreenRegistered(screen: AvailableScreens) {
return true;
}
function edgeToEdgeHack(screen: AvailableScreens, theme: Theme) {
const isDark = tinyColor(theme.sidebarBg).isDark();
if (Platform.OS === 'android') {
if (Platform.Version >= 34) {
const listener = Navigation.events().registerComponentDidAppearListener((event) => {
if (event.componentName === screen) {
setNavigationBarColor(screen, theme);
listener.remove();
}
});
}
if (Platform.Version >= 36) {
return {
drawBehind: true,
translucent: false,
isDark,
};
}
}
StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content');
return {isDark};
}
export function openToS() {
NavigationStore.setToSOpen(true);
return showOverlay(Screens.TERMS_OF_SERVICE, {}, {overlay: {interceptTouchOutside: true}});
@ -286,8 +335,7 @@ export function openToS() {
export function resetToHome(passProps: LaunchProps = {launchType: Launch.Normal}) {
const theme = getThemeFromState();
const isDark = tinyColor(theme.sidebarBg).isDark();
StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content');
const edgeToEdge = edgeToEdgeHack(Screens.HOME, theme);
if (!passProps.coldStart && (passProps.launchType === Launch.AddServer || passProps.launchType === Launch.AddServerFromDeepLink)) {
dismissModal({componentId: Screens.SERVER});
@ -313,6 +361,7 @@ export function resetToHome(passProps: LaunchProps = {launchType: Launch.Normal}
statusBar: {
visible: true,
backgroundColor: theme.sidebarBg,
...edgeToEdge,
},
topBar: {
visible: false,
@ -337,8 +386,7 @@ export function resetToHome(passProps: LaunchProps = {launchType: Launch.Normal}
export function resetToSelectServer(passProps: LaunchProps) {
const theme = getDefaultThemeByAppearance();
const isDark = tinyColor(theme.sidebarBg).isDark();
StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content');
const edgeToEdge = edgeToEdgeHack(Screens.SERVER, theme);
const children = [{
component: {
@ -356,6 +404,7 @@ export function resetToSelectServer(passProps: LaunchProps) {
statusBar: {
visible: true,
backgroundColor: theme.sidebarBg,
...edgeToEdge,
},
topBar: {
backButton: {
@ -383,8 +432,7 @@ export function resetToSelectServer(passProps: LaunchProps) {
export function resetToOnboarding(passProps: LaunchProps) {
const theme = getDefaultThemeByAppearance();
const isDark = tinyColor(theme.sidebarBg).isDark();
StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content');
const edgeToEdge = edgeToEdgeHack(Screens.ONBOARDING, theme);
const children = [{
component: {
@ -402,6 +450,7 @@ export function resetToOnboarding(passProps: LaunchProps) {
statusBar: {
visible: true,
backgroundColor: theme.sidebarBg,
...edgeToEdge,
},
topBar: {
backButton: {
@ -429,8 +478,7 @@ export function resetToOnboarding(passProps: LaunchProps) {
export function resetToTeams() {
const theme = getThemeFromState();
const isDark = tinyColor(theme.sidebarBg).isDark();
StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content');
const edgeToEdge = edgeToEdgeHack(Screens.SELECT_TEAM, theme);
return Navigation.setRoot({
root: {
@ -446,6 +494,7 @@ export function resetToTeams() {
statusBar: {
visible: true,
backgroundColor: theme.sidebarBg,
...edgeToEdge,
},
topBar: {
visible: false,
@ -472,7 +521,7 @@ export function goToScreen(name: AvailableScreens, title: string, passProps = {}
}
const theme = getThemeFromState();
const isDark = tinyColor(theme.sidebarBg).isDark();
const edgeToEdge = edgeToEdgeHack(name, theme);
const componentId = NavigationStore.getVisibleScreen();
if (!componentId) {
logError('Trying to go to screen without any screen on the navigation store');
@ -489,8 +538,10 @@ export function goToScreen(name: AvailableScreens, title: string, passProps = {}
right: {enabled: false},
},
statusBar: {
style: isDark ? 'light' : 'dark',
style: edgeToEdge.isDark ? 'light' : 'dark',
backgroundColor: theme.sidebarBg,
drawBehind: edgeToEdge.drawBehind ?? false,
translucent: edgeToEdge.translucent ?? false,
},
topBar: {
animate: true,
@ -608,6 +659,7 @@ export function showModal(name: AvailableScreens, title: string, passProps = {},
}
const theme = getThemeFromState();
const edgeToEdge = edgeToEdgeHack(name, theme);
const modalPresentationStyle: OptionsModalPresentationStyle = Platform.OS === 'ios' ? OptionsModalPresentationStyle.pageSheet : OptionsModalPresentationStyle.none;
const defaultOptions: Options = {
modalPresentationStyle,
@ -617,6 +669,7 @@ export function showModal(name: AvailableScreens, title: string, passProps = {},
statusBar: {
visible: true,
backgroundColor: theme.sidebarBg,
...edgeToEdge,
},
topBar: {
animate: true,

View file

@ -1,9 +1,12 @@
package com.mattermost.rnutils
import android.app.Activity
import android.graphics.Color
import android.os.Build
import android.view.WindowManager
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.view.WindowInsetsControllerCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
@ -13,8 +16,13 @@ import com.mattermost.rnutils.helpers.Notifications
import com.mattermost.rnutils.helpers.RealPathUtil
import com.mattermost.rnutils.helpers.SaveDataTask
import com.mattermost.rnutils.helpers.SplitView
import androidx.core.graphics.toColorInt
import com.facebook.react.bridge.LifecycleEventListener
class RNUtilsModuleImpl(private val reactContext: ReactApplicationContext) {
class RNUtilsModuleImpl(private val reactContext: ReactApplicationContext): LifecycleEventListener {
private var lastHex: String? = null
private var lastLightIcons: Boolean = true
companion object {
const val NAME = "RNUtils"
@ -37,8 +45,17 @@ class RNUtilsModuleImpl(private val reactContext: ReactApplicationContext) {
setCtx(reactContext)
SplitView.setCtx(reactContext)
Notifications.setCtx(reactContext)
reactContext.addLifecycleEventListener(this)
}
override fun onHostResume() {
val hex = lastHex ?: return
val light = lastLightIcons
setNavigationBarColor(hex, light)
}
override fun onHostPause() {}
override fun onHostDestroy() {}
fun getTypedExportedConstants(): MutableMap<String, Any> {
val map = mutableMapOf<String, Any>()
val group = mutableMapOf<String, Any>()
@ -213,4 +230,36 @@ class RNUtilsModuleImpl(private val reactContext: ReactApplicationContext) {
currentActivity.window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
}
}
fun setNavigationBarColor(colorHex: String, lightIcons: Boolean) {
val currentActivity: Activity = reactContext.currentActivity ?: return
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
return
}
lastHex = colorHex
lastLightIcons = lightIcons
currentActivity.runOnUiThread {
try {
val w = currentActivity.window
w.decorView.post {
val parsedColor = colorHex.toColorInt()
w.navigationBarColor = parsedColor
val controller = WindowInsetsControllerCompat(w, w.decorView)
controller.isAppearanceLightNavigationBars = lightIcons
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
w.isNavigationBarContrastEnforced = false
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
w.navigationBarDividerColor = Color.TRANSPARENT
}
}
} catch (e: Exception) {
android.util.Log.e("RNUtils", "Error setting navigation bar color: $colorHex", e)
}
}
}
}

View file

@ -1,5 +1,6 @@
package com.mattermost.rnutils
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
@ -115,4 +116,9 @@ class RNUtilsModule(context: ReactApplicationContext) :
val pathList = paths.toArrayList().map { it.toString() }
implementation.createZipFile(pathList, promise)
}
@ReactMethod
fun setNavigationBarColor(colorHex: String, lightIcons: Boolean) {
implementation.setNavigationBarColor(colorHex, lightIcons)
}
}

View file

@ -75,6 +75,9 @@ export interface Spec extends TurboModule {
setSoftKeyboardToAdjustNothing(): void;
createZipFile: (paths: string[]) => Promise<string>;
// Android only
setNavigationBarColor: (color: string, lightIcons: boolean) => void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('RNUtils');