Fix first Wear message auto-scroll

This commit is contained in:
callebtc 2026-08-01 00:32:20 +02:00
parent efeb9b54b5
commit 44b2b8cb79
2 changed files with 62 additions and 8 deletions

View File

@ -113,9 +113,21 @@ fun ChatScaffold(
val followNewest = remember(messages.size) { atNewest }
LaunchedEffect(columnState, messages.size) {
if (messages.isNotEmpty() && followNewest) {
val expectedSingleMessageKey = messages.singleOrNull()?.id
scrollToNewestAfterItemsMeasured(
expectedItemCount = messages.size,
measuredItemCounts = snapshotFlow { columnState.layoutInfo.totalItemsCount }
expectedSingleMessageKey = expectedSingleMessageKey,
measuredLayouts = snapshotFlow {
val layoutInfo = columnState.layoutInfo
MeasuredChatLayout(
itemCount = layoutInfo.totalItemsCount,
singleVisibleItemKey = if (expectedSingleMessageKey != null) {
layoutInfo.visibleItems.singleOrNull()?.key
} else {
null
}
)
}
) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
// item is partially visible, which left the last message cropped.
@ -138,12 +150,22 @@ fun ChatScaffold(
)
}
internal data class MeasuredChatLayout(
val itemCount: Int,
val singleVisibleItemKey: Any?
)
internal suspend fun scrollToNewestAfterItemsMeasured(
expectedItemCount: Int,
measuredItemCounts: Flow<Int>,
expectedSingleMessageKey: Any?,
measuredLayouts: Flow<MeasuredChatLayout>,
scrollToEnd: suspend () -> Unit
) {
measuredItemCounts.first { it >= expectedItemCount }
measuredLayouts.first { layout ->
layout.itemCount >= expectedItemCount &&
(expectedSingleMessageKey == null ||
layout.singleVisibleItemKey == expectedSingleMessageKey)
}
scrollToEnd()
}

View File

@ -12,13 +12,14 @@ class ChatAutoScrollTest {
@Test
fun `newest scroll waits until appended message is measured`() = runTest {
val measuredItemCount = MutableStateFlow(3)
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
var scrollCount = 0
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
measuredItemCounts = measuredItemCount
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
@ -27,7 +28,7 @@ class ChatAutoScrollTest {
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
measuredItemCount.value = 4
measuredLayouts.value = MeasuredChatLayout(4, null)
scrollJob.join()
assertEquals(1, scrollCount)
@ -35,16 +36,47 @@ class ChatAutoScrollTest {
@Test
fun `newest scroll runs immediately when messages are already measured`() = runTest {
val measuredItemCount = MutableStateFlow(4)
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(4, null))
var scrollCount = 0
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
measuredItemCounts = measuredItemCount
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
assertEquals(1, scrollCount)
}
@Test
fun `first message waits past stale empty placeholder layout`() = runTest {
val messageKey = "first-message"
val measuredLayouts = MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
var scrollCount = 0
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 1,
expectedSingleMessageKey = messageKey,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
measuredLayouts.value = MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey
)
scrollJob.join()
assertEquals(1, scrollCount)
}
}