From e2b57b55d61a838fe08f18fc1eb95a8b8e60e53f Mon Sep 17 00:00:00 2001 From: jeffrey-signal Date: Thu, 16 Oct 2025 16:56:23 -0400 Subject: [PATCH] Add snackbar host to AppScaffold. --- .../thoughtcrime/securesms/MainActivity.kt | 2 + .../conversation/NewConversationActivityV2.kt | 4 +- .../securesms/window/AppScaffold.kt | 238 ++++++++++-------- .../securesms/window/AppScaffoldWithTopBar.kt | 70 +----- 4 files changed, 146 insertions(+), 168 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 3d559f72a8..cc26c11163 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -417,6 +418,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner AppScaffold( navigator = wrappedNavigator, paneExpansionState = paneExpansionState, + contentWindowInsets = WindowInsets(), bottomNavContent = { if (isNavigationVisible) { Column( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt index 83adf113c7..6cfa8f6362 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivityV2.kt @@ -50,7 +50,7 @@ import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity import org.thoughtcrime.securesms.recipients.ui.findby.FindByMode -import org.thoughtcrime.securesms.window.AppScaffoldWithTopBar +import org.thoughtcrime.securesms.window.AppScaffold import org.thoughtcrime.securesms.window.WindowSizeClass import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator @@ -168,7 +168,7 @@ private fun NewConversationScreenUi( val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() val isSplitPane = windowSizeClass.isSplitPane(forceSplitPaneOnCompactLandscape = uiState.forceSplitPaneOnCompactLandscape) - AppScaffoldWithTopBar( + AppScaffold( topBarContent = { Scaffolds.DefaultTopAppBar( title = if (!isSplitPane) stringResource(R.string.NewConversationActivity__new_message) else "", diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt index 1902e1ac61..122c32fe93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt @@ -14,11 +14,15 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo @@ -204,30 +208,48 @@ enum class WindowSizeClass( } /** - * Composable who's precise layout will depend on the window size class of the device it is being utilized on. - * This is built to be generic so that we can use it throughout the application to support different device classes. + * A top-level scaffold that automatically adapts its layout based on the device's window size class. It is a generic container designed to handle the + * arrangement of navigation rails, top/bottom bars, and list-detail pane management for both compact and large screens. + * + * @param topBarContent An optional top bar that spans across all panes. + * + * @param primaryContent The main content, which is typically the detail view in a split-pane layout. + * @param secondaryContent The secondary content, which is typically the list view in a split-pane layout. + * + * @param navRailContent The side navigation rail, shown on medium and larger screen sizes. + * @param bottomNavContent The bottom navigation bar, shown on compact screen sizes. + * + * @param paneExpansionState Manages the position and expansion of the panes in a list-detail layout. + * @param paneExpansionDragHandle An optional drag handle used to resize panes in the list-detail layout. + * + * @param animatorFactory Provides animations to control how panes enter and exit the screen during navigation. */ @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun AppScaffold( navigator: AppScaffoldNavigator, + topBarContent: @Composable () -> Unit = {}, primaryContent: @Composable () -> Unit = {}, + secondaryContent: @Composable () -> Unit, navRailContent: @Composable () -> Unit = {}, bottomNavContent: @Composable () -> Unit = {}, paneExpansionState: PaneExpansionState = rememberPaneExpansionState(), paneExpansionDragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null, - animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default, - secondaryContent: @Composable () -> Unit + snackbarHost: @Composable () -> Unit = {}, + contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, + animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default ) { val isForcedCompact = WindowSizeClass.checkForcedCompact() val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() if (isForcedCompact) { ListAndNavigation( + topBarContent = topBarContent, listContent = secondaryContent, navRailContent = navRailContent, bottomNavContent = bottomNavContent, - windowSizeClass = windowSizeClass + windowSizeClass = windowSizeClass, + contentWindowInsets = contentWindowInsets ) return @@ -236,114 +258,134 @@ fun AppScaffold( val minPaneWidth = navigator.scaffoldDirective.defaultPanePreferredWidth val navigationState = navigator.state - NavigableListDetailPaneScaffold( - navigator = navigator, - listPane = { - val animationState = with(animatorFactory) { - this@NavigableListDetailPaneScaffold.getListAnimationState(navigationState) - } - - AnimatedPane( - enterTransition = EnterTransition.None, - exitTransition = ExitTransition.None, - modifier = Modifier - .zIndex(0f) - .then(animationState.parentModifier) - ) { - Box( - modifier = Modifier - .then(animationState.toModifier()) - .clipToBounds() - .layout { measurable, constraints -> - val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) - val placeable = measurable.measure( - constraints.copy( - minWidth = minPaneWidth.roundToPx(), - maxWidth = width - ) - ) - layout(constraints.maxWidth, placeable.height) { - placeable.placeRelative( - x = 0, - y = 0 - ) - } - } - ) { - ListAndNavigation( - listContent = secondaryContent, - navRailContent = navRailContent, - bottomNavContent = bottomNavContent, - windowSizeClass = windowSizeClass - ) + Scaffold( + containerColor = Color.Transparent, + contentWindowInsets = contentWindowInsets, + topBar = topBarContent, + snackbarHost = snackbarHost + ) { paddingValues -> + NavigableListDetailPaneScaffold( + navigator = navigator, + listPane = { + val animationState = with(animatorFactory) { + this@NavigableListDetailPaneScaffold.getListAnimationState(navigationState) } - } - }, - detailPane = { - val animationState = with(animatorFactory) { - this@NavigableListDetailPaneScaffold.getDetailAnimationState(navigationState) - } - AnimatedPane( - enterTransition = EnterTransition.None, - exitTransition = ExitTransition.None, - modifier = Modifier - .zIndex(1f) - .then(animationState.parentModifier) - ) { - Box( + AnimatedPane( + enterTransition = EnterTransition.None, + exitTransition = ExitTransition.None, modifier = Modifier - .then(animationState.toModifier()) - .clipToBounds() - .layout { measurable, constraints -> - val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) - val placeable = measurable.measure( - constraints.copy( - minWidth = minPaneWidth.roundToPx(), - maxWidth = width - ) - ) - layout(constraints.maxWidth, placeable.height) { - placeable.placeRelative( - x = constraints.maxWidth - - max(constraints.maxWidth, placeable.width), - y = 0 - ) - } - } + .zIndex(0f) + .then(animationState.parentModifier) ) { - primaryContent() + Box( + modifier = Modifier + .then(animationState.toModifier()) + .clipToBounds() + .layout { measurable, constraints -> + val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) + val placeable = measurable.measure( + constraints.copy( + minWidth = minPaneWidth.roundToPx(), + maxWidth = width + ) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.placeRelative( + x = 0, + y = 0 + ) + } + } + ) { + ListAndNavigation( + topBarContent = { }, + listContent = secondaryContent, + navRailContent = navRailContent, + bottomNavContent = bottomNavContent, + windowSizeClass = windowSizeClass, + contentWindowInsets = contentWindowInsets + ) + } } - } - }, - paneExpansionDragHandle = paneExpansionDragHandle, - paneExpansionState = paneExpansionState - ) + }, + detailPane = { + val animationState = with(animatorFactory) { + this@NavigableListDetailPaneScaffold.getDetailAnimationState(navigationState) + } + + AnimatedPane( + enterTransition = EnterTransition.None, + exitTransition = ExitTransition.None, + modifier = Modifier + .zIndex(1f) + .then(animationState.parentModifier) + ) { + Box( + modifier = Modifier + .then(animationState.toModifier()) + .clipToBounds() + .layout { measurable, constraints -> + val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) + val placeable = measurable.measure( + constraints.copy( + minWidth = minPaneWidth.roundToPx(), + maxWidth = width + ) + ) + layout(constraints.maxWidth, placeable.height) { + placeable.placeRelative( + x = constraints.maxWidth - + max(constraints.maxWidth, placeable.width), + y = 0 + ) + } + } + ) { + primaryContent() + } + } + }, + paneExpansionDragHandle = paneExpansionDragHandle, + paneExpansionState = paneExpansionState, + modifier = Modifier.padding(paddingValues) + ) + } } @Composable private fun ListAndNavigation( + topBarContent: @Composable () -> Unit, listContent: @Composable () -> Unit, navRailContent: @Composable () -> Unit, bottomNavContent: @Composable () -> Unit, - windowSizeClass: WindowSizeClass + snackbarHost: @Composable () -> Unit = {}, + windowSizeClass: WindowSizeClass, + contentWindowInsets: WindowInsets ) { - Row( - modifier = if (windowSizeClass.isLandscape()) { - Modifier.displayCutoutPadding() - } else Modifier - ) { - if (windowSizeClass.navigation == Navigation.RAIL) { - navRailContent() - } - - Column { - Box(modifier = Modifier.weight(1f)) { - listContent() + Scaffold( + containerColor = Color.Transparent, + topBar = topBarContent, + contentWindowInsets = contentWindowInsets, + snackbarHost = snackbarHost + ) { paddingValues -> + Row( + modifier = Modifier + .padding(paddingValues) + .then(if (windowSizeClass.isLandscape()) Modifier.displayCutoutPadding() else Modifier) + ) { + if (windowSizeClass.navigation == Navigation.RAIL) { + navRailContent() } - if (windowSizeClass.navigation == Navigation.BAR) { - bottomNavContent() + Column { + Box(modifier = Modifier.weight(1f)) { + listContent() + } + + if (windowSizeClass.navigation == Navigation.BAR) { + bottomNavContent() + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldWithTopBar.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldWithTopBar.kt index f7a5031163..05e886acce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldWithTopBar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldWithTopBar.kt @@ -7,87 +7,22 @@ package org.thoughtcrime.securesms.window import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.PaneExpansionState -import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope -import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Scaffolds import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.keyvalue.SignalStore - -/** - * Wraps [AppScaffold], adding a top app bar that spans across both the list and detail panes. - */ -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -@Composable -fun AppScaffoldWithTopBar( - navigator: AppScaffoldNavigator = rememberAppScaffoldNavigator(), - topBarContent: @Composable () -> Unit = {}, - primaryContent: @Composable () -> Unit = {}, - navRailContent: @Composable () -> Unit = {}, - bottomNavContent: @Composable () -> Unit = {}, - paneExpansionState: PaneExpansionState = rememberPaneExpansionState(), - paneExpansionDragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null, - animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default, - secondaryContent: @Composable () -> Unit -) { - val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() - val isSplitPane = windowSizeClass.isSplitPane( - forceSplitPaneOnCompactLandscape = if (LocalInspectionMode.current) false else SignalStore.internal.forceSplitPaneOnCompactLandscape - ) - - if (isSplitPane) { - Column { - topBarContent() - - AppScaffold( - navigator = navigator, - primaryContent = primaryContent, - navRailContent = navRailContent, - bottomNavContent = bottomNavContent, - paneExpansionState = paneExpansionState, - paneExpansionDragHandle = paneExpansionDragHandle, - animatorFactory = animatorFactory, - secondaryContent = secondaryContent - ) - } - } else { - AppScaffold( - navigator = navigator, - primaryContent = primaryContent, - navRailContent = navRailContent, - bottomNavContent = bottomNavContent, - paneExpansionState = paneExpansionState, - paneExpansionDragHandle = paneExpansionDragHandle, - animatorFactory = animatorFactory, - secondaryContent = { - Scaffold(topBar = topBarContent) { paddingValues -> - Box(modifier = Modifier.padding(paddingValues)) { - secondaryContent() - } - } - } - ) - } -} @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) @AllDevicePreviews @@ -95,14 +30,13 @@ fun AppScaffoldWithTopBar( private fun AppScaffoldWithTopBarPreview() { Previews.Preview { val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() - val isSplitPane = windowSizeClass.isSplitPane(forceSplitPaneOnCompactLandscape = false) - AppScaffoldWithTopBar( + AppScaffold( navigator = rememberAppScaffoldNavigator(), topBarContent = { Scaffolds.DefaultTopAppBar( - title = if (!isSplitPane) stringResource(R.string.NewConversationActivity__new_message) else "", + title = "Hello World!", titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) }, navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24), navigationContentDescription = "",