Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -22,33 +28,61 @@ 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

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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
)
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -110,6 +111,23 @@ object ChatAnimations {
const val attentionHoldMs = 250L
val attentionFade: TweenSpec<Float> = 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<Float> = 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<Float> = 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<Float> = 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<Float> = 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -231,7 +238,7 @@ internal fun composerMetrics(): ComposerMetrics {
stackedTextTopInset = 16.dp,
stackedTextGap = 8.dp,
controlSpacing = grid.x2,
outsideSpacing = grid.x2,
outsideSpacing = ChatInputDefaults.OutsideSpacing,
)
}

Expand Down
Loading