AppScaffold removal.

This commit is contained in:
Alex Hart
2026-09-02 16:11:32 -03:00
parent 62897a309c
commit cf4bee8a42
9 changed files with 136 additions and 1101 deletions
@@ -14,18 +14,22 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.GraphicsLayerScope
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults
import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
import org.signal.core.ui.navigation.TransitionSpecs
import kotlin.time.Duration.Companion.seconds
/**
@@ -74,7 +78,7 @@ fun ConversationLoadingMask(
}
val chatModifier = Modifier.graphicsLayer {
with(chatAnimationState) { applyChildValues() }
with(chatAnimationState) { applyTo() }
}
Box(modifier = Modifier.fillMaxSize()) {
@@ -86,7 +90,7 @@ fun ConversationLoadingMask(
contentDescription = null,
modifier = Modifier
.graphicsLayer {
with(fakeChatListAnimationState) { applyChildValues() }
with(fakeChatListAnimationState) { applyTo() }
}
.fillMaxSize()
)
@@ -95,11 +99,12 @@ fun ConversationLoadingMask(
}
@Composable
private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimationState {
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f }
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp }
private fun Transition<Boolean>.fakeChatListAnimationState(): MaskAnimationState {
val shift = paneShiftOffset
val alpha = animateFloat(transitionSpec = { TransitionSpecs.paneShiftSpec() }) { if (it) 0f else 1f }
val offset = animateDp(transitionSpec = { TransitionSpecs.paneShiftSpec() }) { if (it) -shift else 0.dp }
return remember {
AppScaffoldAnimationState(
MaskAnimationState(
offset = offset,
alpha = alpha
)
@@ -107,22 +112,40 @@ private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimati
}
@Composable
private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState {
val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f }
private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): MaskAnimationState {
val shift = paneShiftOffset
val alpha = animateFloat(transitionSpec = { TransitionSpecs.paneShiftSpec() }) { if (it) 1f else 0f }
return if (!hasFake) {
remember {
AppScaffoldAnimationState(
MaskAnimationState(
offset = mutableStateOf(0.dp),
alpha = alpha
)
}
} else {
val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp }
val offset = animateDp(transitionSpec = { TransitionSpecs.paneShiftSpec() }) { if (it) 0.dp else shift }
remember {
AppScaffoldAnimationState(
MaskAnimationState(
offset = offset,
alpha = alpha
)
}
}
}
private val paneShiftOffset: Dp
@Composable get() = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -TransitionSpecs.PANE_SHIFT_OFFSET else TransitionSpecs.PANE_SHIFT_OFFSET
/**
* The alpha and horizontal offset a layer is drawn with.
*/
private class MaskAnimationState(
private val offset: State<Dp>,
private val alpha: State<Float>
) {
fun GraphicsLayerScope.applyTo() {
this.alpha = this@MaskAnimationState.alpha.value
this.translationX = offset.value.toPx()
this.clip = true
}
}
@@ -148,6 +148,20 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
windowInsetsListeners.remove(listener)
}
/**
* A guideline's begin and end measure from the left and right edges in either direction, while the views constrained
* to these two use start and end. In RTL that inverts the span, collapsing every view sized by it.
*/
private fun applyHorizontalGuidelines(isLtr: Boolean, parentStart: Int, parentEnd: Int) {
if (isLtr) {
parentStartGuideline?.setGuidelineBegin(parentStart)
parentEndGuideline?.setGuidelineEnd(parentEnd)
} else {
parentStartGuideline?.setGuidelineEnd(parentStart)
parentEndGuideline?.setGuidelineBegin(parentEnd)
}
}
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
val isLtr = ViewUtil.isLtr(this)
@@ -166,8 +180,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
if (!statusBarShrinking) {
statusBarGuideline?.setGuidelineBegin(statusBar)
navigationBarGuideline?.setGuidelineEnd(navigationBar)
parentStartGuideline?.setGuidelineBegin(parentStart)
parentEndGuideline?.setGuidelineEnd(parentEnd)
applyHorizontalGuidelines(isLtr, parentStart, parentEnd)
windowInsetsListeners.forEach {
it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd)
@@ -8,19 +8,25 @@ package org.thoughtcrime.securesms.recipients.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -29,22 +35,22 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import org.signal.core.ui.compose.BreakpointPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.detailPaneMaxContentWidth
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.horizontalPartitionDefaultSpacerSize
import org.signal.core.ui.listPaneDefaultPreferredWidth
import org.signal.core.ui.rememberIsSplitPane
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.ScreenTitlePane
import org.thoughtcrime.securesms.window.AppScaffold
import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
/**
* Provides the common adaptive layout structure for recipient picker screens.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecipientPickerScaffold(
title: String,
@@ -57,8 +63,10 @@ fun RecipientPickerScaffold(
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
AppScaffold(
topBarContent = {
Scaffold(
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets.systemBars,
topBar = {
Scaffolds.DefaultTopAppBar(
title = if (!isSplitPane) title else "",
titleContent = { _, titleText -> Text(text = titleText, style = MaterialTheme.typography.titleLarge) },
@@ -68,41 +76,70 @@ fun RecipientPickerScaffold(
actions = { topAppBarActions() }
)
},
secondaryContent = {
if (isSplitPane) {
ScreenTitlePane(
title = title,
modifier = Modifier.fillMaxSize()
)
} else {
Box {
primaryContent()
FloatingActionButtonContainer(floatingActionButton)
}
}
},
primaryContent = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
snackbarHost = {
SnackbarHost(snackbarHostState)
}
) { paddingValues ->
if (isSplitPane) {
SplitPaneLayout(
title = title,
windowSizeClass = windowSizeClass,
modifier = Modifier.padding(paddingValues)
) {
Box(modifier = Modifier.widthIn(max = windowSizeClass.detailPaneMaxContentWidth)) {
primaryContent()
FloatingActionButtonContainer(floatingActionButton)
}
}
},
} else {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
primaryContent()
FloatingActionButtonContainer(floatingActionButton)
}
}
}
}
snackbarHost = {
SnackbarHost(snackbarHostState)
},
/**
* Places the screen title beside [content], capping the title pane at [listPaneDefaultPreferredWidth] and splitting
* the available width evenly below twice that.
*/
@Composable
private fun SplitPaneLayout(
title: String,
windowSizeClass: WindowSizeClass,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val spacerWidth = windowSizeClass.horizontalPartitionDefaultSpacerSize
navigator = rememberAppScaffoldNavigator(
isSplitPane = isSplitPane
)
)
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val titlePaneWidth = ((maxWidth - spacerWidth) / 2).coerceAtMost(windowSizeClass.listPaneDefaultPreferredWidth)
Row(modifier = Modifier.fillMaxSize()) {
ScreenTitlePane(
title = title,
modifier = Modifier
.width(titlePaneWidth)
.fillMaxHeight()
)
Spacer(modifier = Modifier.width(spacerWidth))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
content()
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@@ -1,457 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.window
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.snap
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.PaneExpansionState
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope
import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.launch
import org.signal.core.ui.NavigationType
import org.signal.core.ui.compose.BreakpointPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.rememberIsSplitPane
import org.thoughtcrime.securesms.main.MainFloatingActionButtonsCallback
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationBarState
import org.thoughtcrime.securesms.main.MainNavigationRail
import kotlin.math.max
/**
* 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.
*
* On phone-class layouts (single horizontal partition) running on devices that predate predictive back (API < 33),
* this dispatches to [SinglePaneAppScaffold], which skips [NavigableListDetailPaneScaffold] / [ThreePaneScaffold] and
* its lookahead measurement pass. The scaffold's seek-driven predictive back animation never fires on those devices,
* so we pay no UX cost for the simpler implementation.
*
* @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. Ignored by [SinglePaneAppScaffold].
* @param paneExpansionDragHandle An optional drag handle used to resize panes in the list-detail layout. Ignored by [SinglePaneAppScaffold].
*
* @param animatorFactory Provides animations to control how panes enter and exit the screen during navigation. Ignored by [SinglePaneAppScaffold].
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun AppScaffold(
navigator: AppScaffoldNavigator<Any>,
modifier: Modifier = Modifier,
topBarContent: @Composable () -> Unit = {},
primaryContent: @Composable () -> Unit = {},
secondaryContent: @Composable () -> Unit,
navRailContent: @Composable () -> Unit = {},
bottomNavContent: @Composable () -> Unit = {},
paneExpansionState: PaneExpansionState = rememberPaneExpansionState(),
paneExpansionDragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null,
snackbarHost: @Composable () -> Unit = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default
) {
val useSimpleScaffold = navigator.scaffoldDirective.maxHorizontalPartitions == 1 && Build.VERSION.SDK_INT < 33
if (useSimpleScaffold && LocalLayoutDirection.current != LayoutDirection.Rtl) {
SinglePaneAppScaffold(
navigator = navigator,
modifier = modifier,
topBarContent = topBarContent,
primaryContent = primaryContent,
secondaryContent = secondaryContent,
bottomNavContent = bottomNavContent,
snackbarHost = snackbarHost,
contentWindowInsets = contentWindowInsets,
animatorFactory = animatorFactory
)
} else {
AdaptiveAppScaffold(
navigator = navigator,
modifier = modifier,
topBarContent = topBarContent,
primaryContent = primaryContent,
secondaryContent = secondaryContent,
navRailContent = navRailContent,
bottomNavContent = bottomNavContent,
paneExpansionState = paneExpansionState,
paneExpansionDragHandle = paneExpansionDragHandle,
snackbarHost = snackbarHost,
contentWindowInsets = contentWindowInsets,
animatorFactory = animatorFactory
)
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun AdaptiveAppScaffold(
navigator: AppScaffoldNavigator<Any>,
modifier: Modifier = Modifier,
topBarContent: @Composable () -> Unit = {},
primaryContent: @Composable () -> Unit = {},
secondaryContent: @Composable () -> Unit,
navRailContent: @Composable () -> Unit = {},
bottomNavContent: @Composable () -> Unit = {},
paneExpansionState: PaneExpansionState = rememberPaneExpansionState(),
paneExpansionDragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null,
snackbarHost: @Composable () -> Unit = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default
) {
val minPaneWidth = navigator.scaffoldDirective.defaultPanePreferredWidth
val navigationState = navigator.state
Scaffold(
containerColor = Color.Transparent,
contentWindowInsets = contentWindowInsets,
topBar = topBarContent,
snackbarHost = snackbarHost,
modifier = modifier
) { paddingValues ->
NavigableListDetailPaneScaffold(
navigator = navigator,
listPane = {
val animationState = with(animatorFactory) {
this@NavigableListDetailPaneScaffold.getListAnimationState(navigationState)
}
AnimatedPane(
enterTransition = EnterTransition.None,
exitTransition = ExitTransition.None,
modifier = Modifier
.zIndex(0f)
.drawWithContent {
with(animationState) {
applyParentValues()
}
}
) {
Box(
modifier = Modifier
.graphicsLayer {
with(animationState) {
applyChildValues()
}
}
.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,
contentWindowInsets = WindowInsets() // parent scaffold already applies the necessary insets
)
}
}
},
detailPane = {
val animationState = with(animatorFactory) {
this@NavigableListDetailPaneScaffold.getDetailAnimationState(navigationState)
}
AnimatedPane(
enterTransition = EnterTransition.None,
exitTransition = ExitTransition.None,
modifier = Modifier
.zIndex(1f)
.drawWithContent {
with(animationState) {
applyParentValues()
}
}
) {
Box(
modifier = Modifier
.graphicsLayer {
with(animationState) {
applyChildValues()
}
}
.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
)
}
}
) {
primaryContent()
}
}
},
paneExpansionDragHandle = paneExpansionDragHandle,
paneExpansionState = paneExpansionState,
modifier = Modifier.padding(paddingValues)
)
}
}
/**
* Phone-only scaffold that swaps content between [secondaryContent] and [primaryContent] without using
* [NavigableListDetailPaneScaffold]. Avoids the lookahead measurement pass and deep adaptive layout tree
* that drives ANR on low-end devices.
*
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun SinglePaneAppScaffold(
navigator: AppScaffoldNavigator<Any>,
modifier: Modifier = Modifier,
topBarContent: @Composable () -> Unit = {},
primaryContent: @Composable () -> Unit = {},
secondaryContent: @Composable () -> Unit,
bottomNavContent: @Composable () -> Unit = {},
snackbarHost: @Composable () -> Unit = {},
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default
) {
val showDetail = navigator.scaffoldValue.primary == PaneAdaptedValue.Expanded
val coroutineScope = rememberCoroutineScope()
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val directionMultiplier = if (isRtl) -1 else 1
val skipSlide = AppScaffoldNavigator.NavigationState.ENTER !in animatorFactory.enabledStates
BackHandler(enabled = navigator.canNavigateBack()) {
coroutineScope.launch { navigator.navigateBack() }
}
Scaffold(
containerColor = Color.Transparent,
contentWindowInsets = contentWindowInsets,
topBar = topBarContent,
snackbarHost = snackbarHost,
modifier = modifier
) { paddingValues ->
AnimatedContent(
targetState = showDetail,
transitionSpec = {
val transform = when {
skipSlide -> EnterTransition.None togetherWith ExitTransition.None
targetState -> slideInHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> fullWidth * directionMultiplier } togetherWith
slideOutHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> -fullWidth * directionMultiplier }
else -> slideInHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> -fullWidth * directionMultiplier } togetherWith
slideOutHorizontally(animationSpec = AppScaffoldAnimationDefaults.tween()) { fullWidth -> fullWidth * directionMultiplier }
}
transform using SizeTransform(clip = false) { _, _ -> snap() }
},
modifier = Modifier.padding(paddingValues),
label = "SimpleAppScaffold"
) { isDetail ->
if (isDetail) {
primaryContent()
} else {
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f)) {
secondaryContent()
}
bottomNavContent()
}
}
}
}
}
@Composable
private fun ListAndNavigation(
topBarContent: @Composable () -> Unit,
listContent: @Composable () -> Unit,
navRailContent: @Composable () -> Unit,
bottomNavContent: @Composable () -> Unit,
snackbarHost: @Composable () -> Unit = {},
contentWindowInsets: WindowInsets,
modifier: Modifier = Modifier
) {
val navigationType = NavigationType.rememberNavigationType()
Scaffold(
containerColor = Color.Transparent,
topBar = topBarContent,
contentWindowInsets = contentWindowInsets,
snackbarHost = snackbarHost,
modifier = modifier
) { paddingValues ->
Row(
modifier = Modifier
.padding(paddingValues)
) {
if (navigationType == NavigationType.RAIL) {
navRailContent()
}
Column {
Box(modifier = Modifier.weight(1f)) {
listContent()
}
if (navigationType == NavigationType.BAR) {
bottomNavContent()
}
}
}
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@BreakpointPreviews
@Composable
private fun AppScaffoldPreview() {
Previews.Preview {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isSplitPane = LocalResources.current.rememberIsSplitPane()
AppScaffold(
navigator = rememberAppScaffoldNavigator(
isSplitPane = isSplitPane,
defaultPanePreferredWidth = 416.dp,
horizontalPartitionSpacerSize = 16.dp
),
secondaryContent = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(color = Color.Red)
) {
Text(
text = "ListContent\n$windowSizeClass",
textAlign = TextAlign.Center
)
}
},
primaryContent = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(color = Color.Blue)
) {
Text(
text = "DetailContent",
textAlign = TextAlign.Center
)
}
},
navRailContent = {
MainNavigationRail(
state = MainNavigationBarState(),
mainFloatingActionButtonsCallback = MainFloatingActionButtonsCallback.Empty,
onDestinationSelected = {}
)
},
bottomNavContent = {
MainNavigationBar(
state = MainNavigationBarState(),
onDestinationSelected = {}
)
},
paneExpansionState = rememberPaneExpansionState(),
paneExpansionDragHandle = {
AppPaneDragHandle(
paneExpansionState = it,
mutableInteractionSource = remember { MutableInteractionSource() }
)
}
)
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldScope.AppPaneDragHandle(
paneExpansionState: PaneExpansionState,
mutableInteractionSource: MutableInteractionSource
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.paneExpansionDraggable(
state = paneExpansionState,
minTouchTargetSize = LocalMinimumInteractiveComponentSize.current,
interactionSource = mutableInteractionSource,
semanticsProperties = paneExpansionState.defaultDragHandleSemantics()
)
) {
Box(
modifier = Modifier
.size(4.dp, 48.dp)
.background(color = Color(0xFF605F5D), RoundedCornerShape(percent = 50))
)
}
}
@@ -1,122 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.window
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.GraphicsLayerScope
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp
/**
* Default animation settings for app-scaffold animations.
*/
object AppScaffoldAnimationDefaults {
val TweenEasing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1f)
val InitAnimationOffset = 48.dp
fun <T> tween() = tween<T>(durationMillis = 200, easing = TweenEasing)
}
data class AppScaffoldAnimationState(
private val alpha: State<Float> = mutableStateOf(1f),
private val scale: State<Float> = mutableStateOf(1f),
val scaleMinimum: Float = 0f,
private val offset: State<Dp> = mutableStateOf(0.dp),
private val corners: State<Dp> = mutableStateOf(0.dp),
val cornersMaximum: Dp = 1000.dp,
private val parentOverlayAlpha: State<Float> = mutableStateOf(0f)
) {
private val unclampedScale by scale
private val unclampedCorners by corners
val contentAlpha by alpha
val contentScale by derivedStateOf { unclampedScale.coerceAtLeast(scaleMinimum) }
val contentOffset by offset
val contentCorners by derivedStateOf { unclampedCorners.coerceAtMost(cornersMaximum) }
fun ContentDrawScope.applyParentValues() {
drawContent()
drawRect(Color(0f, 0f, 0f, parentOverlayAlpha.value))
}
fun GraphicsLayerScope.applyChildValues() {
this.alpha = contentAlpha
this.scaleX = contentScale
this.scaleY = contentScale
this.translationX = contentOffset.toPx()
this.translationY = 0f
this.clip = true
this.shape = RoundedCornerShape(contentCorners)
}
}
/**
* Allows for the customization of the AppScaffold Animators.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
class AppScaffoldAnimationStateFactory(
val enabledStates: Set<AppScaffoldNavigator.NavigationState> = AppScaffoldNavigator.NavigationState.entries.toSet()
) {
companion object {
val Default = AppScaffoldAnimationStateFactory()
private val EMPTY_STATE = AppScaffoldAnimationState(
alpha = mutableStateOf(1f)
)
}
private var latestListSeekState: AppScaffoldAnimationState = EMPTY_STATE
private var latestDetailSeekState: AppScaffoldAnimationState = EMPTY_STATE
@Composable
fun ThreePaneScaffoldPaneScope.getListAnimationState(navigationState: AppScaffoldNavigator.NavigationState): AppScaffoldAnimationState {
if (navigationState !in enabledStates) {
return EMPTY_STATE
}
return when (navigationState) {
AppScaffoldNavigator.NavigationState.ENTER -> defaultListInitAnimationState()
AppScaffoldNavigator.NavigationState.EXIT -> defaultListInitAnimationState()
AppScaffoldNavigator.NavigationState.SEEK -> defaultListSeekAnimationState().also {
latestListSeekState = it
}
AppScaffoldNavigator.NavigationState.RELEASE -> defaultListReleaseAnimationState(latestListSeekState)
}
}
@Composable
fun ThreePaneScaffoldPaneScope.getDetailAnimationState(navigationState: AppScaffoldNavigator.NavigationState): AppScaffoldAnimationState {
if (navigationState !in enabledStates) {
return EMPTY_STATE
}
return when (navigationState) {
AppScaffoldNavigator.NavigationState.ENTER -> defaultDetailInitAnimationState()
AppScaffoldNavigator.NavigationState.EXIT -> defaultDetailInitAnimationState()
AppScaffoldNavigator.NavigationState.SEEK -> defaultDetailSeekAnimationState().also {
latestDetailSeekState = it
}
AppScaffoldNavigator.NavigationState.RELEASE -> defaultDetailReleaseAnimationState(latestDetailSeekState)
}
}
}
@@ -1,253 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.window
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.spring
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
private const val SEEK_DAMPING_RATIO = Spring.DampingRatioNoBouncy
private const val SEEK_STIFFNESS = Spring.StiffnessMedium
/**
* Default animation spec for back gesture seeking.
*/
fun <T> appScaffoldSeekSpring(): FiniteAnimationSpec<T> = spring(
dampingRatio = SEEK_DAMPING_RATIO,
stiffness = SEEK_STIFFNESS
)
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldPaneScope.animateDp(
transitionSpec: @Composable Transition.Segment<*>.() -> FiniteAnimationSpec<Dp> = { AppScaffoldAnimationDefaults.tween() },
targetWhenHiding: () -> Dp = { 0.dp },
targetWhenShowing: () -> Dp
): State<Dp> {
return scaffoldStateTransition.animateDp(
transitionSpec = transitionSpec
) {
val isHiding = it[paneRole] == PaneAdaptedValue.Hidden
if (isHiding) {
targetWhenHiding()
} else {
targetWhenShowing()
}
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldPaneScope.animateFloat(
transitionSpec: @Composable Transition.Segment<*>.() -> FiniteAnimationSpec<Float> = { AppScaffoldAnimationDefaults.tween() },
targetWhenHiding: () -> Float = { 0f },
targetWhenShowing: () -> Float
): State<Float> {
return scaffoldStateTransition.animateFloat(
transitionSpec = transitionSpec
) {
val isHiding = it[paneRole] == PaneAdaptedValue.Hidden
if (isHiding) {
targetWhenHiding()
} else {
targetWhenShowing()
}
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnimationState {
val directionMultiplier = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
val offset = animateDp(
targetWhenHiding = {
-AppScaffoldAnimationDefaults.InitAnimationOffset * directionMultiplier
},
targetWhenShowing = {
0.dp
}
)
val alpha = animateFloat {
1f
}
return remember {
AppScaffoldAnimationState(
alpha = alpha,
offset = offset
)
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnimationState {
val directionMultiplier = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
val scale = animateFloat(
transitionSpec = {
appScaffoldSeekSpring()
},
targetWhenShowing = { 0.5f },
targetWhenHiding = { 1f }
)
val offset = animateDp(
transitionSpec = {
appScaffoldSeekSpring()
},
targetWhenHiding = { -(88.dp) * directionMultiplier },
targetWhenShowing = { 0.dp }
)
return remember {
AppScaffoldAnimationState(
offset = offset,
scale = scale,
scaleMinimum = 0.9f,
parentOverlayAlpha = mutableStateOf(0.2f)
)
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldPaneScope.defaultListReleaseAnimationState(from: AppScaffoldAnimationState): AppScaffoldAnimationState {
val initialScale = remember { from.contentScale }
val initialOffset = remember { from.contentOffset }
val scale = animateFloat(
targetWhenHiding = { initialScale },
targetWhenShowing = { 1f }
)
val offset = animateDp(
targetWhenHiding = { initialOffset },
targetWhenShowing = { 0.dp }
)
val alpha = animateFloat(
targetWhenHiding = { 0.2f },
targetWhenShowing = { 0f }
)
return remember {
AppScaffoldAnimationState(
scale = scale,
scaleMinimum = from.scaleMinimum,
offset = offset,
parentOverlayAlpha = alpha
)
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAnimationState {
val directionMultiplier = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
val offset = animateDp(
targetWhenHiding = {
AppScaffoldAnimationDefaults.InitAnimationOffset * directionMultiplier
},
targetWhenShowing = {
0.dp
}
)
val alpha = animateFloat {
1f
}
return remember {
AppScaffoldAnimationState(
alpha = alpha,
offset = offset
)
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAnimationState {
val directionMultiplier = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
val scale = animateFloat(
transitionSpec = {
appScaffoldSeekSpring()
},
targetWhenShowing = { 1f },
targetWhenHiding = { 0.5f }
)
val offset = animateDp(
transitionSpec = {
appScaffoldSeekSpring()
},
targetWhenShowing = { 0.dp },
targetWhenHiding = { 88.dp * directionMultiplier }
)
val roundedCorners = animateDp(
transitionSpec = {
appScaffoldSeekSpring()
}
) { 1000.dp }
return remember {
AppScaffoldAnimationState(
scale = scale,
scaleMinimum = 0.9f,
offset = offset,
corners = roundedCorners,
cornersMaximum = 42.dp
)
}
}
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ThreePaneScaffoldPaneScope.defaultDetailReleaseAnimationState(from: AppScaffoldAnimationState): AppScaffoldAnimationState {
val scale = remember { from.contentScale }
val offset = remember { from.contentOffset }
val corners = remember { from.contentCorners }
val scaleState = remember { mutableStateOf(scale) }
val offsetState = remember { mutableStateOf(offset) }
val cornersState = remember { mutableStateOf(corners) }
val alpha = animateFloat { 1f }
return remember {
AppScaffoldAnimationState(
scale = scaleState,
scaleMinimum = from.scaleMinimum,
offset = offsetState,
corners = cornersState,
cornersMaximum = from.cornersMaximum,
alpha = alpha
)
}
}
@@ -1,141 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.window
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.annotation.RememberInComposition
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.window.core.layout.WindowSizeClass
import org.signal.core.ui.horizontalPartitionDefaultSpacerSize
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.listPaneDefaultPreferredWidth
import org.signal.core.ui.rememberIsSplitPane
/**
* AppScaffoldNavigator wraps a delegate navigator (such as the value returned by [rememberThreePaneScaffoldNavigatorDelegate]
* and implements a state machine that will produce [NavigationState] to allow proper animation coordination.
*/
@Stable
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
open class AppScaffoldNavigator<T> @RememberInComposition constructor(private val delegate: ThreePaneScaffoldNavigator<T>) : ThreePaneScaffoldNavigator<T> by delegate {
var state: NavigationState by mutableStateOf(NavigationState.ENTER)
private set
override suspend fun navigateTo(pane: ThreePaneScaffoldRole, contentKey: T?) {
state = NavigationState.ENTER
return delegate.navigateTo(pane, contentKey)
}
override suspend fun navigateBack(backNavigationBehavior: BackNavigationBehavior): Boolean {
if (state == NavigationState.SEEK) {
state = NavigationState.RELEASE
}
if (state == NavigationState.ENTER) {
state = NavigationState.EXIT
}
return delegate.navigateBack(backNavigationBehavior)
}
override suspend fun seekBack(backNavigationBehavior: BackNavigationBehavior, fraction: Float) {
if (fraction == 0f && state == NavigationState.SEEK) {
state = NavigationState.ENTER
}
if (fraction > 0f && state != NavigationState.SEEK) {
state = NavigationState.SEEK
}
return delegate.seekBack(backNavigationBehavior, fraction)
}
/**
* State machine which describes the current navigation state to help with animation coordination.
*/
enum class NavigationState {
/**
* We've navigated to a new pane.
*/
ENTER,
/**
* We've navigated back from a pane without using seek.
*/
EXIT,
/**
* The user is performing a back gesture seek action.
*/
SEEK,
/**
* The user has let go of a seek and will go back.
*/
RELEASE
}
}
/**
* Sane default navigator. If you want to provide your own implementation
* of AppScaffoldNavigator, utilize the remember pattern here but use
* [rememberThreePaneScaffoldNavigatorDelegate] to get a delegate and hand off
* to your own subclass of [AppScaffoldNavigator]
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun rememberAppScaffoldNavigator(
windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass,
isSplitPane: Boolean = LocalResources.current.rememberIsSplitPane(),
horizontalPartitionSpacerSize: Dp = windowSizeClass.horizontalPartitionDefaultSpacerSize,
defaultPanePreferredWidth: Dp = windowSizeClass.listPaneDefaultPreferredWidth
): AppScaffoldNavigator<Any> {
val delegate = rememberThreePaneScaffoldNavigatorDelegate(
isSplitPane,
horizontalPartitionSpacerSize,
defaultPanePreferredWidth
)
return remember(delegate) { AppScaffoldNavigator(delegate) }
}
/**
* Produces a ThreePaneScaffoldNavigatorDelegate. Since the developer can
* further modify navigator behavior, this is best done using the delegate pattern.
* Use this to grab the initial delegate, and then either subclass or create an
* instance of [AppScaffoldNavigator] keyed to this delegate.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun rememberThreePaneScaffoldNavigatorDelegate(
isSplitPane: Boolean,
horizontalPartitionSpacerSize: Dp,
defaultPanePreferredWidth: Dp
): ThreePaneScaffoldNavigator<Any> {
return rememberListDetailPaneScaffoldNavigator<Any>(
scaffoldDirective = calculatePaneScaffoldDirective(
currentWindowAdaptiveInfo()
).copy(
maxHorizontalPartitions = if (isSplitPane) 2 else 1,
horizontalPartitionSpacerSize = horizontalPartitionSpacerSize,
defaultPanePreferredWidth = defaultPanePreferredWidth
)
)
}
@@ -1,75 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.window
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
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.text.style.TextAlign
import org.signal.core.ui.compose.BreakpointPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
@BreakpointPreviews
@Composable
private fun AppScaffoldWithTopBarPreview() {
Previews.Preview {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
AppScaffold(
navigator = rememberAppScaffoldNavigator(),
topBarContent = {
Scaffolds.DefaultTopAppBar(
title = "Hello World!",
titleContent = { _, title -> Text(text = title, style = MaterialTheme.typography.titleLarge) },
navigationIcon = SignalIcons.ArrowStart.imageVector,
navigationContentDescription = "",
onNavigationClick = { }
)
},
secondaryContent = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(color = Color.Red)
) {
Text(
text = "ListContent\n$windowSizeClass",
textAlign = TextAlign.Center
)
}
},
primaryContent = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.background(color = Color.Blue)
) {
Text(
text = "DetailContent",
textAlign = TextAlign.Center
)
}
}
)
}
}