perf: animate swipe-to-reply icon in the layer phase, not composition

The reply icon's fade/scale read dragOffset in composition, and because Box is
an inline composable that read invalidated the whole ChatBubbleLayout on every
drag and settle frame. Gate the icon on a derivedStateOf boolean that flips only
at the 0<->non-0 edges (icon added/removed twice per gesture) and move the
progress math into the icon's graphicsLayer block so it's read in the layer
phase. The bubble translation was already deferred; now the entire swipe and
settle animate with zero recomposition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2
This commit is contained in:
Claude
2026-07-29 14:33:47 +00:00
parent 3ab87dd48e
commit ce25273898
@@ -50,6 +50,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@@ -192,13 +193,18 @@ fun ChatBubbleLayout(
},
) {
if (onSwipeReply != null) {
val progress = (abs(dragOffset) / swipeThresholdPx).coerceIn(0f, 1f)
if (progress > 0f) {
// Gate composition on a boolean that flips only at the 0<->non-0 edges, so
// the icon is added/removed twice per gesture instead of recomposing every
// frame; the fade/scale reads dragOffset inside graphicsLayer (layer phase),
// so the whole swipe animates without recomposing ChatBubbleLayout.
val swiping by remember { derivedStateOf { dragOffset != 0f } }
if (swiping) {
Box(
modifier =
Modifier
.align(if (isLoggedInUser) Alignment.CenterEnd else Alignment.CenterStart)
.graphicsLayer {
val progress = (abs(dragOffset) / swipeThresholdPx).coerceIn(0f, 1f)
alpha = progress
scaleX = 0.6f + 0.4f * progress
scaleY = 0.6f + 0.4f * progress