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 @@ -3,8 +3,11 @@ package com.flipcash.app.messenger.internal.screens
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
Expand Down Expand Up @@ -53,6 +56,15 @@ internal object ChatAnimations {
val replySurfaceExit: ExitTransition =
shrinkVertically(replySurfaceIntSize, shrinkTowards = Alignment.Top) + fadeOut(replySurface)

// The flash a jump leaves on the message it landed on: white at full, held long enough to be
// caught by an eye still following the scroll, then faded off.
//
// A tween rather than a spring, unlike everything above it: this one runs from full to nothing
// with no competing target to be interrupted by, so there is no settling for a spring to
// express — only a rate, and a linear one is what reads as a light going out.
const val attentionHoldMs = 250L
val attentionFade: TweenSpec<Float> = tween(durationMillis = 750, easing = LinearEasing)

// Receipt label exit when a new message is sent — fade out + collapse.
private val deliveredIntSize: SpringSpec<IntSize> = spring(dampingRatio = 0.88f, stiffness = 250f)
val receiptExit: ExitTransition = shrinkVertically(deliveredIntSize) + fadeOut(delivered)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.flipcash.app.messenger.internal.screens.components

import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
Expand All @@ -10,12 +11,15 @@ import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListLayoutInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
Expand All @@ -31,6 +35,7 @@ import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.flipcash.app.messenger.internal.ChatViewModel
import com.flipcash.app.messenger.internal.screens.ChatAnimations
import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.shared.chat.models.ChatAction
import com.flipcash.shared.chat.models.ChatActionHandler
Expand All @@ -40,6 +45,7 @@ import com.flipcash.shared.chat.models.SeparatorConfig
import com.getcode.theme.CodeTheme
import com.getcode.ui.utils.rememberKeyboardController
import com.getcode.util.vibration.LocalVibrator
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.dropWhile
Expand Down Expand Up @@ -116,6 +122,25 @@ internal fun MessageList(
if (buried > 0) listState.animateScrollBy(buried.toFloat())
}

// The mark a jump leaves on the message it landed on, so the scroll answers which message
// it was for. Held here rather than in ChatViewModel.State: it is a property of this list
// having moved, and it has to outlive the jump — jumpTarget is cleared the moment the
// scroll settles, and the flash is only starting there.
val attention = remember { Animatable(0f) }
var attentionId by remember { mutableStateOf<Long?>(null) }
// Counted rather than keyed on the id alone: tapping the same quote twice is two jumps, and
// an id that did not change would leave the second one dark.
var attentionRequest by remember { mutableIntStateOf(0) }
LaunchedEffect(attentionRequest) {
if (attentionRequest == 0) return@LaunchedEffect
attention.snapTo(1f)
delay(ChatAnimations.attentionHoldMs)
attention.animateTo(0f, ChatAnimations.attentionFade)
}
// Read in the draw phase, so the fade repaints the one bubble without recomposing the list.
// Remembered so the row keeps being handed the same reference and stays skippable.
val readAttention: () -> Float = remember { { attention.value } }

// Walking the append path, rather than PagingConfig.jumpThreshold: a jump there routes
// through PageFetcher::refresh with triggerRemoteRefresh set, so every tap would fire a
// RemoteMediator refresh — token = null and a fetch of the newest page — to reach a message
Expand Down Expand Up @@ -150,7 +175,14 @@ internal fun MessageList(
index = indexOf(messages, target)
}

index?.let { listState.animateScrollToItem(it) }
index?.let {
listState.centerItem(it)
attentionId = target
attentionRequest++
}
// Last, and it has to be: this clears jumpTarget, which is what this effect is keyed
// on, so the coroutine is cancelled here. The flash runs in its own effect for the
// same reason.
onJumpConsumed()
}

Expand Down Expand Up @@ -248,6 +280,11 @@ internal fun MessageList(
selecting = selecting,
focused = focused,
animateInsertion = animateInsertion,
attention = if (bubble != null && bubble.messageId == attentionId) {
readAttention
} else {
NoAttention
},
)
}

