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
Expand Up @@ -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<MainActivity>,
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)
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
Loading