mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
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:
parent
80342a74e8
commit
92f60bd02f
@ -1,14 +1,13 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@ -41,9 +40,16 @@ import com.bitchat.watch.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||
|
||||
/**
|
||||
* The shared chat body for global chat and DM threads: sticky collapsing header (via [header]),
|
||||
* TransformingLazyColumn message list (native Wear center-scaling/fade, rotary, scrollbar),
|
||||
* floating scroll-aware action bar, and the push-to-talk overlay.
|
||||
* 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
|
||||
* 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
|
||||
fun ChatScaffold(
|
||||
@ -52,7 +58,7 @@ fun ChatScaffold(
|
||||
emptyText: String,
|
||||
voice: VoiceNoteController,
|
||||
onOpenImage: (String) -> Unit,
|
||||
header: @Composable (expanded: Boolean) -> Unit,
|
||||
header: @Composable () -> Unit,
|
||||
actionBar: @Composable () -> Unit
|
||||
) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
@ -70,78 +76,52 @@ fun ChatScaffold(
|
||||
previousCount = messages.size
|
||||
}
|
||||
|
||||
// "Docked at newest" is the single source of truth for the chat's resting state:
|
||||
// bottom clearance expanded (newest message sits above the floating buttons), action
|
||||
// bar visible, header at full size. It is driven by scroll intent, not layout geometry.
|
||||
var dockedAtNewest by remember { mutableStateOf(true) }
|
||||
// Action bar: ALWAYS visible while the list is scrolled to the bottom; hides quickly
|
||||
// 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()
|
||||
}
|
||||
// One state drives both overlays: visible at the bottom or when scrolling toward it,
|
||||
// hidden when scrolling up into history. The 24px (~12dp) threshold is deliberately
|
||||
// small so the controls answer every flick immediately.
|
||||
var atNewest by remember { mutableStateOf(true) }
|
||||
val controlsVisible = remember { mutableStateOf(true) }
|
||||
LaunchedEffect(columnState) {
|
||||
var lastPosition = -1
|
||||
var bottomPosition = 0
|
||||
snapshotFlow {
|
||||
val first = columnState.layoutInfo.visibleItems.firstOrNull()
|
||||
Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0)
|
||||
}.collect { (canScrollForward, index, offset) ->
|
||||
val atBottom = !canScrollForward
|
||||
val position = index * 100_000 + offset
|
||||
if (atBottom) {
|
||||
bottomPosition = position
|
||||
dockedAtNewest = true
|
||||
buttonsVisible.value = true
|
||||
atNewest = !canScrollForward
|
||||
if (!canScrollForward) {
|
||||
controlsVisible.value = true
|
||||
} else if (lastPosition >= 0) {
|
||||
when {
|
||||
position > lastPosition + 24 -> buttonsVisible.value = true
|
||||
position < lastPosition - 24 -> buttonsVisible.value = false
|
||||
position > lastPosition + 24 -> controlsVisible.value = true
|
||||
position < lastPosition - 24 -> controlsVisible.value = false
|
||||
}
|
||||
if (bottomPosition - position > undockThresholdPx) dockedAtNewest = false
|
||||
}
|
||||
lastPosition = position
|
||||
}
|
||||
}
|
||||
|
||||
// Stick to bottom: on new messages, follow to the last item while the user is docked at
|
||||
// the newest, and re-align whenever the bottom clearance expands (padding growth changes
|
||||
// the scroll range). Gating on dockedAtNewest (maintained by the layout collector above)
|
||||
// avoids the stale-layoutInfo race of computing the distance here directly.
|
||||
LaunchedEffect(columnState, messages.size, dockedAtNewest) {
|
||||
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.
|
||||
// Stick to bottom: follow new messages while resting at the newest.
|
||||
LaunchedEffect(columnState, messages.size) {
|
||||
if (messages.isNotEmpty() && atNewest) {
|
||||
// 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) }
|
||||
}
|
||||
}
|
||||
val listBottomPadding by animateDpAsState(
|
||||
targetValue = if (dockedAtNewest) 56.dp else 8.dp,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS),
|
||||
label = "listBottomPad"
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
header(dockedAtNewest)
|
||||
ChatBody(
|
||||
messages = messages,
|
||||
myPeerID = myPeerID,
|
||||
emptyText = emptyText,
|
||||
voice = voice,
|
||||
onOpenImage = onOpenImage,
|
||||
columnState = columnState,
|
||||
listBottomPadding = listBottomPadding,
|
||||
buttonsVisible = buttonsVisible.value,
|
||||
actionBar = actionBar,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
ChatBody(
|
||||
messages = messages,
|
||||
myPeerID = myPeerID,
|
||||
emptyText = emptyText,
|
||||
voice = voice,
|
||||
onOpenImage = onOpenImage,
|
||||
columnState = columnState,
|
||||
controlsVisible = controlsVisible.value,
|
||||
header = header,
|
||||
actionBar = actionBar,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -152,8 +132,8 @@ private fun ChatBody(
|
||||
voice: VoiceNoteController,
|
||||
onOpenImage: (String) -> Unit,
|
||||
columnState: TransformingLazyColumnState,
|
||||
listBottomPadding: androidx.compose.ui.unit.Dp,
|
||||
buttonsVisible: Boolean,
|
||||
controlsVisible: Boolean,
|
||||
header: @Composable () -> Unit,
|
||||
actionBar: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@ -166,8 +146,10 @@ private fun ChatBody(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// Arrangement.Bottom anchors short content to the bottom: the first message
|
||||
// starts just above the action bar and new messages push history upward.
|
||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Bottom,
|
||||
contentPadding = PaddingValues(bottom = listBottomPadding)
|
||||
// The padding reserves permanent room for the floating header and action
|
||||
// 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()) {
|
||||
item {
|
||||
@ -200,7 +182,22 @@ private fun ChatBody(
|
||||
}
|
||||
|
||||
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),
|
||||
enter = slideInVertically(
|
||||
initialOffsetY = { it },
|
||||
|
||||
@ -75,11 +75,10 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
|
||||
emptyText = "no messages yet\nsay hi to the mesh",
|
||||
voice = voice,
|
||||
onOpenImage = { viewerPath = it },
|
||||
header = { expanded ->
|
||||
header = {
|
||||
ChatHeader(
|
||||
peerCount = peers.size,
|
||||
unreadDms = unreadDms.values.sum(),
|
||||
expanded = expanded,
|
||||
onOpenPeople = onOpenPeople
|
||||
)
|
||||
},
|
||||
@ -97,29 +96,16 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) {
|
||||
private fun ChatHeader(
|
||||
peerCount: Int,
|
||||
unreadDms: Int,
|
||||
expanded: Boolean,
|
||||
onOpenPeople: () -> Unit
|
||||
) {
|
||||
// Collapsing header: full-size title and icons while docked at the newest messages (the
|
||||
// chat's resting state); scales down smoothly as the user scrolls into history so the
|
||||
// conversation gets maximum room.
|
||||
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"
|
||||
)
|
||||
// Floating title row; the scaffold slides/fades it as a unit when the user scrolls.
|
||||
val iconSize = 16.dp
|
||||
val titleSize = 15.dp
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = vPadding),
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
@ -78,12 +78,11 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
|
||||
else "setting up encryption…",
|
||||
voice = voice,
|
||||
onOpenImage = { viewerPath = it },
|
||||
header = { expanded ->
|
||||
header = {
|
||||
DmHeader(
|
||||
nickname = nickname,
|
||||
peerID = peerID,
|
||||
sessionEstablished = sessionEstablished,
|
||||
expanded = expanded
|
||||
sessionEstablished = sessionEstablished
|
||||
)
|
||||
},
|
||||
actionBar = {
|
||||
@ -100,30 +99,17 @@ fun DmScreen(peerID: String, onOpenTextInput: () -> Unit) {
|
||||
private fun DmHeader(
|
||||
nickname: String,
|
||||
peerID: String,
|
||||
sessionEstablished: Boolean,
|
||||
expanded: Boolean
|
||||
sessionEstablished: Boolean
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
val headerIconSize by androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 16.dp else 11.dp,
|
||||
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
|
||||
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"
|
||||
)
|
||||
// Floating title row; the scaffold slides/fades it as a unit when the user scrolls.
|
||||
val headerIconSize = 16.dp
|
||||
val headerTitleSize = 15.dp
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = headerVPadding),
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user