diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt index 4ad7df9c78..5a62e0538e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.layer.GraphicsLayer import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.rememberGraphicsLayer @@ -47,7 +48,6 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationFragment import org.thoughtcrime.securesms.serialization.JsonSerializableNavType import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults import org.thoughtcrime.securesms.window.AppScaffoldAnimationState -import org.thoughtcrime.securesms.window.AppScaffoldNavigator import org.thoughtcrime.securesms.window.WindowSizeClass import kotlin.reflect.typeOf import kotlin.time.Duration.Companion.milliseconds @@ -90,7 +90,11 @@ fun NavGraphBuilder.chatNavGraphBuilder( bitmap = bitmap, contentDescription = null, modifier = Modifier - .then(fakeChatListAnimationState.toModifier()) + .graphicsLayer { + with(fakeChatListAnimationState) { + applyChildValues() + } + } .fillMaxSize() ) } @@ -100,7 +104,11 @@ fun NavGraphBuilder.chatNavGraphBuilder( fragmentState = fragmentState, arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." }, modifier = Modifier - .then(chatAnimationState.toModifier()) + .graphicsLayer { + with(chatAnimationState) { + applyChildValues() + } + } .background(MaterialTheme.colorScheme.background) .fillMaxSize() ) { fragment -> @@ -129,34 +137,37 @@ fun NavGraphBuilder.chatNavGraphBuilder( @Composable private fun Transition.fakeChatListAnimationState(): AppScaffoldAnimationState { - val alpha by animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f } - val offset by animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp } + val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f } + val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp } - return AppScaffoldAnimationState( - navigationState = AppScaffoldNavigator.NavigationState.ENTER, - offset = offset, - alpha = alpha - ) + return remember { + AppScaffoldAnimationState( + offset = offset, + alpha = alpha + ) + } } @Composable private fun Transition.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState { - val alpha by animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f } + val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 1f else 0f } return if (!hasFake) { - AppScaffoldAnimationState( - navigationState = AppScaffoldNavigator.NavigationState.ENTER, - offset = 0.dp, - alpha = alpha - ) + remember { + AppScaffoldAnimationState( + offset = mutableStateOf(0.dp), + alpha = alpha + ) + } } else { - val offset by animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp } + val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0.dp else 48.dp } - AppScaffoldAnimationState( - navigationState = AppScaffoldNavigator.NavigationState.ENTER, - offset = offset, - alpha = alpha - ) + remember { + AppScaffoldAnimationState( + offset = offset, + alpha = alpha + ) + } } } 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 1932ef9103..bd7ece0c08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt @@ -38,7 +38,9 @@ import androidx.compose.runtime.remember 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.LocalConfiguration import androidx.compose.ui.platform.LocalInspectionMode @@ -277,11 +279,19 @@ fun AppScaffold( exitTransition = ExitTransition.None, modifier = Modifier .zIndex(0f) - .then(animationState.parentModifier) + .drawWithContent { + with(animationState) { + applyParentValues() + } + } ) { Box( modifier = Modifier - .then(animationState.toModifier()) + .graphicsLayer { + with(animationState) { + applyChildValues() + } + } .clipToBounds() .layout { measurable, constraints -> val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) @@ -320,11 +330,19 @@ fun AppScaffold( exitTransition = ExitTransition.None, modifier = Modifier .zIndex(1f) - .then(animationState.parentModifier) + .drawWithContent { + with(animationState) { + applyParentValues() + } + } ) { Box( modifier = Modifier - .then(animationState.toModifier()) + .graphicsLayer { + with(animationState) { + applyChildValues() + } + } .clipToBounds() .layout { measurable, constraints -> val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimationState.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimationState.kt index 4e2d11f0e8..39b31d3cd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimationState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimationState.kt @@ -7,17 +7,19 @@ package org.thoughtcrime.securesms.window import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween -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.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 /** @@ -30,28 +32,38 @@ object AppScaffoldAnimationDefaults { fun tween() = tween(durationMillis = 200, easing = TweenEasing) } -/** - * 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 + private val alpha: State = mutableStateOf(1f), + private val scale: State = mutableStateOf(1f), + val scaleMinimum: Float = 0f, + private val offset: State = mutableStateOf(0.dp), + private val corners: State = mutableStateOf(0.dp), + val cornersMaximum: Dp = 1000.dp, + private val parentOverlayAlpha: State = mutableStateOf(0f) ) { - fun toModifier(): Modifier { - return Modifier - .alpha(alpha) - .scale(scale) - .offset(offset) - .clip(clipShape) + + 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) } } @@ -67,13 +79,12 @@ class AppScaffoldAnimationStateFactory( val Default = AppScaffoldAnimationStateFactory() private val EMPTY_STATE = AppScaffoldAnimationState( - navigationState = AppScaffoldNavigator.NavigationState.ENTER, - alpha = 1f + alpha = mutableStateOf(1f) ) } - private var latestListSeekState: AppScaffoldAnimationState = AppScaffoldAnimationState(AppScaffoldNavigator.NavigationState.SEEK) - private var latestDetailSeekState: AppScaffoldAnimationState = AppScaffoldAnimationState(AppScaffoldNavigator.NavigationState.SEEK) + private var latestListSeekState: AppScaffoldAnimationState = EMPTY_STATE + private var latestDetailSeekState: AppScaffoldAnimationState = EMPTY_STATE @Composable fun ThreePaneScaffoldPaneScope.getListAnimationState(navigationState: AppScaffoldNavigator.NavigationState): AppScaffoldAnimationState { @@ -87,6 +98,7 @@ class AppScaffoldAnimationStateFactory( AppScaffoldNavigator.NavigationState.SEEK -> defaultListSeekAnimationState().also { latestListSeekState = it } + AppScaffoldNavigator.NavigationState.RELEASE -> defaultListReleaseAnimationState(latestListSeekState) } } @@ -103,6 +115,7 @@ class AppScaffoldAnimationStateFactory( 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 5c358c46e7..a614bfd8a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimators.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldAnimators.kt @@ -11,18 +11,15 @@ 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.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.runtime.mutableStateOf +import androidx.compose.runtime.remember 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 @@ -79,7 +76,7 @@ fun ThreePaneScaffoldPaneScope.animateFloat( @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnimationState { - val offset by animateDp( + val offset = animateDp( targetWhenHiding = { -AppScaffoldAnimationDefaults.InitAnimationOffset }, @@ -88,21 +85,22 @@ fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnima } ) - val alpha by animateFloat { + val alpha = animateFloat { 1f } - return AppScaffoldAnimationState( - AppScaffoldNavigator.NavigationState.ENTER, - alpha = alpha, - offset = offset - ) + return remember { + AppScaffoldAnimationState( + alpha = alpha, + offset = offset + ) + } } @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnimationState { - val scale by animateFloat( + val scale = animateFloat( transitionSpec = { appScaffoldSeekSpring() }, @@ -110,7 +108,7 @@ fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnima targetWhenHiding = { 1f } ) - val offset by animateDp( + val offset = animateDp( transitionSpec = { appScaffoldSeekSpring() }, @@ -118,52 +116,51 @@ fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnima 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)) - } - ) + 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 scale by animateFloat( - targetWhenHiding = { from.scale }, + val initialScale = remember { from.contentScale } + val initialOffset = remember { from.contentOffset } + + val scale = animateFloat( + targetWhenHiding = { initialScale }, targetWhenShowing = { 1f } ) - val offset by animateDp( - targetWhenHiding = { from.offset }, + val offset = animateDp( + targetWhenHiding = { initialOffset }, targetWhenShowing = { 0.dp } ) - val alpha by animateFloat( + val alpha = 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)) - } - ) + return remember { + AppScaffoldAnimationState( + scale = scale, + scaleMinimum = from.scaleMinimum, + offset = offset, + parentOverlayAlpha = alpha + ) + } } @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAnimationState { - val offset by animateDp( + val offset = animateDp( targetWhenHiding = { AppScaffoldAnimationDefaults.InitAnimationOffset }, @@ -172,21 +169,22 @@ fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAni } ) - val alpha by animateFloat { + val alpha = animateFloat { 1f } - return AppScaffoldAnimationState( - navigationState = AppScaffoldNavigator.NavigationState.ENTER, - alpha = alpha, - offset = offset - ) + return remember { + AppScaffoldAnimationState( + alpha = alpha, + offset = offset + ) + } } @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAnimationState { - val scale by animateFloat( + val scale = animateFloat( transitionSpec = { appScaffoldSeekSpring() }, @@ -194,7 +192,7 @@ fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAni targetWhenHiding = { 0.5f } ) - val offset by animateDp( + val offset = animateDp( transitionSpec = { appScaffoldSeekSpring() }, @@ -202,30 +200,44 @@ fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAni targetWhenHiding = { 88.dp } ) - val roundedCorners by animateDp( + val roundedCorners = animateDp( transitionSpec = { appScaffoldSeekSpring() } ) { 1000.dp } - return AppScaffoldAnimationState( - navigationState = AppScaffoldNavigator.NavigationState.SEEK, - scale = scale.coerceAtLeast(0.9f), - offset = offset, - clipShape = RoundedCornerShape(roundedCorners.coerceAtMost(42.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 alpha by animateFloat { 1f } + val scale = remember { from.contentScale } + val offset = remember { from.contentOffset } + val corners = remember { from.contentCorners } - return AppScaffoldAnimationState( - navigationState = AppScaffoldNavigator.NavigationState.RELEASE, - scale = from.scale, - offset = from.offset, - clipShape = from.clipShape, - alpha = alpha - ) + 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 + ) + } }