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.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,35 +137,38 @@ fun NavGraphBuilder.chatNavGraphBuilder(
@Composable
private fun Transition<Boolean>.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,
return remember {
AppScaffoldAnimationState(
offset = offset,
alpha = alpha
)
}
}
@Composable
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) {
remember {
AppScaffoldAnimationState(
navigationState = AppScaffoldNavigator.NavigationState.ENTER,
offset = 0.dp,
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 }
remember {
AppScaffoldAnimationState(
navigationState = AppScaffoldNavigator.NavigationState.ENTER,
offset = offset,
alpha = alpha
)
}
}
}
/**

View File

@@ -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)

View File

@@ -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 <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(
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<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)
) {
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)
}
}

View File

@@ -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,
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,
return remember {
AppScaffoldAnimationState(
offset = offset,
scale = scale.coerceAtLeast(0.9f),
parentModifier = Modifier.drawWithContent {
drawContent()
drawRect(Color(0f, 0f, 0f, 0.2f))
}
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,
return remember {
AppScaffoldAnimationState(
scale = scale,
scaleMinimum = from.scaleMinimum,
offset = offset,
parentModifier = Modifier.drawWithContent {
drawContent()
drawRect(Color(0f, 0f, 0f, alpha))
}
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,
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),
return remember {
AppScaffoldAnimationState(
scale = scale,
scaleMinimum = 0.9f,
offset = offset,
clipShape = RoundedCornerShape(roundedCorners.coerceAtMost(42.dp))
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,
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
)
}
}