mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +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
|
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 },
|
||||||
|
|||||||
@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user