From 508c6d56e4227613280cfe7737fe3a661a813b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A6=81=E7=B3=96=E9=86=8B=E6=94=BE=E6=A4=92?= =?UTF-8?q?=E7=9B=90?= <62319450+Moriafly@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:51:39 +0800 Subject: [PATCH] Fix Android Sheet system bar visibility inheritance Capture the owner's status and navigation bar visibility before the Sheet dialog takes focus, then initialize each new dialog window from that snapshot. This prevents opening a Sheet from revealing bars hidden by the host. Apply the snapshot only once so subsequent visibility changes made by Sheet content survive recomposition. Leave the owner's requests untouched on dismissal and keep the implementation internal without adding public API. Validation: assembled the debug sample and Android test APKs. Four real-window instrumentation tests passed on Android 12 (API 31), covering bottom and floating Sheets, content-controlled bar visibility, recomposition, dismissal, reopening, icon appearance, and IME/back handling. git diff --check passed. --- .../salt/sample/ui/SheetInteractionTest.kt | 96 +++++++++++++++++++ .../ui/sheet/internal/SheetHost.android.kt | 8 ++ .../sheet/internal/SheetSystemBars.android.kt | 83 ++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 ui2/src/androidMain/kotlin/com/moriafly/salt/ui/sheet/internal/SheetSystemBars.android.kt diff --git a/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/SheetInteractionTest.kt b/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/SheetInteractionTest.kt index 9efc9a2f..01ded4dd 100644 --- a/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/SheetInteractionTest.kt +++ b/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/SheetInteractionTest.kt @@ -98,6 +98,74 @@ import java.util.concurrent.atomic.AtomicReference class SheetInteractionTest { private val device get() = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + @Test + fun sheetInheritsSystemBarsAndAllowsContentToManageThem() { + val statusBar = WindowInsetsCompat.Type.statusBars() + val navigationBar = WindowInsetsCompat.Type.navigationBars() + for (displayMode in listOf(SheetDisplayMode.Bottom, SheetDisplayMode.Floating)) { + val fixture = AndroidSheetFixture(displayMode = displayMode, systemBarControls = true) + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val controller = WindowCompat.getInsetsController( + activity.window, + activity.window.decorView + ) + controller.show(navigationBar) + controller.hide(statusBar) + activity.setContent { fixture.Content() } + } + shown(By.text("Open sheet")) + assertBarVisibility(scenario, statusBar) { it.window.decorView } + click(By.text("Open sheet")) + shown(By.text("Fixture sheet")) + assertBarVisibility(scenario, statusBar) { fixture.dialogView } + listOf(0, statusBar or navigationBar, navigationBar) + .forEachIndexed { index, hiddenBars -> + scenario.onActivity { fixture.nextHiddenBars = hiddenBars } + click(By.text("Change system bars")) + assertBarVisibility(scenario, hiddenBars) { fixture.dialogView } + click(By.text("Inside")) + shown(By.text("Inside: ${index + 1}")) + assertBarVisibility(scenario, hiddenBars) { fixture.dialogView } + } + click(By.text("Program close")) + assertTrue(device.wait(Until.gone(By.text("Fixture sheet")), 5_000)) + assertBarVisibility(scenario, statusBar) { it.window.decorView } + click(By.text("Open sheet")) + shown(By.text("Fixture sheet")) + assertBarVisibility(scenario, statusBar) { fixture.dialogView } + click(By.text("Program close")) + assertTrue(device.wait(Until.gone(By.text("Fixture sheet")), 5_000)) + assertBarVisibility(scenario, statusBar) { it.window.decorView } + } + } + } + + private fun assertBarVisibility( + scenario: ActivityScenario, + hiddenBars: Int, + view: (MainActivity) -> android.view.View? + ) { + val statusBar = WindowInsetsCompat.Type.statusBars() + val navigationBar = WindowInsetsCompat.Type.navigationBars() + val expectedStatus = hiddenBars and statusBar == 0 + val expectedNavigation = hiddenBars and navigationBar == 0 + var actualStatus: Boolean? = null + var actualNavigation: Boolean? = null + val deadline = SystemClock.uptimeMillis() + 5_000 + do { + scenario.onActivity { activity -> + val insets = view(activity)?.let(ViewCompat::getRootWindowInsets) + actualStatus = insets?.isVisible(statusBar) + actualNavigation = insets?.isVisible(navigationBar) + } + if (actualStatus == expectedStatus && actualNavigation == expectedNavigation) return + SystemClock.sleep(50) + } while (SystemClock.uptimeMillis() < deadline) + assertEquals("status bar visibility", expectedStatus, actualStatus) + assertEquals("navigation bar visibility", expectedNavigation, actualNavigation) + } + @Test fun bottomSheetRestoresOwnerSystemBarsConfiguredThroughWindowUtil() { verifyWindowUtilSystemBars(SheetDisplayMode.Bottom) @@ -818,6 +886,7 @@ private class AndroidSheetFixture( private val rounded: Boolean = false, private val noActions: Boolean = false, private val edgeToEdge: Boolean = false, + private val systemBarControls: Boolean = false, private val displayMode: SheetDisplayMode = SheetDisplayMode.Bottom, private val handoff: Boolean = false, private val dismissalProperties: SheetProperties = SheetProperties(dismissible = dismissible) @@ -837,6 +906,9 @@ private class AndroidSheetFixture( var statusDarkIcons: (() -> Boolean)? = null var navigationDarkIcons: (() -> Boolean)? = null var navigationBarInsets: (() -> Insets)? = null + var dialogView: android.view.View? = null + private var changeSystemBars: (Int) -> Unit = {} + var nextHiddenBars = 0 val windowSize = AtomicReference(DpSize.Unspecified) var contentMargin = 0f @@ -918,7 +990,25 @@ private class AndroidSheetFixture( @Composable private fun RecordSheetWindow() { val dialogView = LocalView.current + this.dialogView = dialogView val dialogWindow = (dialogView.parent as? DialogWindowProvider)?.window + if (systemBarControls && dialogWindow != null) { + val controller = WindowCompat.getInsetsController(dialogWindow, dialogWindow.decorView) + changeSystemBars = { hiddenBars -> + val statusBar = WindowInsetsCompat.Type.statusBars() + val navigationBar = WindowInsetsCompat.Type.navigationBars() + if (hiddenBars and statusBar == 0) { + controller.show(statusBar) + } else { + controller.hide(statusBar) + } + if (hiddenBars and navigationBar == 0) { + controller.show(navigationBar) + } else { + controller.hide(navigationBar) + } + } + } navigationBarInsets = { ViewCompat.getRootWindowInsets(dialogView) ?.getInsets(WindowInsetsCompat.Type.navigationBars()) ?: Insets.NONE @@ -971,6 +1061,12 @@ private class AndroidSheetFixture( @Composable private fun ButtonContent() { Button(onClick = { insideClicks++ }, text = "Inside") + if (systemBarControls) { + Button( + onClick = { changeSystemBars(nextHiddenBars) }, + text = "Change system bars" + ) + } if (handoff) { Button( onClick = { diff --git a/ui2/src/androidMain/kotlin/com/moriafly/salt/ui/sheet/internal/SheetHost.android.kt b/ui2/src/androidMain/kotlin/com/moriafly/salt/ui/sheet/internal/SheetHost.android.kt index bf62850a..1734adbe 100644 --- a/ui2/src/androidMain/kotlin/com/moriafly/salt/ui/sheet/internal/SheetHost.android.kt +++ b/ui2/src/androidMain/kotlin/com/moriafly/salt/ui/sheet/internal/SheetHost.android.kt @@ -65,6 +65,10 @@ internal actual fun SheetHost( ) { val ownerSize = LocalWindowInfo.current.containerDpSize val ownerView = LocalView.current + // Keep one owner snapshot per presentation, before the Dialog can change the visible insets. + val ownerBarVisibility = remember(ownerView, lease) { + SheetSystemBars.readOwnerVisibility(ownerView) + } SheetOwnerScrim(ownerView = ownerView, coordinator = lease.coordinator) val bottom = displayMode.isBottom( adaptiveWindow = adaptiveWindow, @@ -80,6 +84,9 @@ internal actual fun SheetHost( ) ) { val window = (LocalView.current.parent as? DialogWindowProvider)?.window + val systemBars = remember(window, ownerBarVisibility) { + window?.let { SheetSystemBars(window = it, ownerVisibility = ownerBarVisibility) } + } val keyboard = LocalSoftwareKeyboardController.current val imeInsets = WindowInsets.ime val density = LocalDensity.current @@ -115,6 +122,7 @@ internal actual fun SheetHost( val replacementPending = hasVisibleSheet && coordinator.activeLease?.windowAttached != true SideEffect { + systemBars?.inheritOwnerVisibility() // The visual transition must not keep the closing dialog in the input path. window?.configureSheetInput( inputEnabled = inputEnabled, diff --git a/ui2/src/androidMain/kotlin/com/moriafly/salt/ui/sheet/internal/SheetSystemBars.android.kt b/ui2/src/androidMain/kotlin/com/moriafly/salt/ui/sheet/internal/SheetSystemBars.android.kt new file mode 100644 index 00000000..a507e5f8 --- /dev/null +++ b/ui2/src/androidMain/kotlin/com/moriafly/salt/ui/sheet/internal/SheetSystemBars.android.kt @@ -0,0 +1,83 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("ktlint:standard:filename") + +package com.moriafly.salt.ui.sheet.internal + +import android.view.View +import android.view.Window +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat + +/** + * Initializes one Sheet dialog window with the owner's opening system bar visibility. + * + * Call [inheritOwnerVisibility] from the host's SideEffect after a successful composition. + * Only the dialog's controller is changed; the owner keeps its own requests, so dismissal requires + * no restoration. Further visibility changes belong to the developer controlling the dialog. + */ +internal class SheetSystemBars( + window: Window, + private val ownerVisibility: Visibility +) { + private val controller = WindowCompat.getInsetsController(window, window.decorView) + private var initialized = false + + /** + * Applies the opening state once per dialog. Later recompositions must not override explicit + * changes from the Sheet's content or bars temporarily revealed by system gestures or the IME. + */ + fun inheritOwnerVisibility() { + if (initialized) { + return + } + setVisible(WindowInsetsCompat.Type.statusBars(), ownerVisibility.statusBarVisible) + setVisible(WindowInsetsCompat.Type.navigationBars(), ownerVisibility.navigationBarVisible) + initialized = true + } + + private fun setVisible(bar: Int, visible: Boolean) { + if (visible) { + controller.show(bar) + } else { + controller.hide(bar) + } + } + + data class Visibility( + val statusBarVisible: Boolean, + val navigationBarVisible: Boolean + ) + + companion object { + /** + * Captures actual visibility before the dialog takes focus. Later owner insets reflect the + * active dialog and cannot reveal pending requests on the unfocused owner. Without insets, + * uses the platform's default visible bars. + */ + fun readOwnerVisibility(ownerView: View): Visibility { + val insets = ViewCompat.getRootWindowInsets(ownerView) + return Visibility( + statusBarVisible = insets?.isVisible(WindowInsetsCompat.Type.statusBars()) != false, + navigationBarVisible = + insets?.isVisible(WindowInsetsCompat.Type.navigationBars()) != false + ) + } + } +}