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

View File

@@ -55,6 +55,7 @@ fun AppSidebarNavigation(
style: NavigationStyle,
onDestinationSelected: (AppDestination) -> Unit,
modifier: Modifier = Modifier,
useNativeWindowBackdrop: Boolean = false,
) {
when (style) {
NavigationStyle.AndroidRail -> AndroidNavigationRail(selected, onDestinationSelected, modifier)
@@ -62,6 +63,7 @@ fun AppSidebarNavigation(
selected = selected,
onDestinationSelected = onDestinationSelected,
modifier = modifier,
useNativeWindowBackdrop = useNativeWindowBackdrop,
)
NavigationStyle.AndroidBottomBar -> error("Bottom navigation is rendered by the phone shell")
}
@@ -103,13 +105,14 @@ private fun DesktopSidebarNavigation(
selected: AppDestination,
onDestinationSelected: (AppDestination) -> Unit,
modifier: Modifier = Modifier,
useNativeWindowBackdrop: Boolean = false,
) {
val colors = LocalVniDropColors.current
Column(
modifier = modifier
.width(DesktopNavigationWidthDp.dp)
.fillMaxHeight()
.background(colors.backgroundSurface200)
.background(if (useNativeWindowBackdrop) Color.Transparent else colors.backgroundSurface200)
.padding(horizontal = 12.dp, vertical = 14.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.ui.Modifier
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 com.vnidrop.app.UiPlatform
@@ -33,6 +34,7 @@ fun AppShell(
windowClass: WindowClass,
uiPlatform: UiPlatform,
mainContentTopStartRadius: Dp = 0.dp,
useNativeWindowBackdrop: Boolean = false,
onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit = {},
floatingAction: (@Composable BoxScope.() -> Unit)? = null,
@@ -40,11 +42,13 @@ fun AppShell(
) {
val colors = LocalVniDropColors.current
val navigationStyle = navigationStyleFor(uiPlatform, windowClass)
val windowSurface = if (useNativeWindowBackdrop) Color.Transparent else colors.backgroundDashCanvas
Surface(
modifier = modifier
.fillMaxSize()
.background(colors.backgroundDashCanvas),
color = colors.backgroundDashCanvas,
.background(windowSurface),
color = windowSurface,
contentColor = colors.foregroundDefault,
) {
if (navigationStyle == NavigationStyle.AndroidBottomBar) {
PhoneShell(
@@ -59,6 +63,7 @@ fun AppShell(
selectedDestination = selectedDestination,
navigationStyle = navigationStyle,
mainContentTopStartRadius = mainContentTopStartRadius,
useNativeWindowBackdrop = useNativeWindowBackdrop,
onDestinationSelected = onDestinationSelected,
overlay = overlay,
floatingAction = floatingAction,
@@ -73,6 +78,7 @@ private fun WideShell(
selectedDestination: AppDestination,
navigationStyle: NavigationStyle,
mainContentTopStartRadius: Dp,
useNativeWindowBackdrop: Boolean,
onDestinationSelected: (AppDestination) -> Unit,
overlay: @Composable BoxScope.() -> Unit,
floatingAction: (@Composable BoxScope.() -> Unit)?,
@@ -83,11 +89,18 @@ private fun WideShell(
Row(
modifier = Modifier
.fillMaxSize()
.then(if (roundedContent) Modifier.background(colors.backgroundSurface200) else Modifier),
.then(
if (roundedContent && !useNativeWindowBackdrop) {
Modifier.background(colors.backgroundSurface200)
} else {
Modifier
},
),
) {
AppSidebarNavigation(
selected = selectedDestination,
style = navigationStyle,
useNativeWindowBackdrop = useNativeWindowBackdrop,
onDestinationSelected = onDestinationSelected,
)
Box(
@@ -96,13 +109,12 @@ private fun WideShell(
.fillMaxSize()
.then(
if (roundedContent) {
Modifier
.clip(RoundedCornerShape(topStart = mainContentTopStartRadius))
.background(colors.backgroundDashCanvas)
Modifier.clip(RoundedCornerShape(topStart = mainContentTopStartRadius))
} else {
Modifier
},
),
)
.background(colors.backgroundDashCanvas),
) {
content()
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.background
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
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.onAllNodesWithText
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.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.v2.runComposeUiTest
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.shell.AppShell
import com.vnidrop.app.ui.theme.VniDropTheme
import com.vnidrop.app.ui.theme.LocalVniDropColors
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -290,6 +297,68 @@ class FoundationComposeTest {
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
fun androidPagesUseStaticFeatureIconsWithoutTitleDescriptions() = runComposeUiTest {
val actions = object : ReceiveInvitationActions {