Skip to content

Commit d2fe576

Browse files
authored
feat(chat): float the message bar over a fade and retune chat motion (#1617)
* feat(chat): float the message bar over a fade and retune chat motion Ports code-ios-app#907 and the tuning values from code-ios-app#908. The message bar drops its own background and sits on a fade that reaches 0.9 opacity. With the keyboard down it narrows to a grid.x6 side margin and sinks grid.x2 into the navigation bar area; both follow the keyboard's open fraction, so the bar resizes with the keyboard instead of after it. The transcript moves grid.x2 closer to the bar while the keyboard is up, on the same fraction. New messages use iOS's retuned insertion spring (0.27s, bounce 0.15), start at 0.9 scale from the sender's bottom corner, and rise 35% of their height plus the gap below them. The receipt line fades out in 80ms while its row closes on a no-bounce 0.35s spring, which the reaction reflow now shares. Bubble fills go to 0.10 (outgoing) and 0.05 (incoming) white, and the swipe-to-reply arrow starts at 0.35. The $ button stays "$" whenever a message field sits next to it, so it no longer flashes "Send $" in contact DMs as text comes and goes. * feat(chat): push the transcript up when a message arrives at the bottom A new message at index 0 used to land by requestScrollToItem for our own messages, a one-frame jump, and by animateScrollToItem for incoming ones, on its own curve. When the transcript is resting on the newest message, it now scrolls by the new row's measured height on insertionPush, the insertion stiffness without the bounce. The older rows ride up while the new row rises into its slot, as on iOS. animateItem can't do this: the move is a scroll, and a jump in scroll position resets placement animations. The scroll ends with a snap to index 0 if the row above resized during it. * fix(chat): wait for the new row's layout before pushing the transcript The newest-message flow fires when the paged snapshot changes, before the list lays it out, so the scroll position still described the old list: firstVisibleItemIndex read 0 rather than 1, the push was skipped, and our own messages fell through to requestScrollToItem's one-frame jump. Wait for the next layout pass, with a 250ms guard, before reading it. * feat(chat): hand the receipt to a new send in one step The previous message's receipt dropped when a send was confirmed and the new one's line appeared 700ms later, so the line hopped. The newest send now holds the receipt on the row above until it is confirmed and 700ms have passed, then the line moves down as the old one leaves, matching iOS #908. A failed send still shows its own line straight away. * feat(chat): port #908's typing linger, Read/Delivered rule and receipt grouping - Typing dots stay 800ms after STOPPED, and the typist's own message clears them straight away. - A row carries a status line if it is the newest confirmed send or the newest of mine they have read, so Read and Delivered can show together. - A bubble carrying a line ends its run: round corners and the normal gap until the line moves on. * fix(chat): move the receipt line without a jump At the handoff the new line drew at full height for a frame before its own expand started, and it grew on a different spring than the old line shrank on, while the old row's gap snapped from x2 to x1. The transcript jumped about 20px and snapped back. The row's AnimatedVisibility now owns the entrance on the reflow spring the exit uses, so one row grows as the other shrinks, and the row gap animates on the same spring. ReceiptLabel loses its own entrance.
1 parent cf124aa commit d2fe576

14 files changed

Lines changed: 518 additions & 213 deletions

File tree

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

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

33
import androidx.compose.animation.AnimatedContent
4-
import androidx.compose.animation.SizeTransform
54
import androidx.compose.animation.AnimatedVisibility
65
import androidx.compose.animation.ContentTransform
76
import androidx.compose.animation.EnterTransition
87
import androidx.compose.animation.ExitTransition
8+
import androidx.compose.animation.SizeTransform
9+
import androidx.compose.animation.core.CubicBezierEasing
910
import androidx.compose.animation.fadeIn
1011
import androidx.compose.animation.fadeOut
1112
import androidx.compose.animation.scaleIn
@@ -17,10 +18,17 @@ import androidx.compose.foundation.clickable
1718
import androidx.compose.foundation.layout.Arrangement
1819
import androidx.compose.foundation.layout.Box
1920
import androidx.compose.foundation.layout.Column
21+
import androidx.compose.foundation.layout.ExperimentalLayoutApi
2022
import androidx.compose.foundation.layout.Row
23+
import androidx.compose.foundation.layout.WindowInsets
24+
import androidx.compose.foundation.layout.defaultMinSize
25+
import androidx.compose.foundation.layout.exclude
2126
import androidx.compose.foundation.layout.fillMaxWidth
2227
import androidx.compose.foundation.layout.height
23-
import androidx.compose.foundation.layout.defaultMinSize
28+
import androidx.compose.foundation.layout.ime
29+
import androidx.compose.foundation.layout.imeAnimationSource
30+
import androidx.compose.foundation.layout.imeAnimationTarget
31+
import androidx.compose.foundation.layout.navigationBars
2432
import androidx.compose.foundation.layout.navigationBarsPadding
2533
import androidx.compose.foundation.layout.padding
2634
import androidx.compose.foundation.layout.requiredSize
@@ -39,26 +47,30 @@ import androidx.compose.ui.Modifier
3947
import androidx.compose.ui.draw.alpha
4048
import androidx.compose.ui.draw.clip
4149
import androidx.compose.ui.focus.FocusRequester
50+
import androidx.compose.ui.graphics.Brush
4251
import androidx.compose.ui.graphics.Color
4352
import androidx.compose.ui.graphics.TransformOrigin
53+
import androidx.compose.ui.layout.layout
54+
import androidx.compose.ui.platform.LocalDensity
4455
import androidx.compose.ui.platform.testTag
4556
import androidx.compose.ui.res.stringResource
4657
import androidx.compose.ui.text.style.TextAlign
4758
import androidx.compose.ui.unit.dp
59+
import androidx.compose.ui.unit.lerp
4860
import com.flipcash.app.messenger.internal.ChatSubject
4961
import com.flipcash.app.messenger.internal.ChatViewModel
5062
import com.flipcash.app.messenger.internal.balanceRequirement
5163
import com.flipcash.app.messenger.internal.requiresStaff
64+
import com.flipcash.features.messenger.R
65+
import com.flipcash.services.models.chat.BlobAccessContext
66+
import com.flipcash.services.models.chat.ChatType
5267
import com.flipcash.shared.chat.models.ChatActionHandler
5368
import com.flipcash.shared.chat.ui.ChatAnimations
54-
import com.flipcash.services.models.chat.ChatType
55-
import com.flipcash.services.models.chat.BlobAccessContext
56-
import com.flipcash.features.messenger.R
57-
import com.getcode.theme.CodeTheme
5869
import com.flipcash.shared.chat.ui.ComposerReplyStrip
70+
import com.flipcash.shared.common.ui.ContactAvatar
71+
import com.getcode.theme.CodeTheme
5972
import com.getcode.ui.components.chat.ChatInput
6073
import com.getcode.ui.components.chat.ChatInputSubmit
61-
import com.flipcash.shared.common.ui.ContactAvatar
6274
import com.getcode.ui.components.chat.TypingIndicator
6375
import com.getcode.ui.core.drawWithGradient
6476
import com.getcode.ui.core.measured
@@ -68,6 +80,27 @@ import dev.chrisbanes.haze.HazeState
6880
import dev.chrisbanes.haze.blur.hazeBlur
6981
import dev.chrisbanes.haze.blur.materials.HazeMaterials
7082

83+
/** The ramp's shape: clear for most of its height, then a late rise into the background. */
84+
private val ComposerFadeCurve = CubicBezierEasing(0.42f, 0.18f, 0.17f, 1.04f)
85+
86+
/** Short of opaque, so a bubble behind the bar's bottom edge still shows faintly. */
87+
private const val ComposerFadeEndAlpha = 0.9f
88+
89+
private const val ComposerFadeSamples = 32
90+
91+
/** Sampled once: the curve has no gradient-stop equivalent, so it is laid down as stops. */
92+
private val ComposerFadeStops: List<Pair<Float, Float>> = (0..ComposerFadeSamples).map { i ->
93+
val t = i / ComposerFadeSamples.toFloat()
94+
t to (ComposerFadeEndAlpha * ComposerFadeCurve.transform(t)).coerceIn(0f, 1f)
95+
}
96+
97+
/** The dissolve from the transcript into [color] behind the composer, from [startY] to [endY]. */
98+
private fun composerFade(color: Color, startY: Float, endY: Float): Brush = Brush.verticalGradient(
99+
colorStops = ComposerFadeStops.map { (t, alpha) -> t to color.copy(alpha = alpha) }.toTypedArray(),
100+
startY = startY,
101+
endY = endY,
102+
)
103+
71104
@Composable
72105
internal fun UserControlBottomBar(
73106
state: ChatViewModel.State,
@@ -154,20 +187,51 @@ internal fun UserControlBottomBar(
154187
}
155188
}
156189
}
190+
// Compact at rest: with the keyboard down the composer sits narrower and a little lower, into
191+
// the navigation bar's inset, and opens out to the normal margins as the keyboard comes up.
192+
// Only once there is a composer; the full-width Send Cash button keeps its width.
193+
//
194+
// Tracks how far open the keyboard is rather than springing on a keyboard up/down flag, so
195+
// the bar changes size in step with the keyboard's own motion: not ahead of it (a flag that
196+
// flips as the hide starts), and not after it (one that waits for the hide to finish). The
197+
// composer appearing at all (typingConstraints resolving a frame after the bar is revealed)
198+
// doesn't move anything, since nothing here animates on its own.
199+
val keyboardOpen = keyboardOpenFraction()
200+
val hasComposer = state.typingConstraints.enabled
201+
val compactInset = CodeTheme.dimens.grid.x6.coerceAtLeast(CodeTheme.dimens.inset)
202+
val sideInset = if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset
203+
val restingDrop = if (hasComposer) lerp(CodeTheme.dimens.grid.x2, 0.dp, keyboardOpen) else 0.dp
204+
// The part of the navigation bar's inset the keyboard isn't covering, so the bar only sinks
205+
// into it once the keyboard has cleared it.
206+
val restingRoom = WindowInsets.navigationBars.exclude(WindowInsets.ime)
157207
Box {
208+
// The transcript runs under the bar and dissolves into the background here, from the
209+
// bar's top edge to the bottom of the screen (or the keyboard's top edge). The bar has no
210+
// surface of its own and floats over it.
211+
val fadeColor = CodeTheme.colors.background
158212
Box(
159213
modifier = Modifier
160214
.fillMaxWidth()
161215
.height(buttonHeight)
162216
.align(Alignment.BottomCenter)
163217
.drawWithGradient(
164-
color = CodeTheme.colors.background,
218+
brush = { startY, endY -> composerFade(fadeColor, startY, endY) },
165219
startY = { 0f },
166220
),
167221
)
168222
AnimatedContent(
169223
modifier = Modifier
170224
.measured { buttonHeight = it.height }
225+
// Reports itself shorter by the drop and lets the controls run past its bottom
226+
// edge, so the bar sits that far into the navigation bar's inset and the
227+
// transcript's bottom padding (measured from this height) follows it down.
228+
.layout { measurable, constraints ->
229+
val drop = restingDrop.roundToPx().coerceAtMost(restingRoom.getBottom(this))
230+
val placeable = measurable.measure(constraints)
231+
layout(placeable.width, (placeable.height - drop).coerceAtLeast(0)) {
232+
placeable.place(0, 0)
233+
}
234+
}
171235
.padding(vertical = CodeTheme.dimens.grid.x3)
172236
.navigationBarsPadding()
173237
// typingConstraints.enabled starts false and only resolves a frame or two after
@@ -231,8 +295,8 @@ internal fun UserControlBottomBar(
231295
hazeState = hazeState,
232296
modifier = Modifier
233297
// Inset to the composer row's own margins, so the card's edges
234-
// line up with the field it sits above.
235-
.padding(horizontal = CodeTheme.dimens.inset)
298+
// line up with the field it sits above, and narrows with it.
299+
.padding(horizontal = sideInset)
236300
.padding(bottom = CodeTheme.dimens.grid.x2)
237301
.testTag("composer_reply_strip"),
238302
)
@@ -241,7 +305,7 @@ internal fun UserControlBottomBar(
241305
Row(
242306
modifier = Modifier
243307
.fillMaxWidth()
244-
.padding(horizontal = CodeTheme.dimens.inset),
308+
.padding(horizontal = sideInset),
245309
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2),
246310
verticalAlignment = Alignment.Bottom,
247311
) {
@@ -375,4 +439,21 @@ private fun DeactivatedChatBottomBar() {
375439
textAlign = TextAlign.Center,
376440
)
377441
}
378-
}
442+
}
443+
444+
/**
445+
* How far open the keyboard is, from 0 (down) to 1 (fully up), following its show and hide
446+
* animations frame by frame.
447+
*/
448+
@OptIn(ExperimentalLayoutApi::class)
449+
@Composable
450+
internal fun keyboardOpenFraction(): Float {
451+
val density = LocalDensity.current
452+
val now = WindowInsets.ime.getBottom(density)
453+
val full = maxOf(
454+
WindowInsets.imeAnimationSource.getBottom(density),
455+
WindowInsets.imeAnimationTarget.getBottom(density),
456+
now,
457+
)
458+
return if (full <= 0) 0f else now.toFloat() / full
459+
}

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

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,16 @@ import androidx.compose.runtime.snapshotFlow
3636
import androidx.compose.runtime.snapshots.Snapshot
3737
import androidx.compose.ui.Alignment
3838
import androidx.compose.ui.Modifier
39+
import androidx.compose.ui.draw.BlurredEdgeTreatment
40+
import androidx.compose.ui.draw.blur
41+
import androidx.compose.ui.graphics.graphicsLayer
3942
import androidx.compose.ui.input.pointer.pointerInput
4043
import androidx.compose.ui.platform.LocalDensity
4144
import androidx.compose.ui.res.stringResource
4245
import androidx.compose.ui.unit.Dp
4346
import androidx.compose.ui.unit.IntRect
4447
import androidx.compose.ui.unit.dp
45-
import androidx.compose.ui.draw.BlurredEdgeTreatment
46-
import androidx.compose.ui.draw.blur
47-
import androidx.compose.ui.graphics.graphicsLayer
48+
import androidx.compose.ui.unit.lerp
4849
import androidx.lifecycle.Lifecycle
4950
import androidx.lifecycle.compose.LocalLifecycleOwner
5051
import androidx.lifecycle.repeatOnLifecycle
@@ -64,6 +65,7 @@ import com.flipcash.shared.chat.models.ChatListItem
6465
import com.flipcash.shared.chat.models.LinkCardResolution
6566
import com.flipcash.shared.chat.models.LocalChatActionHandler
6667
import com.flipcash.shared.chat.models.LocalLinkCardResolution
68+
import com.flipcash.shared.chat.models.ReceiptStatus
6769
import com.flipcash.shared.chat.models.SeparatorConfig
6870
import com.flipcash.shared.chat.reactions.ReactionRefreshPlanner
6971
import com.flipcash.shared.chat.ui.ChatAnimations
@@ -77,11 +79,13 @@ import com.getcode.util.vibration.LocalVibrator
7779
import kotlinx.coroutines.delay
7880
import kotlinx.coroutines.flow.collectLatest
7981
import kotlinx.coroutines.flow.distinctUntilChanged
82+
import kotlinx.coroutines.flow.drop
8083
import kotlinx.coroutines.flow.dropWhile
8184
import kotlinx.coroutines.flow.filterNotNull
8285
import kotlinx.coroutines.flow.first
8386
import kotlinx.coroutines.flow.mapNotNull
8487
import kotlinx.coroutines.withTimeoutOrNull
88+
import kotlin.time.Duration.Companion.milliseconds
8589

