From c0eaeefbe023af15d1be0d33c950f5cf62f560f9 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Wed, 29 Oct 2025 09:11:11 +0800 Subject: [PATCH] Properly handle android system bars (#9242) --- .../com/mattermost/rnbeta/MainActivity.kt | 2 + .../app/src/main/res/values-v35/styles.xml | 9 +++ app/screens/navigation.ts | 79 ++++++++++++++++--- .../mattermost/rnutils/RNUtilsModuleImpl.kt | 51 +++++++++++- .../android/src/oldarch/RNUtilsModule.kt | 6 ++ .../@mattermost/rnutils/src/NativeRNUtils.ts | 3 + 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.kt b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.kt index 820c7326a..1e5462f51 100644 --- a/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.kt +++ b/android/app/src/main/java/com/mattermost/rnbeta/MainActivity.kt @@ -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() { diff --git a/android/app/src/main/res/values-v35/styles.xml b/android/app/src/main/res/values-v35/styles.xml index decb34cef..45b73c2c3 100644 --- a/android/app/src/main/res/values-v35/styles.xml +++ b/android/app/src/main/res/values-v35/styles.xml @@ -7,5 +7,14 @@ @drawable/rn_edit_text_material true + + @android:color/transparent + + + false + false + + + shortEdges diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index d8fffaf58..212c74166 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -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 | undefined; export const allOrientations: LayoutOrientation[] = ['sensor', 'sensorLandscape', 'sensorPortrait', 'landscape', 'portrait']; export const portraitOrientation: LayoutOrientation[] = ['portrait']; +const loginFlowScreens = new Set([ + 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, diff --git a/libraries/@mattermost/rnutils/android/src/main/java/com/mattermost/rnutils/RNUtilsModuleImpl.kt b/libraries/@mattermost/rnutils/android/src/main/java/com/mattermost/rnutils/RNUtilsModuleImpl.kt index ceafaa653..6d98b58aa 100644 --- a/libraries/@mattermost/rnutils/android/src/main/java/com/mattermost/rnutils/RNUtilsModuleImpl.kt +++ b/libraries/@mattermost/rnutils/android/src/main/java/com/mattermost/rnutils/RNUtilsModuleImpl.kt @@ -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 { val map = mutableMapOf() val group = mutableMapOf() @@ -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) + } + } + } } diff --git a/libraries/@mattermost/rnutils/android/src/oldarch/RNUtilsModule.kt b/libraries/@mattermost/rnutils/android/src/oldarch/RNUtilsModule.kt index dc3358b28..a28f9093d 100644 --- a/libraries/@mattermost/rnutils/android/src/oldarch/RNUtilsModule.kt +++ b/libraries/@mattermost/rnutils/android/src/oldarch/RNUtilsModule.kt @@ -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) + } } diff --git a/libraries/@mattermost/rnutils/src/NativeRNUtils.ts b/libraries/@mattermost/rnutils/src/NativeRNUtils.ts index 9451b11dd..ab41c44e9 100644 --- a/libraries/@mattermost/rnutils/src/NativeRNUtils.ts +++ b/libraries/@mattermost/rnutils/src/NativeRNUtils.ts @@ -75,6 +75,9 @@ export interface Spec extends TurboModule { setSoftKeyboardToAdjustNothing(): void; createZipFile: (paths: string[]) => Promise; + + // Android only + setNavigationBarColor: (color: string, lightIcons: boolean) => void; } export default TurboModuleRegistry.getEnforcing('RNUtils');