From 1d403d3deef36e3fb7d939775d6e9f9683b193ae Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 3 Oct 2025 15:43:08 -0300 Subject: [PATCH] Implement AppScaffold back-gesture. --- .../thoughtcrime/securesms/MainActivity.kt | 8 +- .../securesms/main/MainNavigationViewModel.kt | 9 +- .../securesms/window/AppScaffold.kt | 70 ++----- .../window/AppScaffoldAnimationState.kt | 78 ++++++++ .../securesms/window/AppScaffoldAnimators.kt | 181 +++++++++++++++++- .../securesms/window/AppScaffoldNavigator.kt | 123 ++++++++++++ 6 files changed, 404 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimationState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldNavigator.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 359ac3c1e3..4a71958cab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -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 { - val scaffoldNavigator = rememberAppScaffoldNavigator( + ): AppScaffoldNavigator { + val scaffoldNavigator = rememberThreePaneScaffoldNavigatorDelegate( isSplitPane = windowSizeClass.isSplitPane(), horizontalPartitionSpacerSize = contentLayoutData.partitionWidth, defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth) diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt index 3cff7143aa..7b9939c321 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -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? = null + private var navigator: AppScaffoldNavigator? = 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, goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit): ThreePaneScaffoldNavigator { + fun wrapNavigator(composeScope: CoroutineScope, threePaneScaffoldNavigator: ThreePaneScaffoldNavigator, goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit): AppScaffoldNavigator { 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(private val delegate: ThreePaneScaffoldNavigator) : ThreePaneScaffoldNavigator by delegate { + private inner class Nav(delegate: ThreePaneScaffoldNavigator) : AppScaffoldNavigator(delegate) { override suspend fun seekBack(backNavigationBehavior: BackNavigationBehavior, fraction: Float) { - delegate.seekBack(backNavigationBehavior, fraction) + super.seekBack(backNavigationBehavior, fraction) if (fraction == 0f) { lockPaneToSecondary = true diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt index 5e1935e094..c7cef864a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt @@ -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 = rememberListDetailPaneScaffoldNavigator(), + navigator: AppScaffoldNavigator, 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 { - return rememberListDetailPaneScaffoldNavigator( - scaffoldDirective = calculatePaneScaffoldDirective( - currentWindowAdaptiveInfo() - ).copy( - maxHorizontalPartitions = if (isSplitPane) 2 else 1, - horizontalPartitionSpacerSize = horizontalPartitionSpacerSize, - defaultPanePreferredWidth = defaultPanePreferredWidth - ) - ) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimationState.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimationState.kt new file mode 100644 index 0000000000..6a6cec481c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimationState.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimators.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimators.kt index bdb704a68d..908afad74f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimators.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimators.kt @@ -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 appScaffoldSeekSpring(): FiniteAnimationSpec = 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 = { tween(durationMillis = 200, easing = easing) }, targetWhenHiding: () -> Dp = { 0.dp }, targetWhenShowing: () -> Dp ): State { 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 = { tween(durationMillis = 200, easing = easing) }, targetWhenHiding: () -> Float = { 0f }, targetWhenShowing: () -> Float ): State { 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 + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldNavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldNavigator.kt new file mode 100644 index 0000000000..87681368b7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldNavigator.kt @@ -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 @RememberInComposition constructor(private val delegate: ThreePaneScaffoldNavigator) : ThreePaneScaffoldNavigator 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 { + 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 { + return rememberListDetailPaneScaffoldNavigator( + scaffoldDirective = calculatePaneScaffoldDirective( + currentWindowAdaptiveInfo() + ).copy( + maxHorizontalPartitions = if (isSplitPane) 2 else 1, + horizontalPartitionSpacerSize = horizontalPartitionSpacerSize, + defaultPanePreferredWidth = defaultPanePreferredWidth + ) + ) +}