AppScaffold Animation Performance impromements.

This commit is contained in:
Alex Hart
2025-10-28 14:51:48 -03:00
committed by jeffrey-signal
parent 443463aca8
commit ae8b8bbe7c
4 changed files with 173 additions and 119 deletions

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.ImageBitmap 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.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer 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.serialization.JsonSerializableNavType
import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults import org.thoughtcrime.securesms.window.AppScaffoldAnimationDefaults
import org.thoughtcrime.securesms.window.AppScaffoldAnimationState import org.thoughtcrime.securesms.window.AppScaffoldAnimationState
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.WindowSizeClass import org.thoughtcrime.securesms.window.WindowSizeClass
import kotlin.reflect.typeOf import kotlin.reflect.typeOf
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -90,7 +90,11 @@ fun NavGraphBuilder.chatNavGraphBuilder(
bitmap = bitmap, bitmap = bitmap,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.then(fakeChatListAnimationState.toModifier()) .graphicsLayer {
with(fakeChatListAnimationState) {
applyChildValues()
}
}
.fillMaxSize() .fillMaxSize()
) )
} }
@@ -100,7 +104,11 @@ fun NavGraphBuilder.chatNavGraphBuilder(
fragmentState = fragmentState, fragmentState = fragmentState,
arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." }, arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." },
modifier = Modifier modifier = Modifier
.then(chatAnimationState.toModifier()) .graphicsLayer {
with(chatAnimationState) {
applyChildValues()
}
}
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.fillMaxSize() .fillMaxSize()
) { fragment -> ) { fragment ->
@@ -129,34 +137,37 @@ fun NavGraphBuilder.chatNavGraphBuilder(
@Composable @Composable
private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimationState { private fun Transition<Boolean>.fakeChatListAnimationState(): AppScaffoldAnimationState {
val alpha by animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f } val alpha = animateFloat(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) 0f else 1f }
val offset by animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp } val offset = animateDp(transitionSpec = { AppScaffoldAnimationDefaults.tween() }) { if (it) (-48).dp else 0.dp }
return AppScaffoldAnimationState( return remember {
navigationState = AppScaffoldNavigator.NavigationState.ENTER, AppScaffoldAnimationState(
offset = offset, offset = offset,
alpha = alpha alpha = alpha
) )
}
} }
@Composable @Composable
private fun Transition<Boolean>.chatAnimationState(hasFake: Boolean): AppScaffoldAnimationState { private fun Transition<Boolean>.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) { return if (!hasFake) {
AppScaffoldAnimationState( remember {
navigationState = AppScaffoldNavigator.NavigationState.ENTER, AppScaffoldAnimationState(
offset = 0.dp, offset = mutableStateOf(0.dp),
alpha = alpha alpha = alpha
) )
}
} else { } 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( remember {
navigationState = AppScaffoldNavigator.NavigationState.ENTER, AppScaffoldAnimationState(
offset = offset, offset = offset,
alpha = alpha alpha = alpha
) )
}
} }
} }

View File

