wear: simplify chat scrolling to the classic messenger pattern - constant list padding with header/action bar as floating overlays driven by one scroll-direction state; removes the animated bottom clearance and in-layout header resizing that shifted content mid-gesture

This commit is contained in:
callebtc 2026-07-29 01:04:44 +02:00
parent 80342a74e8
commit 92f60bd02f
3 changed files with 73 additions and 104 deletions

View File

@ -1,14 +1,13 @@
package com.bitchat.watch.ui package com.bitchat.watch.ui
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -41,9 +40,16 @@ import com.bitchat.watch.ui.theme.ChatVisualTokens
import com.bitchat.watch.ui.theme.LocalBitchatPalette import com.bitchat.watch.ui.theme.LocalBitchatPalette
/** /**
* The shared chat body for global chat and DM threads: sticky collapsing header (via [header]), * The shared chat body for global chat and DM threads, following the classic messenger
* TransformingLazyColumn message list (native Wear center-scaling/fade, rotary, scrollbar), * pattern: a TransformingLazyColumn message list (native Wear center-scaling/fade, rotary,
* floating scroll-aware action bar, and the push-to-talk overlay. * 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
* they are always visible.
*
* The list's contentPadding is CONSTANT and both overlays are layout-neutral, so showing or
* hiding them never changes the scroll geometry. Earlier revisions animated the bottom
* clearance and resized the header in the layout path, which shifted content under the
* user's finger mid-gesture (felt as "resistance") and fed back into the dock detection.
*/ */
@Composable @Composable
fun ChatScaffold( fun ChatScaffold(
@ -52,7 +58,7 @@ fun ChatScaffold(
emptyText: String, emptyText: String,
voice: VoiceNoteController, voice: VoiceNoteController,
onOpenImage: (String) -> Unit, onOpenImage: (String) -> Unit,
header: @Composable (expanded: Boolean) -> Unit, header: @Composable () -> Unit,
actionBar: @Composable () -> Unit actionBar: @Composable () -> Unit
) { ) {
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
@ -70,78 +76,52 @@ fun ChatScaffold(
previousCount = messages.size previousCount = messages.size
} }
// "Docked at newest" is the single source of truth for the chat's resting state: // One state drives both overlays: visible at the bottom or when scrolling toward it,
// bottom clearance expanded (newest message sits above the floating buttons), action // hidden when scrolling up into history. The 24px (~12dp) threshold is deliberately
// bar visible, header at full size. It is driven by scroll intent, not layout geometry. // small so the controls answer every flick immediately.
var dockedAtNewest by remember { mutableStateOf(true) } var atNewest by remember { mutableStateOf(true) }
// Action bar: ALWAYS visible while the list is scrolled to the bottom; hides quickly val controlsVisible = remember { mutableStateOf(true) }
// when scrolling up into history and reappears as soon as the user scrolls back down,
// so replying is one short flick away even from the very top. The bar is an overlay,
// so its fast 12dp threshold cannot feed back into the scroll geometry.
val buttonsVisible = remember { mutableStateOf(true) }
// The docked state, by contrast, changes the geometry: collapsing the bottom clearance
// shrinks the scroll range by the 48dp padding delta. Undocking near the bottom would
// clamp the scroll position back to the end and instantly re-dock — the hide/show
// flapping loop. So undocking requires scrolling well clear of the bottom (60dp > 48dp).
val undockThresholdPx = with(androidx.compose.ui.platform.LocalDensity.current) {
60.dp.roundToPx()
}
LaunchedEffect(columnState) { LaunchedEffect(columnState) {
var lastPosition = -1 var lastPosition = -1
var bottomPosition = 0
snapshotFlow { snapshotFlow {
val first = columnState.layoutInfo.visibleItems.firstOrNull() val first = columnState.layoutInfo.visibleItems.firstOrNull()
Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0) Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0)
}.collect { (canScrollForward, index, offset) -> }.collect { (canScrollForward, index, offset) ->
val atBottom = !canScrollForward
val position = index * 100_000 + offset val position = index * 100_000 + offset
if (atBottom) { atNewest = !canScrollForward
bottomPosition = position if (!canScrollForward) {
dockedAtNewest = true controlsVisible.value = true
buttonsVisible.value = true
} else if (lastPosition >= 0) { } else if (lastPosition >= 0) {
when { when {
position > lastPosition + 24 -> buttonsVisible.value = true position > lastPosition + 24 -> controlsVisible.value = true
position < lastPosition - 24 -> buttonsVisible.value = false position < lastPosition - 24 -> controlsVisible.value = false
} }
if (bottomPosition - position > undockThresholdPx) dockedAtNewest = false
} }
lastPosition = position lastPosition = position
} }
} }
// Stick to bottom: on new messages, follow to the last item while the user is docked at // Stick to bottom: follow new messages while resting at the newest.
// the newest, and re-align whenever the bottom clearance expands (padding growth changes LaunchedEffect(columnState, messages.size) {
// the scroll range). Gating on dockedAtNewest (maintained by the layout collector above) if (messages.isNotEmpty() && atNewest) {
// avoids the stale-layoutInfo race of computing the distance here directly. // scrollBy to the end of the range: animateScrollToItem stops as soon as the
LaunchedEffect(columnState, messages.size, dockedAtNewest) { // item is partially visible, which left the last message cropped.
if (messages.isNotEmpty() && dockedAtNewest) {
// scrollBy to the end of the range: animateScrollToItem stops as soon as the item
// is partially visible, which left the last message cropped behind the buttons.
columnState.scroll { scrollBy(Float.MAX_VALUE) } columnState.scroll { scrollBy(Float.MAX_VALUE) }
} }
} }
val listBottomPadding by animateDpAsState(
targetValue = if (dockedAtNewest) 56.dp else 8.dp,
animationSpec = tween(BitchatMotion.STANDARD_MS),
label = "listBottomPad"
)
Column(modifier = Modifier.fillMaxSize()) { ChatBody(
header(dockedAtNewest) messages = messages,
ChatBody( myPeerID = myPeerID,
messages = messages, emptyText = emptyText,
myPeerID = myPeerID, voice = voice,
emptyText = emptyText, onOpenImage = onOpenImage,
voice = voice, columnState = columnState,
onOpenImage = onOpenImage, controlsVisible = controlsVisible.value,
columnState = columnState, header = header,
listBottomPadding = listBottomPadding, actionBar = actionBar,
buttonsVisible = buttonsVisible.value, modifier = Modifier.fillMaxSize()
actionBar = actionBar, )
modifier = Modifier.weight(1f)
)
}
} }
@Composable @Composable
@ -152,8 +132,8 @@ private fun ChatBody(
voice: VoiceNoteController, voice: VoiceNoteController,
onOpenImage: (String) -> Unit, onOpenImage: (String) -> Unit,
columnState: TransformingLazyColumnState, columnState: TransformingLazyColumnState,
listBottomPadding: androidx.compose.ui.unit.Dp, controlsVisible: Boolean,
buttonsVisible: Boolean, header: @Composable () -> Unit,
actionBar: @Composable () -> Unit, actionBar: @Composable () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
@ -166,8 +146,10 @@ private fun ChatBody(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
// Arrangement.Bottom anchors short content to the bottom: the first message // Arrangement.Bottom anchors short content to the bottom: the first message
// starts just above the action bar and new messages push history upward. // starts just above the action bar and new messages push history upward.
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Bottom, // The padding reserves permanent room for the floating header and action
contentPadding = PaddingValues(bottom = listBottomPadding) // bar; being constant, it never disturbs an in-flight scroll gesture.
verticalArrangement = Arrangement.Bottom,
contentPadding = PaddingValues(top = 30.dp, bottom = 64.dp)
) { ) {
if (messages.isEmpty()) { if (messages.isEmpty()) {
item { item {
@ -200,7 +182,22 @@ private fun ChatBody(
} }
AnimatedVisibility( AnimatedVisibility(
visible = buttonsVisible, visible = controlsVisible,
modifier = Modifier.align(Alignment.TopCenter),
enter = slideInVertically(
initialOffsetY = { -it },
animationSpec = tween(BitchatMotion.STANDARD_MS)
) + fadeIn(animationSpec = tween(BitchatMotion.STANDARD_MS)),
exit = slideOutVertically(
targetOffsetY = { -it },
animationSpec = tween(BitchatMotion.STANDARD_MS)
) + fadeOut(animationSpec = tween(BitchatMotion.STANDARD_MS))
) {
header()
}
AnimatedVisibility(
visible = controlsVisible,
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
enter = slideInVertically( enter = slideInVertically(
initialOffsetY = { it }, initialOffsetY = { it },

View File

@ -75,11 +75,10 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
emptyText = "no messages yet\nsay hi to the mesh", emptyText = "no messages yet\nsay hi to the mesh",
voice = voice, voice = voice,
onOpenImage = { viewerPath = it }, onOpenImage = { viewerPath = it },
header = { expanded -> header = {
ChatHeader( ChatHeader(
peerCount = peers.size, peerCount = peers.size,
unreadDms = unreadDms.values.sum(), unreadDms = unreadDms.values.sum(),
expanded = expanded,
onOpenPeople = onOpenPeople onOpenPeople = onOpenPeople
) )
}, },
@ -97,29 +96,16 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
private fun ChatHeader( private fun ChatHeader(
peerCount: Int, peerCount: Int,
unreadDms: Int, unreadDms: Int,
expanded: Boolean,
onOpenPeople: () -> Unit onOpenPeople: () -> Unit
) { ) {
// Collapsing header: full-size title and icons while docked at the newest messages (the // Floating title row; the scaffold slides/fades it as a unit when the user scrolls.
// chat's resting state); scales down smoothly as the user scrolls into history so the val iconSize = 16.dp
// conversation gets maximum room. val titleSize = 15.dp
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
BitchatMotion.STANDARD_MS
)
val iconSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "hdrIcon"
)
val titleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "hdrTitle"
)
val vPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "hdrPad"
)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = vPadding), .padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {

View File

@ -78,12 +78,11 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
else "setting up encryption…", else "setting up encryption…",
voice = voice, voice = voice,
onOpenImage = { viewerPath = it }, onOpenImage = { viewerPath = it },
header = { expanded -> header = {
DmHeader( DmHeader(
nickname = nickname, nickname = nickname,
peerID = peerID, peerID = peerID,
sessionEstablished = sessionEstablished, sessionEstablished = sessionEstablished
expanded = expanded
) )
}, },
actionBar = { actionBar = {
@ -100,30 +99,17 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
private fun DmHeader( private fun DmHeader(
nickname: String, nickname: String,
peerID: String, peerID: String,
sessionEstablished: Boolean, sessionEstablished: Boolean
expanded: Boolean
) { ) {
val palette = LocalBitchatPalette.current val palette = LocalBitchatPalette.current
val headerIconSize by androidx.compose.animation.core.animateDpAsState( // Floating title row; the scaffold slides/fades it as a unit when the user scrolls.
targetValue = if (expanded) 16.dp else 11.dp, val headerIconSize = 16.dp
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS), val headerTitleSize = 15.dp
label = "dmHdrIcon"
)
val headerTitleSize by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 15.dp else 11.dp,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrTitle"
)
val headerVPadding by androidx.compose.animation.core.animateDpAsState(
targetValue = if (expanded) 6.dp else 1.dp,
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
label = "dmHdrPad"
)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = headerVPadding), .padding(horizontal = 8.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {