Skip to content

Commit f41d02c

Browse files
committed
style(chat): continue to tweak animations around chat message insert
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent f36c5e0 commit f41d02c

4 files changed

Lines changed: 66 additions & 38 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.flipcash.app.messenger.internal.screens
2+
3+
import androidx.compose.animation.ExitTransition
4+
import androidx.compose.animation.core.Spring
5+
import androidx.compose.animation.core.SpringSpec
6+
import androidx.compose.animation.core.spring
7+
import androidx.compose.animation.fadeOut
8+
import androidx.compose.animation.shrinkVertically
9+
import androidx.compose.ui.unit.IntSize
10+
11+
// All chat animation spring specs in one place.
12+
internal object ChatAnimations {
13+
// Message bubble insertion — scale from 0.95 + opacity.
14+
// Matches iOS insertionSpring: .spring(duration: 0.23, bounce: 0.27).
15+
val insertion: SpringSpec<Float> = spring(dampingRatio = 0.73f, stiffness = 746f)
16+
17+
// Typing indicator entry/exit — scale from 0.95 + opacity.
18+
val typingIndicator: SpringSpec<Float> = spring(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh)
19+
20+
// Action bar <-> composer swap — scale from 0.95 + opacity.
21+
val composerSwap: SpringSpec<Float> = spring(dampingRatio = 0.69f, stiffness = Spring.StiffnessHigh)
22+
23+
// Delivered label appearance — scale from 0.95 + opacity.
24+
// Matches iOS deliveredSpring: .spring(duration: 0.4, bounce: 0.12).
25+
val delivered: SpringSpec<Float> = spring(dampingRatio = 0.88f, stiffness = 250f)
26+
27+
// Delivered -> Read label swap — scale + opacity.
28+
val readSwap: SpringSpec<Float> = spring(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh)
29+
30+
// Receipt label exit when a new message is sent — fade out + collapse.
31+
private val deliveredIntSize: SpringSpec<IntSize> = spring(dampingRatio = 0.88f, stiffness = 250f)
32+
val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered)
33+
}

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package com.flipcash.app.messenger.internal.screens
22

