diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index 069f7ee9c..cb3dd2e2e 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -293,7 +293,7 @@ internal fun UserControlBottomBar( // Toasts rest above the composer and as wide as its row, rather than the tab bar's. The // bar's measured height includes the padding above the composer, which the host's own // gap replaces. - ToastBottomClearance((buttonHeight - CodeTheme.dimens.staticGrid.x3).coerceAtLeast(0.dp)) + ToastBottomClearance((buttonHeight - composerBarVerticalPadding).coerceAtLeast(0.dp)) ToastHorizontalInset(sideInset) ChatUpdateToast(enabled = hasComposer) Box { @@ -327,7 +327,7 @@ internal fun UserControlBottomBar( placeable.place(0, 0) } } - .padding(vertical = CodeTheme.dimens.staticGrid.x3) + .padding(vertical = composerBarVerticalPadding) .navigationBarsPadding() // typingConstraints.enabled starts false and only resolves a frame or two after // open, once Room confirms whether the chat has a cash message. Rendering the @@ -652,6 +652,10 @@ private fun DeactivatedChatBottomBar() { } } +/** The bar's padding above and below its controls; the `$` circle's top sits this far under the bar's top. */ +internal val composerBarVerticalPadding: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x3 + /** * The margin either side of the bar. Compact at rest once there is a composer, opening out to the * normal inset as the keyboard ([keyboardOpen], 0 to 1) comes up. The `$` button's trailing edge sits diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt index becc2843c..7ca00647b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -1,7 +1,12 @@ package com.flipcash.app.messenger.internal.screens.components +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -10,6 +15,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -22,26 +28,35 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +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.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme -import com.getcode.ui.components.Badge +import com.flipcash.shared.chat.ui.ChatAnimations +import com.getcode.ui.components.unreadCountLabel import com.getcode.ui.components.chat.ChatInputDefaults import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.hazeBlur +import kotlinx.coroutines.delay +import androidx.compose.material3.Text +import kotlin.math.sqrt /** Past this many rows from the newest, a tap jumps rather than animates (from the old Flipchat). */ internal const val JumpToBottomAnimateMaxRows = 100 @@ -49,6 +64,25 @@ internal const val JumpToBottomAnimateMaxRows = 100 internal val JumpToBottomButtonSize = 48.dp private val BadgeOutline = 2.dp +/** Past this the label reads "99+", so counts at or above it are the same state to animate. */ +private const val BadgeCountCap = 100 +private val BadgeMinSize = 20.dp + +/** + * How far in from the button's bounding-box corner, on both axes, the badge's centre sits so it is on + * the circle's edge at 45 degrees: the edge point is r/sqrt(2) from the circle's centre, and the + * corner is r from it on each axis. + */ +internal fun jumpToBottomBadgeInset(buttonSize: Dp): Dp = buttonSize / 2 * (1f - (1f / sqrt(2f))) + +/** + * How far above the screen's bottom the jump button sits so its bottom edge is twice [composerGap] + * above the `$` circle's top edge. [barHeight] is the bar's height (the list's bottom padding); the + * circle starts [barVerticalPadding] under the bar's top, so that padding is taken back off. + */ +internal fun jumpToBottomBottomOffset(barHeight: Dp, barVerticalPadding: Dp, composerGap: Dp): Dp = + barHeight - barVerticalPadding + composerGap * 2 + /** Whether a tap at [firstVisibleIndex] rows from the newest jumps instead of animating. */ internal fun jumpsToBottom(firstVisibleIndex: Int): Boolean = firstVisibleIndex > JumpToBottomAnimateMaxRows @@ -82,8 +116,10 @@ internal fun JumpToBottomButton( AnimatedVisibility( modifier = modifier, visible = visible, - enter = fadeIn(tween(200)) + scaleIn(tween(200), initialScale = 0.8f), - exit = fadeOut(tween(150)) + scaleOut(tween(150), targetScale = 0.8f), + enter = fadeIn(ChatAnimations.jumpEnter) + + scaleIn(ChatAnimations.jumpEnter, initialScale = ChatAnimations.jumpScale), + exit = fadeOut(ChatAnimations.jumpExit) + + scaleOut(ChatAnimations.jumpExit, targetScale = ChatAnimations.jumpScale), ) { Box { Box( @@ -114,34 +150,89 @@ internal fun JumpToBottomButton( modifier = Modifier.size(28.dp), ) } + // While the button is up the badge follows the count; once the button starts leaving it + // keeps what it showed, so a count cleared by the jump doesn't shrink it on the way out. + var heldBadge by remember { mutableStateOf(unreadCount > 0) } + LaunchedEffect(visible, unreadCount) { if (visible) heldBadge = unreadCount > 0 } + val badgeWanted = if (visible) unreadCount > 0 else heldBadge + // Springs carry no delay, so the badge is held back after the button instead. + var badgeShown by remember { mutableStateOf(false) } + LaunchedEffect(badgeWanted) { + if (badgeWanted) delay(ChatAnimations.badgeEnterDelayMs) + badgeShown = badgeWanted + } + val capped = unreadCount.coerceAtMost(BadgeCountCap) + var previousCapped by remember { mutableIntStateOf(capped) } + val bump = remember { Animatable(1f) } + LaunchedEffect(capped) { + val rose = previousCapped > 0 && capped > previousCapped + previousCapped = capped + if (rose) { + bump.animateTo(ChatAnimations.badgeBumpScale, ChatAnimations.badgeBump) + bump.animateTo(1f, ChatAnimations.badgeBump) + } + } + val inset = with(LocalDensity.current) { jumpToBottomBadgeInset(JumpToBottomButtonSize).roundToPx() } AnimatedVisibility( modifier = Modifier .align(Alignment.TopEnd) - // The pill, outline included, is centred on the top-trailing corner of the button's - // bounds: placed half its own size out, without changing the size it reports. + // The pill, outline included, is centred on the circle's edge at 45 degrees: its + // centre starts on the bounding box's corner, then moves [inset] in on both axes. + // Placed without changing the size it reports. .layout { measurable, constraints -> val placeable = measurable.measure(constraints) - val dx = if (layoutDirection == LayoutDirection.Rtl) -placeable.width / 2 else placeable.width / 2 - layout(placeable.width, placeable.height) { placeable.place(dx, -placeable.height / 2) } + val rtl = layoutDirection == LayoutDirection.Rtl + val dx = (placeable.width / 2 - inset) * if (rtl) -1 else 1 + layout(placeable.width, placeable.height) { + placeable.place(IntOffset(dx, -placeable.height / 2 + inset)) + } }, - visible = unreadCount > 0, - enter = fadeIn(tween(200)) + scaleIn(tween(200)), - exit = fadeOut(tween(150)) + scaleOut(tween(150)), + visible = badgeShown, + enter = scaleIn(ChatAnimations.badgeEnter, initialScale = 0f), + // It leaves with the button, which is already scaling and fading away. + exit = ExitTransition.None, ) { // The outline is the chat background, so the pill reads as cut out of the button. Box( Modifier + .graphicsLayer { + scaleX = bump.value + scaleY = bump.value + } .background(background, CircleShape) .padding(BadgeOutline), ) { - Badge( - count = shownCount, - color = CodeTheme.colors.badgeFocus, - textStyle = CodeTheme.typography.caption.copy( - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - ), - ) + Box( + modifier = Modifier + .clip(CircleShape) + .background(CodeTheme.colors.badgeFocus) + // Never narrower than tall, so a single digit is a circle. + .defaultMinSize(minWidth = BadgeMinSize, minHeight = BadgeMinSize) + .padding(horizontal = CodeTheme.dimens.grid.x1, vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + AnimatedContent( + targetState = shownCount.coerceAtMost(BadgeCountCap), + transitionSpec = { + if (targetState > initialState) { + (slideInVertically { it } + fadeIn()) togetherWith + (slideOutVertically { -it } + fadeOut()) + } else { + fadeIn() togetherWith fadeOut() + } + }, + label = "unreadCount", + ) { count -> + Text( + text = unreadCountLabel(count), + style = CodeTheme.typography.caption.copy( + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ), + color = Color.White, + ) + } + } } } } 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 c4680007a..a229b1d58 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 @@ -718,7 +718,13 @@ internal fun MessageList( modifier = Modifier .align(Alignment.BottomEnd) // The scaffold already rides the keyboard, so the bar-height padding does too. - .padding(bottom = contentPadding.calculateBottomPadding() + 20.dp) + .padding( + bottom = jumpToBottomBottomOffset( + barHeight = contentPadding.calculateBottomPadding(), + barVerticalPadding = composerBarVerticalPadding, + composerGap = ChatInputDefaults.OutsideSpacing, + ), + ) .alignedToDollarColumn( insets = composerInsets, dollarColumn = dollarColumn, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt index 587621cd5..be8c35fd4 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt @@ -1,5 +1,8 @@ package com.flipcash.app.messenger.internal +import androidx.compose.ui.unit.dp +import com.flipcash.app.messenger.internal.screens.components.jumpToBottomBadgeInset +import com.flipcash.app.messenger.internal.screens.components.jumpToBottomBottomOffset import com.flipcash.app.messenger.internal.screens.components.jumpsToBottom import com.getcode.ui.components.unreadCountLabel import org.junit.Test @@ -24,4 +27,21 @@ class JumpToBottomTest { assertFalse(jumpsToBottom(100)) assertTrue(jumpsToBottom(101)) } + + @Test + fun `button's bottom edge is twice the composer gap above the dollar circle`() { + val barHeight = 80.dp + val barPad = 12.dp + val gap = 8.dp + val offset = jumpToBottomBottomOffset(barHeight, barPad, gap) + // The button's bottom edge rises (offset - barHeight) above the bar's top; the circle's top is + // barPad below it. The gap between the two is the sum. + assertEquals(gap * 2, (offset - barHeight) + barPad) + } + + @Test + fun `badge centre sits on the circle's edge at 45 degrees`() { + // 48/2 * (1 - 1/sqrt(2)) = 7.03dp in from the bounding box's corner on both axes. + assertEquals(7.03f, jumpToBottomBadgeInset(48.dp).value, 0.01f) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt index 0168df261..7d0c5c43f 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/ChatAnimations.kt @@ -2,6 +2,7 @@ package com.flipcash.shared.chat.ui import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutLinearInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.SpringSpec @@ -110,6 +111,23 @@ object ChatAnimations { const val attentionHoldMs = 250L val attentionFade: TweenSpec = tween(durationMillis = 750, easing = LinearEasing) + // The jump-to-bottom button. Stiffness is (2pi / response)^2, damping ratio is 1 - bounce. + // Entering — iOS jumpEnter: .spring(response: 0.3, bounce: 0.25), from 80%. + val jumpEnter: SpringSpec = spring(dampingRatio = 0.75f, stiffness = 439f) + const val jumpScale = 0.8f + // Leaving — iOS jumpExit: .easeIn(duration: 0.15), to 80%. An accelerating tween with no bounce, + // so the button doesn't rebound as it goes. + val jumpExit: TweenSpec = tween(durationMillis = 150, easing = FastOutLinearInEasing) + // Its unread badge arriving, [badgeEnterDelayMs] after the button — iOS badgeEnter: + // .spring(response: 0.25, bounce: 0.4), from 0, delayed 0.06s. Springs carry no delay in Compose, + // so the caller holds the badge back that long. + val badgeEnter: SpringSpec = spring(dampingRatio = 0.6f, stiffness = 631f) + const val badgeEnterDelayMs = 60L + // The badge's 1 -> [badgeBumpScale] -> 1 bump when the count goes up. iOS badgeBump is the same + // spring as badgeEnter: .spring(response: 0.25, bounce: 0.4). + val badgeBump: SpringSpec = spring(dampingRatio = 0.6f, stiffness = 631f) + const val badgeBumpScale = 1.15f + // Receipt label exit when a new message is sent — the row closes the gap on [reflow] while the // line fades out fast enough to be gone before the bubble below reaches it. iOS // receiptExitFade: 0.08 s. diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt index 8837bff52..b83a9b5d3 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/chat/ChatInput.kt @@ -153,6 +153,13 @@ object ChatInputDefaults { val OutsideSize: Dp @Composable get() = oneLineFieldHeight(FieldPadding, AccessorySize) + /** + * The gap between the `$` / cancel slot and the field. The jump-to-bottom button rides twice this + * above the `$` button, so it is named here rather than repeated. + */ + val OutsideSpacing: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x2 + /** * The corner of the thumbnails the composer's header holds. The field's own corner is this plus * the header inset, and the photo chips draw with it, so the two curves share a centre. @@ -231,7 +238,7 @@ internal fun composerMetrics(): ComposerMetrics { stackedTextTopInset = 16.dp, stackedTextGap = 8.dp, controlSpacing = grid.x2, - outsideSpacing = grid.x2, + outsideSpacing = ChatInputDefaults.OutsideSpacing, ) }