feat(ui): support native desktop backdrops

This commit is contained in:
2026-07-22 15:48:16 +02:00
parent a0d352895e
commit 0ab076e5b2
4 changed files with 103 additions and 9 deletions

View File

@@ -13,6 +13,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -54,6 +55,8 @@ fun App(
dependencies: AppDependencies, dependencies: AppDependencies,
windowChromeTopInset: Dp = 0.dp, windowChromeTopInset: Dp = 0.dp,
windowContentTopStartRadius: Dp = 0.dp, windowContentTopStartRadius: Dp = 0.dp,
useNativeWindowBackdrop: Boolean = false,
onResolvedDarkThemeChanged: (Boolean) -> Unit = {},
windowChrome: (@Composable () -> Unit)? = null, windowChrome: (@Composable () -> Unit)? = null,
) { ) {
val graphHolder = viewModel { AppGraphViewModel(dependencies) } val graphHolder = viewModel { AppGraphViewModel(dependencies) }
@@ -140,13 +143,19 @@ fun App(
} }
val darkTheme = rememberResolvedDarkTheme(appState.themeMode) val darkTheme = rememberResolvedDarkTheme(appState.themeMode)
LaunchedEffect(darkTheme, onResolvedDarkThemeChanged) {
onResolvedDarkThemeChanged(darkTheme)
}
PlatformSystemAppearance(darkTheme) PlatformSystemAppearance(darkTheme)
CompositionLocalProvider(LocalUiPlatform provides dependencies.environment.uiPlatform) { CompositionLocalProvider(LocalUiPlatform provides dependencies.environment.uiPlatform) {
VniDropTheme(isDarkTheme = darkTheme) { VniDropTheme(isDarkTheme = darkTheme) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(LocalVniDropColors.current.backgroundSurface200), .background(
if (useNativeWindowBackdrop) Color.Transparent
else LocalVniDropColors.current.backgroundSurface200,
),
) { ) {
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
@@ -171,6 +180,7 @@ fun App(
windowClass = windowClass, windowClass = windowClass,
uiPlatform = dependencies.environment.uiPlatform, uiPlatform = dependencies.environment.uiPlatform,
mainContentTopStartRadius = windowContentTopStartRadius, mainContentTopStartRadius = windowContentTopStartRadius,
useNativeWindowBackdrop = useNativeWindowBackdrop,
onDestinationSelected = appViewModel::selectDestination, onDestinationSelected = appViewModel::selectDestination,
overlay = { overlay = {
VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter)) VniDropSnackbarHost(graph.messages, Modifier.align(Alignment.BottomCenter))

View File

@@ -55,6 +55,7 @@ fun AppSidebarNavigation(
style: NavigationStyle, style: NavigationStyle,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
useNativeWindowBackdrop: Boolean = false,
) { ) {
when (style) { when (style) {
NavigationStyle.AndroidRail -> AndroidNavigationRail(selected, onDestinationSelected, modifier) NavigationStyle.AndroidRail -> AndroidNavigationRail(selected, onDestinationSelected, modifier)
@@ -62,6 +63,7 @@ fun AppSidebarNavigation(
selected = selected, selected = selected,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
modifier = modifier, modifier = modifier,
useNativeWindowBackdrop = useNativeWindowBackdrop,
) )
NavigationStyle.AndroidBottomBar -> error("Bottom navigation is rendered by the phone shell") NavigationStyle.AndroidBottomBar -> error("Bottom navigation is rendered by the phone shell")
} }
@@ -103,13 +105,14 @@ private fun DesktopSidebarNavigation(
selected: AppDestination, selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
useNativeWindowBackdrop: Boolean = false,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
Column( Column(
modifier = modifier modifier = modifier
.width(DesktopNavigationWidthDp.dp) .width(DesktopNavigationWidthDp.dp)
.fillMaxHeight() .fillMaxHeight()
.background(colors.backgroundSurface200) .background(if (useNativeWindowBackdrop) Color.Transparent else colors.backgroundSurface200)
.padding(horizontal = 12.dp, vertical = 14.dp), .padding(horizontal = 12.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {

View File

@@ -15,6 +15,7 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vnidrop.app.UiPlatform import com.vnidrop.app.UiPlatform
@@ -33,6 +34,7 @@ fun AppShell(
windowClass: WindowClass, windowClass: WindowClass,
uiPlatform: UiPlatform, uiPlatform: UiPlatform,
mainContentTopStartRadius: Dp = 0.dp, mainContentTopStartRadius: Dp = 0.dp,
useNativeWindowBackdrop: Boolean = false,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit = {}, overlay: @Composable BoxScope.() -> Unit = {},
floatingAction: (@Composable BoxScope.() -> Unit)? = null, floatingAction: (@Composable BoxScope.() -> Unit)? = null,
@@ -40,11 +42,13 @@ fun AppShell(
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
val navigationStyle = navigationStyleFor(uiPlatform, windowClass) val navigationStyle = navigationStyleFor(uiPlatform, windowClass)
val windowSurface = if (useNativeWindowBackdrop) Color.Transparent else colors.backgroundDashCanvas
Surface( Surface(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(colors.backgroundDashCanvas), .background(windowSurface),
color = colors.backgroundDashCanvas, color = windowSurface,
contentColor = colors.foregroundDefault,
) { ) {
if (navigationStyle == NavigationStyle.AndroidBottomBar) { if (navigationStyle == NavigationStyle.AndroidBottomBar) {
PhoneShell( PhoneShell(
@@ -59,6 +63,7 @@ fun AppShell(
selectedDestination = selectedDestination, selectedDestination = selectedDestination,
navigationStyle = navigationStyle, navigationStyle = navigationStyle,
mainContentTopStartRadius = mainContentTopStartRadius, mainContentTopStartRadius = mainContentTopStartRadius,
useNativeWindowBackdrop = useNativeWindowBackdrop,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
overlay = overlay, overlay = overlay,
floatingAction = floatingAction, floatingAction = floatingAction,
@@ -73,6 +78,7 @@ private fun WideShell(
selectedDestination: AppDestination, selectedDestination: AppDestination,
navigationStyle: NavigationStyle, navigationStyle: NavigationStyle,
mainContentTopStartRadius: Dp, mainContentTopStartRadius: Dp,
useNativeWindowBackdrop: Boolean,
onDestinationSelected: (AppDestination) -> Unit, onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit, overlay: @Composable BoxScope.() -> Unit,
floatingAction: (@Composable BoxScope.() -> Unit)?, floatingAction: (@Composable BoxScope.() -> Unit)?,
@@ -83,11 +89,18 @@ private fun WideShell(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.then(if (roundedContent) Modifier.background(colors.backgroundSurface200) else Modifier), .then(
if (roundedContent && !useNativeWindowBackdrop) {
Modifier.background(colors.backgroundSurface200)
} else {
Modifier
},
),
) { ) {
AppSidebarNavigation( AppSidebarNavigation(
selected = selectedDestination, selected = selectedDestination,
style = navigationStyle, style = navigationStyle,
useNativeWindowBackdrop = useNativeWindowBackdrop,
onDestinationSelected = onDestinationSelected, onDestinationSelected = onDestinationSelected,
) )
Box( Box(
@@ -96,13 +109,12 @@ private fun WideShell(
.fillMaxSize() .fillMaxSize()
.then( .then(
if (roundedContent) { if (roundedContent) {
Modifier Modifier.clip(RoundedCornerShape(topStart = mainContentTopStartRadius))
.clip(RoundedCornerShape(topStart = mainContentTopStartRadius))
.background(colors.backgroundDashCanvas)
} else { } else {
Modifier Modifier
}, },
), )
.background(colors.backgroundDashCanvas),
) { ) {
content() content()
floatingAction?.invoke(this) floatingAction?.invoke(this)

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.background
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -18,7 +19,12 @@ import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -55,6 +61,7 @@ import com.vnidrop.app.ui.navigation.AppDestination
import com.vnidrop.app.ui.platform.LocalUiPlatform import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.shell.AppShell import com.vnidrop.app.ui.shell.AppShell
import com.vnidrop.app.ui.theme.VniDropTheme import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.LocalVniDropColors
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -290,6 +297,68 @@ class FoundationComposeTest {
runOnIdle { assertEquals(AppDestination.Receive, selected) } runOnIdle { assertEquals(AppDestination.Receive, selected) }
} }
@Test
fun nativeWindowBackdropShowsThroughDesktopChromeButNotMainContent() = runComposeUiTest {
val sentinel = Color.Magenta
var expectedMain = Color.Unspecified
setContent {
VniDropTheme(isDarkTheme = false) {
expectedMain = LocalVniDropColors.current.backgroundDashCanvas
Box(
Modifier
.size(width = 320.dp, height = 200.dp)
.background(sentinel)
.testTag("native-backdrop-shell"),
) {
AppShell(
selectedDestination = AppDestination.Send,
windowClass = WindowClass.Desktop,
uiPlatform = UiPlatform.Windows,
mainContentTopStartRadius = 20.dp,
useNativeWindowBackdrop = true,
onDestinationSelected = {},
) {
Text("Content")
}
}
}
}
val pixels = onNodeWithTag("native-backdrop-shell").captureToImage().toPixelMap()
assertEquals(sentinel.toArgb(), pixels[pixels.width / 20, pixels.height * 9 / 10].toArgb())
assertEquals(expectedMain.toArgb(), pixels[pixels.width * 19 / 20, pixels.height * 9 / 10].toArgb())
}
@Test
fun desktopChromeKeepsSolidFallbackWithoutNativeBackdrop() = runComposeUiTest {
var expectedSidebar = Color.Unspecified
setContent {
VniDropTheme(isDarkTheme = false) {
expectedSidebar = LocalVniDropColors.current.backgroundSurface200
Box(
Modifier
.size(width = 320.dp, height = 200.dp)
.background(Color.Magenta)
.testTag("solid-backdrop-shell"),
) {
AppShell(
selectedDestination = AppDestination.Send,
windowClass = WindowClass.Desktop,
uiPlatform = UiPlatform.Windows,
mainContentTopStartRadius = 20.dp,
useNativeWindowBackdrop = false,
onDestinationSelected = {},
) {
Text("Content")
}
}
}
}
val pixels = onNodeWithTag("solid-backdrop-shell").captureToImage().toPixelMap()
assertEquals(expectedSidebar.toArgb(), pixels[pixels.width / 20, pixels.height * 9 / 10].toArgb())
}
@Test @Test
fun androidPagesUseStaticFeatureIconsWithoutTitleDescriptions() = runComposeUiTest { fun androidPagesUseStaticFeatureIconsWithoutTitleDescriptions() = runComposeUiTest {
val actions = object : ReceiveInvitationActions { val actions = object : ReceiveInvitationActions {