From ce252738984de2ed7a2c2ad5819e0a77287f71f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 14:33:47 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_016f7dTu8RoJRXjnHM7FKAE2 --- .../loggedIn/chats/feed/layouts/ChatBubbleLayout.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt index 0825e2d5bb..84fdc03ccf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/layouts/ChatBubbleLayout.kt @@ -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