Expand Down Expand Up @@ -384,6 +421,68 @@ private fun indexOf(messages: LazyPagingItems<ChatListItem>, messageId: Long): I
(messages.peek(i) as? ChatListItem.ContentBubble)?.messageId == messageId
}

/**
* Brings [index] to the middle of the transcript, which is where iOS lands a jump too
* (`ChatViewController.scrollToRow`, `.centeredVertically`).
*
* One animation, and it has to be one: a second scroll to correct the first starts from a
* standstill, so what it reads as is the list moving, stopping, and moving again rather than as one
* move to the message.
*
* That leaves the target's height, which decides how far past its leading edge — visually its
* bottom, this list is reverseLayout — the message extends, and which nothing knows until the
* message has been laid out. [measuredHeight] lays it out without showing it.
*/
private suspend fun LazyListState.centerItem(index: Int) {
// Content padding is not transcript a message can sit in: the top of it is under the bar's fade,
// and the bottom is behind the composer.
val band = layoutInfo.centeringBand()
if (band.isEmpty()) {
animateScrollToItem(index)
return
}

val height = measuredHeight(index) ?: 0
animateScrollToItem(index, scrollOffset = centeringOffset(band.last - band.first, height))
}

/**
* The laid-out height of [index], by putting it on screen and taking it straight back off.
*
* Both scrolls are the non-animated kind, which force a remeasure and return without suspending, so
* they run inside one dispatch and the list is back where it started before anything is drawn. What
* is left behind is the measurement.
*/
private suspend fun LazyListState.measuredHeight(index: Int): Int? {
layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }?.let { return it.size }

val anchorIndex = firstVisibleItemIndex
val anchorOffset = firstVisibleItemScrollOffset
scrollToItem(index)
val size = layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }?.size
scrollToItem(anchorIndex, anchorOffset)
return size
}

/**
* The `scrollOffset` that lands an item of [itemHeight] in the middle of a band [bandHeight] tall.
*
* `animateScrollToItem` positions the item's leading edge at `-scrollOffset` from the band's start,
* so centring is half the room the message leaves over, negated.
*
* An item taller than the band cannot be centred, so it is aligned to the band's start instead —
* the edge the jump arrives from, since the reply that quoted it sits below it.
*/
internal fun centeringOffset(bandHeight: Int, itemHeight: Int): Int =
-((bandHeight - itemHeight) / 2).coerceAtLeast(0)

/** The part of the viewport a message can actually be centred in, in item-offset coordinates. */
private fun LazyListLayoutInfo.centeringBand(): IntRange =
(viewportStartOffset + beforeContentPadding)..(viewportEndOffset - afterContentPadding)

/** Handed to every row that is not the one a jump just landed on. */
private val NoAttention: () -> Float = { 0f }

private const val JUMP_PAGE_SIZE = 50
private const val MAX_JUMP_ITEMS = 5_000
private const val JUMP_STEP_TIMEOUT_MS = 2_000L
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ import com.getcode.util.vibration.LocalVibrator
* The row owns what is a function of itself — its insertion animation, its gestures, its spacing to
* the row below — and takes the rest as flags, because they are decided across the whole list:
* [selecting] is true for every row while the backdrop is up, [focused] for the single row it leaves
* sharp, and [animateInsertion] is granted once per message and never again.
* sharp, [attention] carries the flash the list points at a jumped-to message, and
* [animateInsertion] is granted once per message and never again.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
Expand All @@ -71,6 +72,7 @@ internal fun MessageRow(
selecting: Boolean,
focused: Boolean,
animateInsertion: Boolean,
attention: () -> Float = { 0f },
) {
val onAction = LocalChatActionHandler.current
val vibrator = LocalVibrator.current
Expand Down Expand Up @@ -221,6 +223,7 @@ internal fun MessageRow(
messages,
separatorConfig
),
attention = attention,
)
}
val showReceipt =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.flipcash.app.messenger.internal

