Compact Wear chat headers and stabilize scroll controls

This commit is contained in:
callebtc 2026-09-07 14:20:35 +03:00
parent afad3c5513
commit 08974107dc
6 changed files with 242 additions and 189 deletions

View File

@ -28,14 +28,19 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.wear.compose.foundation.lazy.TransformingLazyColumn
import androidx.wear.compose.foundation.lazy.TransformingLazyColumnState
import androidx.wear.compose.foundation.lazy.items
@ -50,13 +55,12 @@ import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlin.math.sign
/**
* The shared chat body for global chat and DM threads, following the classic messenger
* pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary,
* scrollbar) with the header and action bar as floating overlays that get out of the way
* while scrolling up into history and return on any downward scroll; at the newest message
* while scrolling up into history and return on a deliberate reverse scroll; at the newest message
* they are always visible.
*
* The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or
@ -92,33 +96,38 @@ fun ChatScaffold(
// Follow intent is changed only by an actual user scroll away from the newest item or by
// reaching the end again. A new item temporarily makes canScrollForward true before layout;
// treating that transient range change as user intent breaks automatic following.
var followNewest by remember { mutableStateOf(true) }
val controlsVisible = remember { mutableStateOf(true) }
var scrollIntent by remember { mutableStateOf(ChatScrollIntentState()) }
val density = LocalDensity.current
val scrollConnection = remember(columnState, density) {
object : NestedScrollConnection {
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
// Both Wear rotary and touch dispatch consumed movement here. Positive list
// movement is toward newer messages. Layout changes never enter this path.
scrollIntent = updatedChatScrollIntent(
current = scrollIntent,
deltaDp = -consumed.y / density.density,
isUserInput = source == NestedScrollSource.UserInput,
atNewest = !columnState.canScrollForward
)
return Offset.Zero
}
}
}
LaunchedEffect(columnState) {
var lastPosition = -1
var scrollIntent = ChatScrollIntentState()
snapshotFlow {
val first = columnState.layoutInfo.visibleItems.firstOrNull()
ChatScrollSnapshot(
canScrollForward = columnState.canScrollForward,
isScrollInProgress = columnState.isScrollInProgress,
position = (first?.index ?: 0) * 100_000 + (first?.offset ?: 0)
)
}.collect { snapshot ->
scrollIntent = updatedChatScrollIntent(
current = scrollIntent,
snapshot = snapshot,
previousPosition = lastPosition
)
followNewest = scrollIntent.followsNewest
controlsVisible.value = scrollIntent.controlsVisible
lastPosition = snapshot.position
!columnState.canScrollForward && !columnState.isScrollInProgress
}.collect { atNewest ->
if (atNewest) scrollIntent = ChatScrollIntentState()
}
}
// Stick to bottom when the user has not intentionally moved into history.
LaunchedEffect(columnState, messages.size) {
if (messages.isNotEmpty() && followNewest) {
if (messages.isNotEmpty() && scrollIntent.followsNewest) {
val expectedSingleMessageKey = messages.singleOrNull()?.id
scrollToNewestAfterItemsMeasured(
expectedItemCount = messages.size,
@ -137,7 +146,14 @@ fun ChatScaffold(
) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the
// item is partially visible, which left the last message cropped.
columnState.scroll { scrollBy(Float.MAX_VALUE) }
// Do not seize the list from an active drag/crown gesture, or follow an
// append whose measurement completed after the user entered history.
followNewestWhenIdle(
scrolling = snapshotFlow { columnState.isScrollInProgress },
shouldFollow = { scrollIntent.followsNewest }
) {
columnState.scroll { scrollBy(Float.MAX_VALUE) }
}
}
}
}
@ -149,10 +165,10 @@ fun ChatScaffold(
voice = voice,
onOpenImage = onOpenImage,
columnState = columnState,
controlsVisible = controlsVisible.value,
controlsVisible = scrollIntent.controlsVisible,
header = header,
actionBar = actionBar,
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize().nestedScroll(scrollConnection)
)
}
@ -161,47 +177,30 @@ internal data class MeasuredChatLayout(
val singleVisibleItemKey: Any?
)
internal data class ChatScrollSnapshot(
val canScrollForward: Boolean,
val isScrollInProgress: Boolean,
val position: Int
)
internal data class ChatScrollIntentState(
val followsNewest: Boolean = true,
val controlsVisible: Boolean = true,
val accumulatedDeltaPx: Int = 0
val reversalDp: Float = 0f
)
internal fun updatedChatScrollIntent(
current: ChatScrollIntentState,
snapshot: ChatScrollSnapshot,
previousPosition: Int
deltaDp: Float,
isUserInput: Boolean,
atNewest: Boolean
): ChatScrollIntentState {
if (!snapshot.canScrollForward) return ChatScrollIntentState()
if (!snapshot.isScrollInProgress || previousPosition < 0) return current
val delta = snapshot.position - previousPosition
val accumulatedDelta = when {
delta == 0 -> current.accumulatedDeltaPx
current.accumulatedDeltaPx == 0 ||
current.accumulatedDeltaPx.sign == delta.sign ->
current.accumulatedDeltaPx + delta
else -> delta
}
val movedAway = accumulatedDelta <= -CHAT_SCROLL_DIRECTION_THRESHOLD_PX
val movedTowardNewest = accumulatedDelta >= CHAT_SCROLL_DIRECTION_THRESHOLD_PX
if (atNewest) return ChatScrollIntentState()
if (!isUserInput || !deltaDp.isFinite() || deltaDp == 0f) return current
// Hysteresis measures net travel opposite the current controls state, not the sum of
// tiny back-and-forth movements. Keep it across discrete crown ticks and idle periods.
val reversal = (current.reversalDp + if (current.controlsVisible) -deltaDp else deltaDp)
.coerceAtLeast(0f)
val threshold = if (current.controlsVisible) 12f else 24f
val toggle = reversal >= threshold
return current.copy(
followsNewest = current.followsNewest && !movedAway,
controlsVisible = when {
movedAway -> false
movedTowardNewest -> true
else -> current.controlsVisible
},
// Keep sub-threshold movement across discrete rotary events. Once intent is clear,
// start a fresh accumulator so reversing direction gets the same threshold treatment.
accumulatedDeltaPx = if (movedAway || movedTowardNewest) 0 else accumulatedDelta
followsNewest = current.followsNewest && deltaDp >= 0f,
controlsVisible = if (toggle) !current.controlsVisible else current.controlsVisible,
reversalDp = if (toggle) 0f else reversal
)
}
@ -219,6 +218,15 @@ internal suspend fun scrollToNewestAfterItemsMeasured(
scrollToEnd()
}
internal suspend fun followNewestWhenIdle(
scrolling: Flow<Boolean>,
shouldFollow: () -> Boolean,
scrollToEnd: suspend () -> Unit
) {
scrolling.first { !it }
if (shouldFollow()) scrollToEnd()
}
@Composable
private fun ChatBody(
messages: List<BitchatMessage>,
@ -236,6 +244,9 @@ private fun ChatBody(
val context = LocalContext.current
val transformationSpec = rememberTransformationSpec()
val isScreenRound = LocalConfiguration.current.isScreenRound
val headerClearance = with(LocalDensity.current) {
maxOf(40.dp, 24.dp + (14f * 1.3f).sp.toDp() / 2 + 6.dp)
}
// Slide-to-cancel: while recording, the finger's position is tracked globally; the
// overlay's mic button reports its bounds and becomes the cancel target when the
// finger hovers it (with generous slack so the snap engages on approach).
@ -362,7 +373,7 @@ private fun ChatBody(
// duplicated padding and a shortened list viewport.
contentPadding = scaffoldPadding.withVerticalClearance(
layoutDirection = layoutDirection,
top = CHAT_HEADER_CONTENT_CLEARANCE,
top = headerClearance,
bottom = CHAT_ACTION_BAR_CLEARANCE
)
) {
@ -437,8 +448,6 @@ private fun ChatBody(
// Extra finger slack (px, ~28dp at watch density) around the cancel target so the snap
// engages as the finger approaches, not only on exact contact.
private const val CANCEL_HOVER_SLANT_PX = 56f
private const val CHAT_SCROLL_DIRECTION_THRESHOLD_PX = 24
private val CHAT_HEADER_CONTENT_CLEARANCE = 56.dp
private val CHAT_ACTION_BAR_CLEARANCE = 64.dp
private val CHAT_HEADER_EDGE_FADE = 36.dp
private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp

View File

@ -127,13 +127,13 @@ private fun ChatHeader(
)
val iconSize by
androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp,
targetValue = if (expanded) 14.dp else 12.dp,
animationSpec = spec,
label = "hdrIcon",
)
val titleSize by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (expanded) 15f else 12f,
targetValue = if (expanded) 14f else 12f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "hdrTitle",
)
@ -156,7 +156,7 @@ private fun ChatHeader(
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false).padding(end = 8.dp),
modifier = Modifier.weight(1f, fill = false).padding(end = 2.dp),
)
}
Icon(

View File

@ -150,13 +150,13 @@ private fun DmHeader(
)
val headerIconSize by
androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp,
targetValue = if (expanded) 14.dp else 12.dp,
animationSpec = spec,
label = "dmHdrIcon",
)
val headerTitleSize by
androidx.compose.animation.core.animateFloatAsState(
targetValue = if (expanded) 15f else 12f,
targetValue = if (expanded) 14f else 12f,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrTitle",
)

View File

@ -1,6 +1,5 @@
package com.bitchat.watch.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
@ -8,11 +7,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
@ -33,16 +32,22 @@ internal fun WearChatHeader(
val configuration = LocalConfiguration.current
val lineHeight = with(LocalDensity.current) { (fontSize * 1.3f).sp.toDp() }
val rowHeight = maxOf(48.dp, lineHeight)
val top = 12.dp + (rowHeight - lineHeight) / 2
val top = (rowHeight - lineHeight) / 2
val background = MaterialTheme.colorScheme.background
// Follow the existing title animation without animating layout or the hit target.
val expansion = ((fontSize - 12f) / 2f).coerceIn(0f, 1f)
val fadeEnd = maxOf((36f + 8f * expansion).dp, top + lineHeight + 4.dp)
BoxWithConstraints(
Modifier.fillMaxWidth()
.background(
Modifier.fillMaxWidth().drawWithCache {
val brush =
Brush.verticalGradient(
0f to MaterialTheme.colorScheme.background,
0.75f to MaterialTheme.colorScheme.background,
0f to background,
0.65f to background.copy(alpha = 0.95f),
1f to Color.Transparent,
endY = fadeEnd.toPx(),
)
),
onDrawBehind { drawRect(brush) }
},
contentAlignment = Alignment.TopCenter,
) {
val safeWidth =
@ -59,8 +64,7 @@ internal fun WearChatHeader(
}
Row(
modifier =
Modifier.padding(top = 12.dp)
.width(safeWidth.coerceAtLeast(0.dp))
Modifier.width(safeWidth.coerceAtLeast(48.dp))
.height(rowHeight)
.clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick),
horizontalArrangement = Arrangement.Center,

View File

@ -11,99 +11,131 @@ import org.junit.Test
class ChatAutoScrollTest {
@Test
fun `new scroll range from appended message keeps follow intent`() {
val updated = updatedChatScrollIntent(
current = ChatScrollIntentState(),
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = false,
position = 100
),
previousPosition = 100
)
assertEquals(ChatScrollIntentState(), updated)
fun `append waits for active gesture and rechecks history intent`() = runTest {
val scrolling = MutableStateFlow(true)
var followsNewest = true
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { followsNewest }) { scrollCount++ }
}
assertFalse(job.isCompleted)
assertEquals(0, scrollCount)
followsNewest = false
scrolling.value = false
job.join()
assertEquals(0, scrollCount)
}
@Test
fun `user scroll away disables follow until list reaches newest again`() {
val browsingHistory = updatedChatScrollIntent(
current = ChatScrollIntentState(),
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = 60
),
previousPosition = 100
)
assertFalse(browsingHistory.followsNewest)
assertFalse(browsingHistory.controlsVisible)
fun `append follows after gesture settles when user remains at newest`() = runTest {
val scrolling = MutableStateFlow(true)
var scrollCount = 0
val job =
launch(start = CoroutineStart.UNDISPATCHED) {
followNewestWhenIdle(scrolling, { true }) { scrollCount++ }
}
assertEquals(0, scrollCount)
scrolling.value = false
job.join()
assertEquals(1, scrollCount)
}
val dockedAgain = updatedChatScrollIntent(
current = browsingHistory,
snapshot = ChatScrollSnapshot(
canScrollForward = false,
isScrollInProgress = false,
position = 200
),
previousPosition = 60
)
assertEquals(ChatScrollIntentState(), dockedAgain)
private fun move(
state: ChatScrollIntentState,
dp: Float,
user: Boolean = true,
newest: Boolean = false,
) = updatedChatScrollIntent(state, dp, user, newest)
@Test
fun `append and programmatic movement never change intent`() {
val docked = ChatScrollIntentState()
assertEquals(docked, move(docked, 1000f, user = false))
val history = ChatScrollIntentState(false, false)
assertEquals(history, move(history, 1000f, user = false))
}
@Test
fun `slow scroll away accumulates intent across sub-threshold updates`() {
var state = ChatScrollIntentState()
var previousPosition = 100
listOf(94, 88, 82, 76).forEach { position ->
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = position
),
previousPosition = previousPosition
)
previousPosition = position
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = false,
position = position
),
previousPosition = previousPosition
)
}
assertFalse(state.followsNewest)
assertFalse(state.controlsVisible)
assertEquals(0, state.accumulatedDeltaPx)
}
@Test
fun `slow scroll toward newest reveals controls without restoring follow early`() {
var state = ChatScrollIntentState(followsNewest = false, controlsVisible = false)
var previousPosition = 60
listOf(66, 72, 78, 84).forEach { position ->
state = updatedChatScrollIntent(
current = state,
snapshot = ChatScrollSnapshot(
canScrollForward = true,
isScrollInProgress = true,
position = position
),
previousPosition = previousPosition
)
previousPosition = position
}
fun `first consumed movement away suspends following before controls hide`() {
val state = move(ChatScrollIntentState(), -1f)
assertFalse(state.followsNewest)
assertEquals(true, state.controlsVisible)
assertEquals(0, state.accumulatedDeltaPx)
assertFalse(move(state, -11f).controlsVisible)
}
@Test
fun `deliberate reversal reveals controls but does not resume following`() {
val history = move(ChatScrollIntentState(), -12f)
val almost = move(history, 23f)
assertFalse(almost.controlsVisible)
val revealed = move(almost, 1f)
assertEquals(true, revealed.controlsVisible)
assertFalse(revealed.followsNewest)
assertEquals(ChatScrollIntentState(), move(revealed, 1f, newest = true))
}
@Test
fun `jitter does not accumulate into repeated toggles`() {
var state = move(ChatScrollIntentState(), -12f)
repeat(100) {
state = move(state, 3f)
state = move(state, -3f)
}
assertFalse(state.controlsVisible)
assertEquals(0f, state.reversalDp)
}
@Test
fun `pauses and discrete crown ticks retain net movement`() {
var state = ChatScrollIntentState()
repeat(4) {
state = move(state, -3f)
state = move(state, 0f, user = false)
}
assertFalse(state.controlsVisible)
repeat(8) {
state = move(state, 3f)
state = move(state, 0f, user = false)
}
assertEquals(true, state.controlsVisible)
assertFalse(state.followsNewest)
}
@Test
fun `fling preserves controls until newest is reached`() {
val history = move(ChatScrollIntentState(), -12f)
assertEquals(history, move(history, 1000f, user = false))
assertEquals(history, move(history, -1000f, user = false))
assertEquals(ChatScrollIntentState(), move(history, 1f, user = false, newest = true))
}
@Test
fun `consumed distance is independent of item boundaries and event chunking`() {
val initial = ChatScrollIntentState()
val oneEvent = move(initial, -12f)
val acrossRows =
listOf(-2f, -3f, -1f, -6f).fold(initial) { state, delta ->
move(state, delta)
}
assertEquals(oneEvent, acrossRows)
}
@Test
fun `pixel distances normalize to the same dp thresholds`() {
for (density in listOf(1f, 1.6875f, 2f, 3f)) {
val history = move(ChatScrollIntentState(), (-12f * density) / density)
assertFalse(history.controlsVisible)
assertEquals(true, move(history, (24f * density) / density).controlsVisible)
}
}
@Test
fun `invalid and unconsumed input is ignored`() {
val initial = ChatScrollIntentState()
assertEquals(initial, move(initial, Float.NaN))
assertEquals(initial, move(initial, Float.POSITIVE_INFINITY))
assertEquals(initial, move(initial, 0f))
}
@Test
@ -111,15 +143,16 @@ class ChatAutoScrollTest {
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
var scrollCount = 0
val scrollJob = launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
) {
scrollCount += 1
val scrollJob =
launch(start = CoroutineStart.UNDISPATCHED) {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
}
}
assertFalse(scrollJob.isCompleted)
assertEquals(0, scrollCount)
@ -138,7 +171,7 @@ class ChatAutoScrollTest {
scrollToNewestAfterItemsMeasured(
expectedItemCount = 4,
expectedSingleMessageKey = null,
measuredLayouts = measuredLayouts
measuredLayouts = measuredLayouts,
) {
scrollCount += 1
}
@ -149,28 +182,31 @@ class ChatAutoScrollTest {
@Test
fun `first message waits past stale empty placeholder layout`() = runTest {
val messageKey = "first-message"
val measuredLayouts = MutableStateFlow(
MeasuredChatLayout(itemCount = 1, singleVisibleItemKey = "empty-placeholder")
)
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
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
)
measuredLayouts.value =
MeasuredChatLayout(
itemCount = 1,
singleVisibleItemKey = messageKey,
)
scrollJob.join()
assertEquals(1, scrollCount)

View File

@ -11,12 +11,16 @@ class WearDisplayGeometryTest {
fun `header band corners stay inside the circle at supported text sizes`() {
for (diameter in listOf(192f, 228f, 240f)) {
for (scale in listOf(0.94f, 1f, 1.24f, 1.3f)) {
val lineHeight = 15f * 1.3f * scale
val top = 12f + (maxOf(48f, lineHeight) - lineHeight) / 2f
val width = roundBandWidth(diameter, diameter, top, top + lineHeight)
val radius = diameter / 2f
for (y in listOf(top, top + lineHeight)) {
assertTrue((width / 2).pow(2) + (y - radius).pow(2) <= radius.pow(2) + 0.01f)
for (titleSize in listOf(12f, 13f, 14f)) {
val lineHeight = titleSize * 1.3f * scale
val top = (maxOf(48f, lineHeight) - lineHeight) / 2f
val width = roundBandWidth(diameter, diameter, top, top + lineHeight)
val radius = diameter / 2f
for (y in listOf(top, top + lineHeight)) {
assertTrue(
(width / 2).pow(2) + (y - radius).pow(2) <= radius.pow(2) + 0.01f
)
}
}
}
}