@@ -38,7 +38,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
@@ -277,11 +279,19 @@ fun AppScaffold(
exitTransition = ExitTransition.None, exitTransition = ExitTransition.None,
modifier = Modifier modifier = Modifier
.zIndex(0f) .zIndex(0f)
.then(animationState.parentModifier) .drawWithContent {
with(animationState) {
applyParentValues()
}
}
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.then(animationState.toModifier()) .graphicsLayer {
with(animationState) {
applyChildValues()
}
}
.clipToBounds() .clipToBounds()
.layout { measurable, constraints -> .layout { measurable, constraints ->
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)
@@ -320,11 +330,19 @@ fun AppScaffold(
exitTransition = ExitTransition.None, exitTransition = ExitTransition.None,
modifier = Modifier modifier = Modifier
.zIndex(1f) .zIndex(1f)
.then(animationState.parentModifier) .drawWithContent {
with(animationState) {
applyParentValues()
}
}
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.then(animationState.toModifier()) .graphicsLayer {
with(animationState) {
applyChildValues()
}
}
.clipToBounds() .clipToBounds()
.layout { measurable, constraints -> .layout { measurable, constraints ->
val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) val width = max(minPaneWidth.roundToPx(), constraints.maxWidth)

View File

@@ -7,17 +7,19 @@ package org.thoughtcrime.securesms.window
import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.runtime.State
import androidx.compose.ui.draw.alpha import androidx.compose.runtime.derivedStateOf
import androidx.compose.ui.draw.clip import androidx.compose.runtime.getValue
import androidx.compose.ui.draw.scale import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.Shape 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.Dp
import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
@@ -30,28 +32,38 @@ object AppScaffoldAnimationDefaults {
fun <T> tween() = tween<T>(durationMillis = 200, easing = TweenEasing) fun <T> tween() = tween<T>(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( data class AppScaffoldAnimationState(
val navigationState: AppScaffoldNavigator.NavigationState, private val alpha: State<Float> = mutableStateOf(1f),
val alpha: Float = 1f, private val scale: State<Float> = mutableStateOf(1f),
val scale: Float = 1f, val scaleMinimum: Float = 0f,
val offset: Dp = 0.dp, private val offset: State<Dp> = mutableStateOf(0.dp),
val clipShape: Shape = RoundedCornerShape(0.dp), private val corners: State<Dp> = mutableStateOf(0.dp),
val parentModifier: Modifier = Modifier.Companion val cornersMaximum: Dp = 1000.dp,
private val parentOverlayAlpha: State<Float> = mutableStateOf(0f)
) { ) {
fun toModifier(): Modifier {
return Modifier private val unclampedScale by scale
.alpha(alpha) private val unclampedCorners by corners
.scale(scale)
.offset(offset) val contentAlpha by alpha
.clip(clipShape) 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() val Default = AppScaffoldAnimationStateFactory()
private val EMPTY_STATE = AppScaffoldAnimationState( private val EMPTY_STATE = AppScaffoldAnimationState(
navigationState = AppScaffoldNavigator.NavigationState.ENTER, alpha = mutableStateOf(1f)
alpha = 1f
) )
} }
private var latestListSeekState: AppScaffoldAnimationState = AppScaffoldAnimationState(AppScaffoldNavigator.NavigationState.SEEK) private var latestListSeekState: AppScaffoldAnimationState = EMPTY_STATE
private var latestDetailSeekState: AppScaffoldAnimationState = AppScaffoldAnimationState(AppScaffoldNavigator.NavigationState.SEEK) private var latestDetailSeekState: AppScaffoldAnimationState = EMPTY_STATE
@Composable @Composable
fun ThreePaneScaffoldPaneScope.getListAnimationState(navigationState: AppScaffoldNavigator.NavigationState): AppScaffoldAnimationState { fun ThreePaneScaffoldPaneScope.getListAnimationState(navigationState: AppScaffoldNavigator.NavigationState): AppScaffoldAnimationState {
@@ -87,6 +98,7 @@ class AppScaffoldAnimationStateFactory(
AppScaffoldNavigator.NavigationState.SEEK -> defaultListSeekAnimationState().also { AppScaffoldNavigator.NavigationState.SEEK -> defaultListSeekAnimationState().also {
latestListSeekState = it latestListSeekState = it
} }
AppScaffoldNavigator.NavigationState.RELEASE -> defaultListReleaseAnimationState(latestListSeekState) AppScaffoldNavigator.NavigationState.RELEASE -> defaultListReleaseAnimationState(latestListSeekState)
} }
} }
@@ -103,6 +115,7 @@ class AppScaffoldAnimationStateFactory(
AppScaffoldNavigator.NavigationState.SEEK -> defaultDetailSeekAnimationState().also { AppScaffoldNavigator.NavigationState.SEEK -> defaultDetailSeekAnimationState().also {
latestDetailSeekState = it latestDetailSeekState = it
} }
AppScaffoldNavigator.NavigationState.RELEASE -> defaultDetailReleaseAnimationState(latestDetailSeekState) AppScaffoldNavigator.NavigationState.RELEASE -> defaultDetailReleaseAnimationState(latestDetailSeekState)
} }
} }

View File

@@ -11,18 +11,15 @@ import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldPaneScope
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.draw.drawWithContent import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.coerceAtMost
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
private const val SEEK_DAMPING_RATIO = Spring.DampingRatioNoBouncy private const val SEEK_DAMPING_RATIO = Spring.DampingRatioNoBouncy
@@ -79,7 +76,7 @@ fun ThreePaneScaffoldPaneScope.animateFloat(
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnimationState { fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnimationState {
val offset by animateDp( val offset = animateDp(
targetWhenHiding = { targetWhenHiding = {
-AppScaffoldAnimationDefaults.InitAnimationOffset -AppScaffoldAnimationDefaults.InitAnimationOffset
}, },
@@ -88,21 +85,22 @@ fun ThreePaneScaffoldPaneScope.defaultListInitAnimationState(): AppScaffoldAnima
} }
) )
val alpha by animateFloat { val alpha = animateFloat {
1f 1f
} }
return AppScaffoldAnimationState( return remember {
AppScaffoldNavigator.NavigationState.ENTER, AppScaffoldAnimationState(
alpha = alpha, alpha = alpha,
offset = offset offset = offset
) )
}
} }
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnimationState { fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnimationState {
val scale by animateFloat( val scale = animateFloat(
transitionSpec = { transitionSpec = {
appScaffoldSeekSpring() appScaffoldSeekSpring()
}, },
@@ -110,7 +108,7 @@ fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnima
targetWhenHiding = { 1f } targetWhenHiding = { 1f }
) )
val offset by animateDp( val offset = animateDp(
transitionSpec = { transitionSpec = {
appScaffoldSeekSpring() appScaffoldSeekSpring()
}, },
@@ -118,52 +116,51 @@ fun ThreePaneScaffoldPaneScope.defaultListSeekAnimationState(): AppScaffoldAnima
targetWhenShowing = { 0.dp } targetWhenShowing = { 0.dp }
) )
return AppScaffoldAnimationState( return remember {
navigationState = AppScaffoldNavigator.NavigationState.SEEK, AppScaffoldAnimationState(
offset = offset, offset = offset,
scale = scale.coerceAtLeast(0.9f), scale = scale,
parentModifier = Modifier.drawWithContent { scaleMinimum = 0.9f,
drawContent() parentOverlayAlpha = mutableStateOf(0.2f)
)
drawRect(Color(0f, 0f, 0f, 0.2f)) }
}
)
} }
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun ThreePaneScaffoldPaneScope.defaultListReleaseAnimationState(from: AppScaffoldAnimationState): AppScaffoldAnimationState { fun ThreePaneScaffoldPaneScope.defaultListReleaseAnimationState(from: AppScaffoldAnimationState): AppScaffoldAnimationState {
val scale by animateFloat( val initialScale = remember { from.contentScale }
targetWhenHiding = { from.scale }, val initialOffset = remember { from.contentOffset }
val scale = animateFloat(
targetWhenHiding = { initialScale },
targetWhenShowing = { 1f } targetWhenShowing = { 1f }
) )
val offset by animateDp( val offset = animateDp(
targetWhenHiding = { from.offset }, targetWhenHiding = { initialOffset },
targetWhenShowing = { 0.dp } targetWhenShowing = { 0.dp }
) )
val alpha by animateFloat( val alpha = animateFloat(
targetWhenHiding = { 0.2f }, targetWhenHiding = { 0.2f },
targetWhenShowing = { 0f } targetWhenShowing = { 0f }
) )
return AppScaffoldAnimationState( return remember {
navigationState = AppScaffoldNavigator.NavigationState.RELEASE, AppScaffoldAnimationState(
scale = scale, scale = scale,
offset = offset, scaleMinimum = from.scaleMinimum,
parentModifier = Modifier.drawWithContent { offset = offset,
drawContent() parentOverlayAlpha = alpha
)
drawRect(Color(0f, 0f, 0f, alpha)) }
}
)
} }
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAnimationState { fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAnimationState {
val offset by animateDp( val offset = animateDp(
targetWhenHiding = { targetWhenHiding = {
AppScaffoldAnimationDefaults.InitAnimationOffset AppScaffoldAnimationDefaults.InitAnimationOffset
}, },
@@ -172,21 +169,22 @@ fun ThreePaneScaffoldPaneScope.defaultDetailInitAnimationState(): AppScaffoldAni
} }
) )
val alpha by animateFloat { val alpha = animateFloat {
1f 1f
} }
return AppScaffoldAnimationState( return remember {
navigationState = AppScaffoldNavigator.NavigationState.ENTER, AppScaffoldAnimationState(
alpha = alpha, alpha = alpha,
offset = offset offset = offset
) )
}
} }
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAnimationState { fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAnimationState {
val scale by animateFloat( val scale = animateFloat(
transitionSpec = { transitionSpec = {
appScaffoldSeekSpring() appScaffoldSeekSpring()
}, },
@@ -194,7 +192,7 @@ fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAni
targetWhenHiding = { 0.5f } targetWhenHiding = { 0.5f }
) )
val offset by animateDp( val offset = animateDp(
transitionSpec = { transitionSpec = {
appScaffoldSeekSpring() appScaffoldSeekSpring()
}, },
@@ -202,30 +200,44 @@ fun ThreePaneScaffoldPaneScope.defaultDetailSeekAnimationState(): AppScaffoldAni
targetWhenHiding = { 88.dp } targetWhenHiding = { 88.dp }
) )
val roundedCorners by animateDp( val roundedCorners = animateDp(
transitionSpec = { transitionSpec = {
appScaffoldSeekSpring() appScaffoldSeekSpring()
} }
) { 1000.dp } ) { 1000.dp }
return AppScaffoldAnimationState( return remember {
navigationState = AppScaffoldNavigator.NavigationState.SEEK, AppScaffoldAnimationState(
scale = scale.coerceAtLeast(0.9f), scale = scale,
offset = offset, scaleMinimum = 0.9f,
clipShape = RoundedCornerShape(roundedCorners.coerceAtMost(42.dp)) offset = offset,
) corners = roundedCorners,
cornersMaximum = 42.dp
)
}
} }
@OptIn(ExperimentalMaterial3AdaptiveApi::class) @OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable @Composable
fun ThreePaneScaffoldPaneScope.defaultDetailReleaseAnimationState(from: AppScaffoldAnimationState): AppScaffoldAnimationState { 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( val scaleState = remember { mutableStateOf(scale) }
navigationState = AppScaffoldNavigator.NavigationState.RELEASE, val offsetState = remember { mutableStateOf(offset) }
scale = from.scale, val cornersState = remember { mutableStateOf(corners) }
offset = from.offset,
clipShape = from.clipShape, val alpha = animateFloat { 1f }
alpha = alpha
) return remember {
AppScaffoldAnimationState(
scale = scaleState,
scaleMinimum = from.scaleMinimum,
offset = offsetState,
corners = cornersState,
cornersMaximum = from.cornersMaximum,
alpha = alpha
)
}
} }