mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
Implement AppScaffold back-gesture.
This commit is contained in:
@@ -40,7 +40,6 @@ import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
|
||||
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -159,8 +158,9 @@ import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import org.thoughtcrime.securesms.window.AppPaneDragHandle
|
||||
import org.thoughtcrime.securesms.window.AppScaffold
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
|
||||
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
|
||||
class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider, Material3OnScrollHelperBinder, ConversationListFragment.Callback, CallLogFragment.Callback {
|
||||
@@ -585,8 +585,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
windowSizeClass: WindowSizeClass,
|
||||
contentLayoutData: MainContentLayoutData,
|
||||
maxWidth: Dp
|
||||
): ThreePaneScaffoldNavigator<Any> {
|
||||
val scaffoldNavigator = rememberAppScaffoldNavigator(
|
||||
): AppScaffoldNavigator<Any> {
|
||||
val scaffoldNavigator = rememberThreePaneScaffoldNavigatorDelegate(
|
||||
isSplitPane = windowSizeClass.isSplitPane(),
|
||||
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
|
||||
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.megaphone.Megaphone
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
|
||||
import org.thoughtcrime.securesms.window.WindowSizeClass
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@@ -38,7 +39,7 @@ class MainNavigationViewModel(
|
||||
) : ViewModel(), MainNavigationRouter {
|
||||
private val megaphoneRepository = AppDependencies.megaphoneRepository
|
||||
|
||||
private var navigator: ThreePaneScaffoldNavigator<Any>? = null
|
||||
private var navigator: AppScaffoldNavigator<Any>? = null
|
||||
private var navigatorScope: CoroutineScope? = null
|
||||
private var goToLegacyDetailLocation: ((MainNavigationDetailLocation) -> Unit)? = null
|
||||
|
||||
@@ -104,7 +105,7 @@ class MainNavigationViewModel(
|
||||
* Sets the navigator on the view-model. This wraps the given navigator in our own delegating implementation
|
||||
* such that we can react to navigateTo/Back signals and maintain proper state for internalDetailLocation.
|
||||
*/
|
||||
fun wrapNavigator(composeScope: CoroutineScope, threePaneScaffoldNavigator: ThreePaneScaffoldNavigator<Any>, goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit): ThreePaneScaffoldNavigator<Any> {
|
||||
fun wrapNavigator(composeScope: CoroutineScope, threePaneScaffoldNavigator: ThreePaneScaffoldNavigator<Any>, goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit): AppScaffoldNavigator<Any> {
|
||||
this.goToLegacyDetailLocation = goToLegacyDetailLocation
|
||||
this.navigatorScope = composeScope
|
||||
this.navigator = Nav(threePaneScaffoldNavigator)
|
||||
@@ -269,9 +270,9 @@ class MainNavigationViewModel(
|
||||
* Ensures that when the user navigates back from the PRIMARY to SECONDARY pane, we lock our pane until they choose another primary
|
||||
* piece of content via [goTo].
|
||||
*/
|
||||
private inner class Nav<T>(private val delegate: ThreePaneScaffoldNavigator<T>) : ThreePaneScaffoldNavigator<T> by delegate {
|
||||
private inner class Nav<T>(delegate: ThreePaneScaffoldNavigator<T>) : AppScaffoldNavigator<T>(delegate) {
|
||||
override suspend fun seekBack(backNavigationBehavior: BackNavigationBehavior, fraction: Float) {
|
||||
delegate.seekBack(backNavigationBehavior, fraction)
|
||||
super.seekBack(backNavigationBehavior, fraction)
|
||||
|
||||
if (fraction == 0f) {
|
||||
lockPaneToSecondary = true
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
|
||||
@@ -26,26 +25,19 @@ import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.layout.AnimatedPane
|
||||
import androidx.compose.material3.adaptive.layout.PaneExpansionState
|
||||
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldScope
|
||||
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
|
||||
import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics
|
||||
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
|
||||
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
|
||||
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
|
||||
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.window.core.ExperimentalWindowCoreApi
|
||||
@@ -208,12 +200,13 @@ enum class WindowSizeClass(
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun AppScaffold(
|
||||
navigator: ThreePaneScaffoldNavigator<Any> = rememberListDetailPaneScaffoldNavigator<Any>(),
|
||||
navigator: AppScaffoldNavigator<Any>,
|
||||
detailContent: @Composable () -> Unit = {},
|
||||
navRailContent: @Composable () -> Unit = {},
|
||||
bottomNavContent: @Composable () -> Unit = {},
|
||||
paneExpansionState: PaneExpansionState = rememberPaneExpansionState(),
|
||||
paneExpansionDragHandle: (@Composable ThreePaneScaffoldScope.(PaneExpansionState) -> Unit)? = null,
|
||||
animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default,
|
||||
listContent: @Composable () -> Unit
|
||||
) {
|
||||
val isForcedCompact = WindowSizeClass.checkForcedCompact()
|
||||
@@ -231,32 +224,25 @@ fun AppScaffold(
|
||||
}
|
||||
|
||||
val minPaneWidth = navigator.scaffoldDirective.defaultPanePreferredWidth
|
||||
val navigationState = navigator.state
|
||||
|
||||
NavigableListDetailPaneScaffold(
|
||||
navigator = navigator,
|
||||
listPane = {
|
||||
val offset by animateDp(
|
||||
targetWhenHiding = {
|
||||
(-48).dp
|
||||
},
|
||||
targetWhenShowing = {
|
||||
0.dp
|
||||
}
|
||||
)
|
||||
|
||||
val alpha by animateFloat {
|
||||
1f
|
||||
val animationState = with(animatorFactory) {
|
||||
this@NavigableListDetailPaneScaffold.getListAnimationState(navigationState)
|
||||
}
|
||||
|
||||
AnimatedPane(
|
||||
enterTransition = EnterTransition.None,
|
||||
exitTransition = ExitTransition.None,
|
||||
modifier = Modifier.zIndex(0f)
|
||||
modifier = Modifier
|
||||
.zIndex(0f)
|
||||
.then(animationState.parentModifier)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.alpha(alpha)
|
||||
.offset(x = offset)
|
||||
.then(animationState.toModifier())
|
||||
.clipToBounds()
|
||||
.layout { measurable, constraints ->
|
||||
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
|
||||
@@ -284,28 +270,20 @@ fun AppScaffold(
|
||||
}
|
||||
},
|
||||
detailPane = {
|
||||
val offset by animateDp(
|
||||
targetWhenHiding = {
|
||||
48.dp
|
||||
},
|
||||
targetWhenShowing = {
|
||||
0.dp
|
||||
}
|
||||
)
|
||||
|
||||
val alpha by animateFloat {
|
||||
1f
|
||||
val animationState = with(animatorFactory) {
|
||||
this@NavigableListDetailPaneScaffold.getDetailAnimationState(navigationState)
|
||||
}
|
||||
|
||||
AnimatedPane(
|
||||
enterTransition = EnterTransition.None,
|
||||
exitTransition = ExitTransition.None,
|
||||
modifier = Modifier.zIndex(1f)
|
||||
modifier = Modifier
|
||||
.zIndex(1f)
|
||||
.then(animationState.parentModifier)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.alpha(alpha)
|
||||
.offset(x = offset)
|
||||
.then(animationState.toModifier())
|
||||
.clipToBounds()
|
||||
.layout { measurable, constraints ->
|
||||
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
|
||||
@@ -447,21 +425,3 @@ fun ThreePaneScaffoldScope.AppPaneDragHandle(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun rememberAppScaffoldNavigator(
|
||||
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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.window
|
||||
|
||||
import androidx.compose.foundation.layout.offset
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Produces modifier that can be composed into another modifier chain.
|
||||
* This object allows us to store "latest state" as we transition.
|
||||
*
|
||||
* @param parentModifier This modifier is applied to the [androidx.compose.material3.adaptive.layout.AnimatedPane] itself,
|
||||
* allowing additional customization like overlays without
|
||||
* the need for additional composables.
|
||||
*/
|
||||
data class AppScaffoldAnimationState(
|
||||
val navigationState: AppScaffoldNavigator.NavigationState,
|
||||
val alpha: Float = 1f,
|
||||
val scale: Float = 1f,
|
||||
val offset: Dp = 0.dp,
|
||||
val clipShape: Shape = RoundedCornerShape(0.dp),
|
||||
val parentModifier: Modifier = Modifier.Companion
|
||||
) {
|
||||
fun toModifier(): Modifier {
|
||||
return Modifier
|
||||
.alpha(alpha)
|
||||
.scale(scale)
|
||||
.offset(offset)
|
||||
.clip(clipShape)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows for the customization of the AppScaffold Animators.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
abstract class AppScaffoldAnimationStateFactory {
|
||||
|
||||
object Default : AppScaffoldAnimationStateFactory()
|
||||
|
||||
protected var latestListSeekState: AppScaffoldAnimationState = AppScaffoldAnimationState(AppScaffoldNavigator.NavigationState.SEEK)
|
||||
protected var latestDetailSeekState: AppScaffoldAnimationState = AppScaffoldAnimationState(AppScaffoldNavigator.NavigationState.SEEK)
|
||||
|
||||
@Composable
|
||||
fun ThreePaneScaffoldPaneScope.getListAnimationState(navigationState: AppScaffoldNavigator.NavigationState): AppScaffoldAnimationState {
|
||||
return when (navigationState) {
|
||||
AppScaffoldNavigator.NavigationState.INIT -> defaultListInitAnimationState()
|
||||
AppScaffoldNavigator.NavigationState.SEEK -> defaultListSeekAnimationState().also {
|
||||
latestListSeekState = it
|
||||
}
|
||||
AppScaffoldNavigator.NavigationState.RELEASE -> defaultListReleaseAnimationState(latestListSeekState)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ThreePaneScaffoldPaneScope.getDetailAnimationState(navigationState: AppScaffoldNavigator.NavigationState): AppScaffoldAnimationState {
|
||||
return when (navigationState) {
|
||||
AppScaffoldNavigator.NavigationState.INIT -> defaultDetailInitAnimationState()
|
||||
AppScaffoldNavigator.NavigationState.SEEK -> defaultDetailSeekAnimationState().also {
|
||||
latestDetailSeekState = it
|
||||
}
|
||||
AppScaffoldNavigator.NavigationState.RELEASE -> defaultDetailReleaseAnimationState(latestDetailSeekState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,27 +6,49 @@
|
||||
package org.thoughtcrime.securesms.window
|
||||
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
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.animation.core.tween
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.coerceAtMost
|
||||
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
|
||||
)
|
||||
|
||||
private val easing = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1f)
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun ThreePaneScaffoldPaneScope.animateDp(
|
||||
transitionSpec: @Composable Transition.Segment<*>.() -> FiniteAnimationSpec<Dp> = { tween(durationMillis = 200, easing = easing) },
|
||||
targetWhenHiding: () -> Dp = { 0.dp },
|
||||
targetWhenShowing: () -> Dp
|
||||
): State<Dp> {
|
||||
return scaffoldStateTransition.animateDp(
|
||||
transitionSpec = { tween(durationMillis = 200, easing = easing) }
|
||||
transitionSpec = transitionSpec
|
||||
) {
|
||||
val isHiding = it[paneRole] == PaneAdaptedValue.Hidden
|
||||
|
||||
@@ -41,11 +63,12 @@ fun ThreePaneScaffoldPaneScope.animateDp(
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun ThreePaneScaffoldPaneScope.animateFloat(
|
||||
transitionSpec: @Composable Transition.Segment<*>.() -> FiniteAnimationSpec<Float> = { tween(durationMillis = 200, easing = easing) },
|
||||
targetWhenHiding: () -> Float = { 0f },
|
||||
targetWhenShowing: () -> Float
|
||||
): State<Float> {
|
||||
return scaffoldStateTransition.animateFloat(
|
||||
transitionSpec = { tween(durationMillis = 200, easing = easing) }
|
||||
transitionSpec = transitionSpec
|
||||
) {
|
||||
val isHiding = it[paneRole] == PaneAdaptedValue.Hidden
|
||||
|
||||
@@ -56,3 +79,157 @@ fun ThreePaneScaffoldPaneScope.animateFloat(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnimationState {
|
||||
val offset by animateDp(
|
||||
targetWhenHiding = {
|
||||
(-48).dp
|
||||
},
|
||||
targetWhenShowing = {
|
||||
0.dp
|
||||
}
|
||||
)
|
||||
|
||||
val alpha by animateFloat {
|
||||
1f
|
||||
}
|
||||
|
||||
return AppScaffoldAnimationState(
|
||||
AppScaffoldNavigator.NavigationState.INIT,
|
||||
alpha = alpha,
|
||||
offset = offset
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnimationState {
|
||||
val scale by animateFloat(
|
||||
transitionSpec = {
|
||||
appScaffoldSeekSpring()
|
||||
},
|
||||
targetWhenShowing = { 0.5f },
|
||||
targetWhenHiding = { 1f }
|
||||
)
|
||||
|
||||
val offset by animateDp(
|
||||
transitionSpec = {
|
||||
appScaffoldSeekSpring()
|
||||
},
|
||||
targetWhenHiding = { -(88.dp) },
|
||||
targetWhenShowing = { 0.dp }
|
||||
)
|
||||
|
||||
return AppScaffoldAnimationState(
|
||||
navigationState = AppScaffoldNavigator.NavigationState.SEEK,
|
||||
offset = offset,
|
||||
scale = scale.coerceAtLeast(0.9f),
|
||||
parentModifier = Modifier.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
drawRect(Color(0f, 0f, 0f, 0.2f))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun ThreePaneScaffoldPaneScope.defaultListReleaseAnimationState(from: AppScaffoldAnimationState): AppScaffoldAnimationState {
|
||||
val scale by animateFloat(
|
||||
targetWhenHiding = { from.scale },
|
||||
targetWhenShowing = { 1f }
|
||||
)
|
||||
|
||||
val offset by animateDp(
|
||||
targetWhenHiding = { from.offset },
|
||||
targetWhenShowing = { 0.dp }
|
||||
)
|
||||
|
||||
val alpha by animateFloat(
|
||||
targetWhenHiding = { 0.2f },
|
||||
targetWhenShowing = { 0f }
|
||||
)
|
||||
|
||||
return AppScaffoldAnimationState(
|
||||
navigationState = AppScaffoldNavigator.NavigationState.RELEASE,
|
||||
scale = scale,
|
||||
offset = offset,
|
||||
parentModifier = Modifier.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
drawRect(Color(0f, 0f, 0f, alpha))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAnimationState {
|
||||
val offset by animateDp(
|
||||
targetWhenHiding = {
|
||||
48.dp
|
||||
},
|
||||
targetWhenShowing = {
|
||||
0.dp
|
||||
}
|
||||
)
|
||||
|
||||
val alpha by animateFloat {
|
||||
1f
|
||||
}
|
||||
|
||||
return AppScaffoldAnimationState(
|
||||
navigationState = AppScaffoldNavigator.NavigationState.INIT,
|
||||
alpha = alpha,
|
||||
offset = offset
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAnimationState {
|
||||
val scale by animateFloat(
|
||||
transitionSpec = {
|
||||
appScaffoldSeekSpring()
|
||||
},
|
||||
targetWhenShowing = { 1f },
|
||||
targetWhenHiding = { 0.5f }
|
||||
)
|
||||
|
||||
val offset by animateDp(
|
||||
transitionSpec = {
|
||||
appScaffoldSeekSpring()
|
||||
},
|
||||
targetWhenShowing = { 0.dp },
|
||||
targetWhenHiding = { 88.dp }
|
||||
)
|
||||
|
||||
val roundedCorners by animateDp(
|
||||
transitionSpec = {
|
||||
appScaffoldSeekSpring()
|
||||
}
|
||||
) { 1000.dp }
|
||||
|
||||
return AppScaffoldAnimationState(
|
||||
navigationState = AppScaffoldNavigator.NavigationState.SEEK,
|
||||
scale = scale.coerceAtLeast(0.9f),
|
||||
offset = offset,
|
||||
clipShape = RoundedCornerShape(roundedCorners.coerceAtMost(42.dp))
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
|
||||
@Composable
|
||||
fun ThreePaneScaffoldPaneScope.defaultDetailReleaseAnimationState(from: AppScaffoldAnimationState): AppScaffoldAnimationState {
|
||||
val alpha by animateFloat { 1f }
|
||||
|
||||
return AppScaffoldAnimationState(
|
||||
navigationState = AppScaffoldNavigator.NavigationState.RELEASE,
|
||||
scale = from.scale,
|
||||
offset = from.offset,
|
||||
clipShape = from.clipShape,
|
||||
alpha = alpha
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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.unit.Dp
|
||||
|
||||
/**
|
||||
* 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.INIT)
|
||||
private set
|
||||
|
||||
override suspend fun navigateTo(pane: ThreePaneScaffoldRole, contentKey: T?) {
|
||||
state = NavigationState.INIT
|
||||
return delegate.navigateTo(pane, contentKey)
|
||||
}
|
||||
|
||||
override suspend fun navigateBack(backNavigationBehavior: BackNavigationBehavior): Boolean {
|
||||
if (state == NavigationState.SEEK) {
|
||||
state = NavigationState.RELEASE
|
||||
}
|
||||
|
||||
return delegate.navigateBack(backNavigationBehavior)
|
||||
}
|
||||
|
||||
override suspend fun seekBack(backNavigationBehavior: BackNavigationBehavior, fraction: Float) {
|
||||
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. This animation is used for both immediate
|
||||
* pane entry and exit (such as tapping a back button instead of using a
|
||||
* gesture)
|
||||
*/
|
||||
INIT,
|
||||
|
||||
/**
|
||||
* 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(
|
||||
isSplitPane: Boolean,
|
||||
horizontalPartitionSpacerSize: Dp,
|
||||
defaultPanePreferredWidth: Dp
|
||||
): 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
|
||||
)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user