From f41d02c2f503cefc1327be86c10ba460c97956ef Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 25 Jun 2026 07:58:47 -0400 Subject: [PATCH] style(chat): continue to tweak animations around chat message insert Signed-off-by: Brandon McAnsh --- .../internal/screens/ChatAnimations.kt | 33 +++++++++++++ .../internal/screens/MessengerScreen.kt | 14 ++---- .../screens/components/MessageList.kt | 48 ++++++++++--------- .../screens/components/ReceiptLabel.kt | 9 ++-- 4 files changed, 66 insertions(+), 38 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt new file mode 100644 index 000000000..382469665 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/ChatAnimations.kt @@ -0,0 +1,33 @@ +package com.flipcash.app.messenger.internal.screens + +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.ui.unit.IntSize + +// All chat animation spring specs in one place. +internal object ChatAnimations { + // Message bubble insertion — scale from 0.95 + opacity. + // Matches iOS insertionSpring: .spring(duration: 0.23, bounce: 0.27). + val insertion: SpringSpec = spring(dampingRatio = 0.73f, stiffness = 746f) + + // Typing indicator entry/exit — scale from 0.95 + opacity. + val typingIndicator: SpringSpec = spring(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh) + + // Action bar <-> composer swap — scale from 0.95 + opacity. + val composerSwap: SpringSpec = spring(dampingRatio = 0.69f, stiffness = Spring.StiffnessHigh) + + // Delivered label appearance — scale from 0.95 + opacity. + // Matches iOS deliveredSpring: .spring(duration: 0.4, bounce: 0.12). + val delivered: SpringSpec = spring(dampingRatio = 0.88f, stiffness = 250f) + + // Delivered -> Read label swap — scale + opacity. + val readSwap: SpringSpec = spring(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh) + + // Receipt label exit when a new message is sent — fade out + collapse. + private val deliveredIntSize: SpringSpec = spring(dampingRatio = 0.88f, stiffness = 250f) + val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 3ded6ef97..aa300dc33 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -1,8 +1,6 @@ package com.flipcash.app.messenger.internal.screens import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -49,7 +47,6 @@ import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.app.messenger.internal.screens.components.MessageList import com.flipcash.features.messenger.R -import com.flipcash.shared.chat.ui.SeparatorConfig import com.getcode.navigation.core.CodeNavigator import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme @@ -191,11 +188,10 @@ private fun UserControlBottomBar( modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), targetState = state.typists.isNotEmpty(), transitionSpec = { - val insertSpec = spring(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh) - (scaleIn(insertSpec, initialScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) - + fadeIn(insertSpec)) togetherWith - (scaleOut(insertSpec, targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) - + fadeOut(insertSpec)) + (scaleIn(ChatAnimations.typingIndicator, initialScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + + fadeIn(ChatAnimations.typingIndicator)) togetherWith + (scaleOut(ChatAnimations.typingIndicator, targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f)) + + fadeOut(ChatAnimations.typingIndicator)) } ) { show -> if (show) { @@ -228,7 +224,7 @@ private fun UserControlBottomBar( transitionSpec = { // Action bar <-> composer swap // Buttons: scale from 0.95 + opacity; Composer: opacity only - val swapSpec = spring(dampingRatio = 0.69f, stiffness = Spring.StiffnessHigh) + val swapSpec = ChatAnimations.composerSwap when (targetState) { ChatViewModel.UserState.Typing -> // Composer fades in; buttons scale+fade out diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 8cb065016..5a419e901 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -1,7 +1,9 @@ package com.flipcash.app.messenger.internal.screens.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring +import com.flipcash.app.messenger.internal.screens.ChatAnimations import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -139,22 +141,19 @@ internal fun MessageList( val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false // Message insertion animation — scale from 0.95 + opacity with edge anchor. - // Tuned to match observed iOS timing (~500ms gradual fade-in). // Only animate genuinely new messages (index 0 after initial load). val shouldAnimate = index == 0 && hasLoaded && item.itemKey !in animatedKeys if (shouldAnimate) animatedKeys.add(item.itemKey) var appeared by remember(item.itemKey) { mutableStateOf(!shouldAnimate) } LaunchedEffect(Unit) { if (!appeared) appeared = true } - val insertionAlphaSpec = spring(dampingRatio = 0.86f, stiffness = 80f) - val insertionScaleSpec = spring(dampingRatio = 0.73f, stiffness = 300f) val insertionAlpha by animateFloatAsState( targetValue = if (appeared) 1f else 0f, - animationSpec = insertionAlphaSpec, + animationSpec = ChatAnimations.insertion, label = "insertAlpha", ) val insertionScale by animateFloatAsState( targetValue = if (appeared) 1f else 0.95f, - animationSpec = insertionScaleSpec, + animationSpec = ChatAnimations.insertion, label = "insertScale", ) @@ -206,12 +205,18 @@ internal fun MessageList( } val showReceipt = shouldShowReceiptLabel(index, item, messages, otherReadPointer) - if (showReceipt && effectiveStatus != null) { - ReceiptLabel( - status = effectiveStatus, - readPointer = otherReadPointer, - animateEntrance = wasSending, - ) + AnimatedVisibility( + visible = showReceipt && effectiveStatus != null, + enter = EnterTransition.None, + exit = ChatAnimations.receiptExit, + ) { + if (effectiveStatus != null) { + ReceiptLabel( + status = effectiveStatus, + readPointer = otherReadPointer, + animateEntrance = wasSending, + ) + } } } } @@ -286,20 +291,17 @@ internal fun MessageList( } } - // opts out of the list maintaining - // scroll position when adding elements before the first item - // we are checking first visible item index to ensure - // the list doesn't shift when viewing scroll back + // Opt out of the list maintaining scroll position when adding + // elements before the first item. Only needed during initial load + // (to prevent starting at the ContactInfoContainer) and when + // scrolled back (to prevent shifting when pagination prepends). + // When at index 0, we do NOT call requestScrollToItem — doing so + // forces an instant reposition that causes a single-frame jitter + // when new messages are inserted. Instead, animateScrollToItem in + // the LaunchedEffect above handles smooth scrolling to new items. Snapshot.withoutReadObservation { if (!hasLoaded) { - // During initial load, always pin to index 0 (newest message) - // to prevent the list from starting at the ContactInfoContainer listState.requestScrollToItem(0, 0) - } else if (listState.firstVisibleItemIndex == 0) { - listState.requestScrollToItem( - index = listState.firstVisibleItemIndex, - scrollOffset = listState.firstVisibleItemScrollOffset - ) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt index b1b18de99..5e1ec78b4 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt @@ -3,9 +3,8 @@ package com.flipcash.app.messenger.internal.screens.components import android.text.format.DateFormat import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.Spring import androidx.compose.animation.core.snap -import androidx.compose.animation.core.spring +import com.flipcash.app.messenger.internal.screens.ChatAnimations import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -25,7 +24,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -74,8 +72,7 @@ internal fun ReceiptLabel( } } - // Matches iOS deliveredSpring: .spring(duration: 0.4, bounce: 0.12) - val deliveredSpec = spring(dampingRatio = 0.88f, stiffness = 250f) + val deliveredSpec = ChatAnimations.delivered Box( modifier = modifier.padding( @@ -94,7 +91,7 @@ internal fun ReceiptLabel( exit = shrinkVertically(snap()) + fadeOut(snap()), ) { // Delivered -> Read directional swap with scale - val readSwapSpec = spring(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh) + val readSwapSpec = ChatAnimations.readSwap AnimatedContent( targetState = status, transitionSpec = {