mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-20 08:45:50 +01:00
AppScaffold removal.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -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)
|
||||
|
||||
+71
-34
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user