import android.graphics.Bitmap
import android.graphics.Canvas
import android.view.View
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.width
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import com.flipcash.app.theme.FlipcashPreview
import com.flipcash.services.models.chat.MessageContent
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.ui.BubblePosition
import com.flipcash.shared.chat.ui.ContentBubble
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import java.io.File
import kotlin.time.Instant

/**
* Renders the flash a jump leaves on the message it landed on, at three points through its fade and
* on both bubble colours, so the scrim can be judged without an emulator. Not an assertion test —
* it writes to `build/screenshots/`.
*
* Same mechanics as `ChatMessageActionScreenshotTest`: pause the clock, pump frames, draw the view.
* The strength is passed as a constant rather than animated, because what is being looked at is the
* scrim at a given strength, not the timing that walks through them.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w400dp-h1100dp-xhdpi")
class ChatJumpAttentionScreenshotTest {

@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()

private fun bubble(isFromSelf: Boolean, text: String) = ChatListItem.ContentBubble(
messageId = 1,
contentIndex = 0,
content = MessageContent.Text(text),
isFromSelf = isFromSelf,
timestamp = Instant.fromEpochSeconds(1_000),
capabilities = emptySet(),
)

@Test
fun rendersAttentionFlashAcrossItsFade() {
composeRule.mainClock.autoAdvance = false
composeRule.setContent {
FlipcashPreview(showBackground = true) {
Column(
modifier = Modifier.width(360.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
listOf(1f, 0.5f, 0f).forEach { strength ->
ContentBubble(
item = bubble(isFromSelf = false, text = "theirs at $strength"),
position = BubblePosition.Solo,
attention = { strength },
)
ContentBubble(
item = bubble(isFromSelf = true, text = "mine at $strength"),
position = BubblePosition.Solo,
attention = { strength },
)
}
}
}
}
repeat(10) { composeRule.mainClock.advanceTimeByFrame() }

capture("chat_jump_attention.png")
}

private fun capture(name: String) {
val root: View = composeRule.activity.findViewById(android.R.id.content)
val width = root.width.takeIf { it > 0 } ?: 1080
val height = root.height.takeIf { it > 0 } ?: 1920
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
root.draw(Canvas(bitmap))

val outDir = File("build/screenshots").apply { mkdirs() }
val file = File(outDir, name)
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${bitmap.width}x${bitmap.height})")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.flipcash.app.messenger.internal.screens.components

import kotlin.test.Test
import kotlin.test.assertEquals

/**
* The jump centres the message it lands on in one scroll, and the whole of that scroll is one
* number: the `scrollOffset` handed to `animateScrollToItem`, which positions the target's leading
* edge at `-scrollOffset` from the start of the band.
*
* The band is what is left of the viewport once content padding is off it.
*/
class JumpCenteringTest {

private val band = 1000 // a 1000px band

@Test
fun `a message is offset by half the room it leaves over`() {
// 200px in a 1000px band leaves 800, so its leading edge goes 400 in and its middle at 500.
assertEquals(-400, centeringOffset(bandHeight = band, itemHeight = 200))
}

@Test
fun `a message filling half the band still centres`() {
assertEquals(-250, centeringOffset(bandHeight = band, itemHeight = 500))
}

@Test
fun `a message too tall to centre is aligned to the band's start`() {
assertEquals(0, centeringOffset(bandHeight = band, itemHeight = 2000))
}

@Test
fun `a message exactly as tall as the band is aligned to it too`() {
assertEquals(0, centeringOffset(bandHeight = band, itemHeight = band))
}

@Test
fun `a height that could not be measured puts the leading edge on the middle`() {
// centerItem falls back to 0 when the target never reported a size. Half a message off is
// the worst it can then be, and it is off toward the top, where there is transcript to see.
assertEquals(-500, centeringOffset(bandHeight = band, itemHeight = 0))
}
}
Loading
Loading