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 + ) + } + } +}