8690
@Composable
8791
internal fun MessageList(
@@ -150,6 +154,7 @@ internal fun MessageList(
150154
// Keys that have already played their insertion animation — persists
151155
// across item disposal so scrolling away and back doesn't replay.
152156
val animatedKeys = remember { mutableSetOf<Any>() }
157+
val settlingKey = rememberSettlingSend(messages)
153158

154159
// The backdrop, read once for everything it covers: the rows, the bubbles' own targets,
155160
// and the contact card at the start of history all stop taking taps together.
@@ -258,7 +263,11 @@ internal fun MessageList(
258263
// shortening the list from below — whether the keyboard came up for the edit or because the
259264
// composer was tapped with the selection bar still showing. See FocusPin.
260265
val imeInsets = WindowInsets.ime
261-
val listBottomPad = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding()
266+
// With the keyboard up the newest message sits a little closer to the composer, which no
267+
// longer rests into the navigation bar's inset. Follows the keyboard the same way the
268+
// composer does (see ChatBottomBar), so the two move together.
269+
val raisedDrop = lerp(0.dp, CodeTheme.dimens.grid.x2, keyboardOpenFraction())
270+
val listBottomPad = CodeTheme.dimens.grid.x2 + contentPadding.calculateBottomPadding() - raisedDrop
262271
val listBottomPadPx = with(LocalDensity.current) { listBottomPad.roundToPx() }
263272
val focusPin = rememberFocusPin(listState, messages, focusedMessageId, imeInsets, listBottomPadPx)
264273
// Read live from the auto-scroll effect below, which outlives the composition it launched in.
@@ -420,6 +429,7 @@ internal fun MessageList(
420429
messages = messages,
421430
separatorConfig = separatorConfig,
422431
otherReadPointer = otherReadPointer,
432+
settlingKey = settlingKey,
423433
selecting = selecting,
424434
focused = focused,
425435
// The pressed row is also the only one that lifts, as iOS lifts only the held bubble.
@@ -562,6 +572,16 @@ internal fun MessageList(
562572
.filterNotNull()
563573
.distinctUntilChanged()
564574
.collectLatest {
575+
// The paged snapshot changes before the list lays it out, so wait for the next
576+
// layout pass; until then the scroll position describes the old list.
577+
withTimeoutOrNull(NextLayoutTimeoutMs) {
578+
snapshotFlow { listState.layoutInfo }.drop(1).first()
579+
}
580+
// The list holds its position by key, so a message arriving at index 0 leaves
581+
// the one that was newest (now index 1) where it sat. Sitting at offset 0 means
582+
// the transcript was resting on the newest message.
583+
val atBottom = listState.firstVisibleItemIndex == 1 &&
584+
listState.firstVisibleItemScrollOffset == 0
565585
// Always scroll for own messages; only near-bottom for incoming
566586
val nearBottom = listState.firstVisibleItemIndex <= 5
567587
val newest = messages.peek(0) as? ChatListItem.ContentBubble
@@ -579,6 +599,25 @@ internal fun MessageList(
579599
}
580600
return@collectLatest
581601
}
602+
// Resting on the newest message, the arrival pushes the transcript up as iOS
603+
// does: every row rides up by the new row's height on the insertion spring,
604+
// while the new row rises into its slot. Placement animations can't do this —
605+
// the move is a scroll, and a jump in scroll position resets them.
606+
if (atBottom && (newest?.isFromSelf == true || nearBottom)) {
607+
val height = listState.measuredHeight(0)
608+
if (height != null) {
609+
// Negative is toward index 0.
610+
listState.animateScrollBy(-height.toFloat(), ChatAnimations.insertionPush)
611+
// The row above can resize under the scroll (its receipt moving on), so
612+
// settle exactly on the newest message rather than a few pixels short.
613+
if (listState.firstVisibleItemIndex != 0 ||
614+
listState.firstVisibleItemScrollOffset != 0
615+
) {
616+
listState.requestScrollToItem(0, 0)
617+
}
618+
return@collectLatest
619+
}
620+
}
582621
when {
583622
// Own message: anchor the new bubble during the next measure pass
584623
// rather than animating to it. An animated scroll resolves its target
@@ -610,6 +649,36 @@ internal fun MessageList(
610649
} // CompositionLocalProvider
611650
}
612651

652+
/** How long a send holds the receipt above it, at least; iOS's settle floor. */
653+
private val ReceiptSettleFloor = 700.milliseconds
654+
655+
/**
656+
* The viewer's newest send while it settles, or null. A send settles from the moment it lands at
657+
* the bottom of the transcript until it is confirmed and [ReceiptSettleFloor] has passed, whichever
658+
* is later; until then the receipt stays on the row above (see [shouldShowReceiptLabel]). A send
659+
* that fails, or that a reply lands under, releases once the floor has passed.
660+
*/
661+
@Composable
662+
private fun rememberSettlingSend(messages: LazyPagingItems<ChatListItem>): Any? {
663+
val newest = if (messages.itemCount > 0) messages.peek(0) as? ChatListItem.ContentBubble else null
664+
val currentNewest by rememberUpdatedState(newest)
665+
var sendKey by remember { mutableStateOf<Any?>(null) }
666+
if (newest != null && newest.isFromSelf && newest.receiptStatus == ReceiptStatus.SENDING) {
667+
sendKey = newest.messageKey
668+
}
669+
var settling by remember { mutableStateOf<Any?>(null) }
670+
LaunchedEffect(sendKey) {
671+
val key = sendKey ?: return@LaunchedEffect
672+
settling = key
673+
delay(ReceiptSettleFloor)
674+
snapshotFlow { currentNewest }.first { row ->
675+
row?.messageKey != key || row.receiptStatus != ReceiptStatus.SENDING
676+
}
677+
settling = null
678+
}
679+
return settling
680+
}
681+
613682
/**
614683
* How many items are actually loaded, as opposed to standing in as placeholders.
615684
*
@@ -767,6 +836,9 @@ private fun LazyListLayoutInfo.centeringBand(): IntRange =
767836
/** Handed to every row that is not the one a jump just landed on. */
768837
private val NoAttention: () -> Float = { 0f }
769838

839+
// A guard on waiting for the list to lay out a newly arrived row: the scroll goes ahead without the
840+
// pass rather than waiting on one that doesn't come.
841+
private const val NextLayoutTimeoutMs = 250L
770842
private const val JUMP_PAGE_SIZE = 50
771843
private const val MAX_JUMP_ITEMS = 5_000
772844
private const val JUMP_STEP_TIMEOUT_MS = 2_000L

0 commit comments

Comments
 (0)