33
import androidx.compose.animation.AnimatedContent
4-
import androidx.compose.animation.core.Spring
5-
import androidx.compose.animation.core.spring
64
import androidx.compose.animation.fadeIn
75
import androidx.compose.animation.fadeOut
86
import androidx.compose.animation.scaleIn
@@ -49,7 +47,6 @@ import com.flipcash.app.core.contacts.DeviceContact
4947
import com.flipcash.app.messenger.internal.ChatViewModel
5048
import com.flipcash.app.messenger.internal.screens.components.MessageList
5149
import com.flipcash.features.messenger.R
52-
import com.flipcash.shared.chat.ui.SeparatorConfig
5350
import com.getcode.navigation.core.CodeNavigator
5451
import com.getcode.navigation.core.LocalCodeNavigator
5552
import com.getcode.theme.CodeTheme
@@ -191,11 +188,10 @@ private fun UserControlBottomBar(
191188
modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset),
192189
targetState = state.typists.isNotEmpty(),
193190
transitionSpec = {
194-
val insertSpec = spring<Float>(dampingRatio = 0.73f, stiffness = Spring.StiffnessHigh)
195-
(scaleIn(insertSpec, initialScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f))
196-
+ fadeIn(insertSpec)) togetherWith
197-
(scaleOut(insertSpec, targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f))
198-
+ fadeOut(insertSpec))
191+
(scaleIn(ChatAnimations.typingIndicator, initialScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f))
192+
+ fadeIn(ChatAnimations.typingIndicator)) togetherWith
193+
(scaleOut(ChatAnimations.typingIndicator, targetScale = 0.95f, transformOrigin = TransformOrigin(0f, 0.5f))
194+
+ fadeOut(ChatAnimations.typingIndicator))
199195
}
200196
) { show ->
201197
if (show) {
@@ -228,7 +224,7 @@ private fun UserControlBottomBar(
228224
transitionSpec = {
229225
// Action bar <-> composer swap
230226
// Buttons: scale from 0.95 + opacity; Composer: opacity only
231-
val swapSpec = spring<Float>(dampingRatio = 0.69f, stiffness = Spring.StiffnessHigh)
227+
val swapSpec = ChatAnimations.composerSwap
232228
when (targetState) {
233229
ChatViewModel.UserState.Typing ->
234230
// Composer fades in; buttons scale+fade out

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.flipcash.app.messenger.internal.screens.components
22

3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.EnterTransition
35
import androidx.compose.animation.core.animateFloatAsState
4-
import androidx.compose.animation.core.spring
6+
import com.flipcash.app.messenger.internal.screens.ChatAnimations
57
import androidx.compose.foundation.gestures.detectTapGestures
68
import androidx.compose.foundation.layout.Arrangement
79
import androidx.compose.foundation.layout.Box
@@ -139,22 +141,19 @@ internal fun MessageList(
139141
val isOutgoing = (item as? ChatListItem.ContentBubble)?.isFromSelf ?: false
140142

141143
// Message insertion animation — scale from 0.95 + opacity with edge anchor.
142-
// Tuned to match observed iOS timing (~500ms gradual fade-in).
143144
// Only animate genuinely new messages (index 0 after initial load).
144145
val shouldAnimate = index == 0 && hasLoaded && item.itemKey !in animatedKeys
145146
if (shouldAnimate) animatedKeys.add(item.itemKey)
146147
var appeared by remember(item.itemKey) { mutableStateOf(!shouldAnimate) }
147148
LaunchedEffect(Unit) { if (!appeared) appeared = true }
148-
val insertionAlphaSpec = spring<Float>(dampingRatio = 0.86f, stiffness = 80f)
149-
val insertionScaleSpec = spring<Float>(dampingRatio = 0.73f, stiffness = 300f)
150149
val insertionAlpha by animateFloatAsState(
151150
targetValue = if (appeared) 1f else 0f,
152-
animationSpec = insertionAlphaSpec,
151+
animationSpec = ChatAnimations.insertion,
153152
label = "insertAlpha",
154153
)
155154
val insertionScale by animateFloatAsState(
156155
targetValue = if (appeared) 1f else 0.95f,
157-
animationSpec = insertionScaleSpec,
156+
animationSpec = ChatAnimations.insertion,
158157
label = "insertScale",
159158
)
160159

@@ -206,12 +205,18 @@ internal fun MessageList(
206205
}
207206
val showReceipt =
208207
shouldShowReceiptLabel(index, item, messages, otherReadPointer)
209-
if (showReceipt && effectiveStatus != null) {
210-
ReceiptLabel(
211-
status = effectiveStatus,
212-
readPointer = otherReadPointer,
213-
animateEntrance = wasSending,
214-
)
208+
AnimatedVisibility(
209+
visible = showReceipt && effectiveStatus != null,
210+
enter = EnterTransition.None,
211+
exit = ChatAnimations.receiptExit,
212+
) {
213+
if (effectiveStatus != null) {
214+
ReceiptLabel(
215+
status = effectiveStatus,
216+
readPointer = otherReadPointer,
217+
animateEntrance = wasSending,
218+
)
219+
}
215220
}
216221
}
217222
}
@@ -286,20 +291,17 @@ internal fun MessageList(
286291
}
287292
}
288293

289-
// opts out of the list maintaining
290-
// scroll position when adding elements before the first item
291-
// we are checking first visible item index to ensure
292-
// the list doesn't shift when viewing scroll back
294+
// Opt out of the list maintaining scroll position when adding
295+
// elements before the first item. Only needed during initial load
296+
// (to prevent starting at the ContactInfoContainer) and when
297+
// scrolled back (to prevent shifting when pagination prepends).
298+
// When at index 0, we do NOT call requestScrollToItem — doing so
299+
// forces an instant reposition that causes a single-frame jitter
300+
// when new messages are inserted. Instead, animateScrollToItem in
301+
// the LaunchedEffect above handles smooth scrolling to new items.
293302
Snapshot.withoutReadObservation {
294303
if (!hasLoaded) {
295-
// During initial load, always pin to index 0 (newest message)
296-
// to prevent the list from starting at the ContactInfoContainer
297304
listState.requestScrollToItem(0, 0)
298-
} else if (listState.firstVisibleItemIndex == 0) {
299-
listState.requestScrollToItem(
300-
index = listState.firstVisibleItemIndex,
301-
scrollOffset = listState.firstVisibleItemScrollOffset
302-
)
303305
}
304306
}
305307
}

apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ReceiptLabel.kt

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ package com.flipcash.app.messenger.internal.screens.components
33
import android.text.format.DateFormat
44
import androidx.compose.animation.AnimatedContent
55
import androidx.compose.animation.AnimatedVisibility
6-
import androidx.compose.animation.core.Spring
76
import androidx.compose.animation.core.snap
8-
import androidx.compose.animation.core.spring
7+
import com.flipcash.app.messenger.internal.screens.ChatAnimations
98
import androidx.compose.animation.expandVertically
109
import androidx.compose.animation.fadeIn
1110
import androidx.compose.animation.fadeOut
@@ -25,7 +24,6 @@ import androidx.compose.runtime.getValue
2524
import androidx.compose.runtime.mutableStateOf
2625
import androidx.compose.runtime.remember
2726
import androidx.compose.runtime.setValue
28-
import androidx.compose.ui.Alignment
2927
import androidx.compose.ui.Modifier
3028
import androidx.compose.ui.platform.LocalContext
3129
import androidx.compose.ui.res.stringResource
@@ -74,8 +72,7 @@ internal fun ReceiptLabel(
7472
}
7573
}
7674

77-
// Matches iOS deliveredSpring: .spring(duration: 0.4, bounce: 0.12)
78-
val deliveredSpec = spring<Float>(dampingRatio = 0.88f, stiffness = 250f)
75+
val deliveredSpec = ChatAnimations.delivered
7976

8077
Box(
8178
modifier = modifier.padding(
@@ -94,7 +91,7 @@ internal fun ReceiptLabel(
9491
exit = shrinkVertically(snap()) + fadeOut(snap()),
9592
) {
9693
// Delivered -> Read directional swap with scale
97-
val readSwapSpec = spring<Float>(dampingRatio = 0.74f, stiffness = Spring.StiffnessHigh)
94+
val readSwapSpec = ChatAnimations.readSwap
9895
AnimatedContent(
9996
targetState = status,
10097
transitionSpec = {

0 commit comments

Comments
 (0)