mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-29 07:16:08 +00:00
Fix Wear chat autoscroll and focal scaling
This commit is contained in:
parent
34f94882f5
commit
ed617f0f1b
@ -22,10 +22,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
@ -87,35 +88,41 @@ fun ChatScaffold(
|
||||
previousCount = messages.size
|
||||
}
|
||||
|
||||
// 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) }
|
||||
// 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) }
|
||||
LaunchedEffect(columnState) {
|
||||
var lastPosition = -1
|
||||
snapshotFlow {
|
||||
val first = columnState.layoutInfo.visibleItems.firstOrNull()
|
||||
Triple(columnState.canScrollForward, first?.index ?: 0, first?.offset ?: 0)
|
||||
}.collect { (canScrollForward, index, offset) ->
|
||||
val position = index * 100_000 + offset
|
||||
atNewest = !canScrollForward
|
||||
if (!canScrollForward) {
|
||||
ChatScrollSnapshot(
|
||||
canScrollForward = columnState.canScrollForward,
|
||||
isScrollInProgress = columnState.isScrollInProgress,
|
||||
position = (first?.index ?: 0) * 100_000 + (first?.offset ?: 0)
|
||||
)
|
||||
}.collect { snapshot ->
|
||||
followNewest = updatedFollowNewest(
|
||||
current = followNewest,
|
||||
snapshot = snapshot,
|
||||
previousPosition = lastPosition
|
||||
)
|
||||
if (!snapshot.canScrollForward) {
|
||||
controlsVisible.value = true
|
||||
} else if (lastPosition >= 0) {
|
||||
when {
|
||||
position > lastPosition + 24 -> controlsVisible.value = true
|
||||
position < lastPosition - 24 -> controlsVisible.value = false
|
||||
snapshot.position > lastPosition + CHAT_SCROLL_DIRECTION_THRESHOLD_PX ->
|
||||
controlsVisible.value = true
|
||||
snapshot.position < lastPosition - CHAT_SCROLL_DIRECTION_THRESHOLD_PX ->
|
||||
controlsVisible.value = false
|
||||
}
|
||||
}
|
||||
lastPosition = position
|
||||
lastPosition = snapshot.position
|
||||
}
|
||||
}
|
||||
|
||||
// Stick to bottom: follow new messages while resting at the newest.
|
||||
// Capture this when the message count changes, before the new layout can temporarily make
|
||||
// canScrollForward true and report that the user is browsing history.
|
||||
val followNewest = remember(messages.size) { atNewest }
|
||||
// Stick to bottom when the user has not intentionally moved into history.
|
||||
LaunchedEffect(columnState, messages.size) {
|
||||
if (messages.isNotEmpty() && followNewest) {
|
||||
val expectedSingleMessageKey = messages.singleOrNull()?.id
|
||||
@ -160,6 +167,24 @@ internal data class MeasuredChatLayout(
|
||||
val singleVisibleItemKey: Any?
|
||||
)
|
||||
|
||||
internal data class ChatScrollSnapshot(
|
||||
val canScrollForward: Boolean,
|
||||
val isScrollInProgress: Boolean,
|
||||
val position: Int
|
||||
)
|
||||
|
||||
internal fun updatedFollowNewest(
|
||||
current: Boolean,
|
||||
snapshot: ChatScrollSnapshot,
|
||||
previousPosition: Int
|
||||
): Boolean = when {
|
||||
!snapshot.canScrollForward -> true
|
||||
snapshot.isScrollInProgress &&
|
||||
previousPosition >= 0 &&
|
||||
snapshot.position < previousPosition - CHAT_SCROLL_DIRECTION_THRESHOLD_PX -> false
|
||||
else -> current
|
||||
}
|
||||
|
||||
internal suspend fun scrollToNewestAfterItemsMeasured(
|
||||
expectedItemCount: Int,
|
||||
expectedSingleMessageKey: Any?,
|
||||
@ -268,18 +293,30 @@ private fun ChatBody(
|
||||
state = columnState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 30.dp, bottom = 64.dp)
|
||||
.clipToBounds(),
|
||||
// Keep full-screen measurement so Wear's transformation focal point and
|
||||
// scroll range stay correct. Only drawing is clipped around the overlays.
|
||||
.drawWithContent {
|
||||
clipRect(
|
||||
top = headerBackdropHeight.toPx(),
|
||||
bottom = size.height - CHAT_ACTION_BAR_CLEARANCE.toPx()
|
||||
) {
|
||||
this@drawWithContent.drawContent()
|
||||
}
|
||||
},
|
||||
// Arrangement.Bottom anchors short content to the bottom: the first message
|
||||
// starts just above the action bar and new messages push history upward.
|
||||
// The viewport itself stays between the floating header and action bar, so
|
||||
// rows cannot scroll underneath either control. Its geometry is constant,
|
||||
// so showing or hiding the overlays never disturbs an in-flight gesture.
|
||||
// content stays clear of both controls while the full-screen list geometry keeps
|
||||
// autoscroll and the native transformation focal point intact.
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
// Wear Material already supplies a responsive 5.2% horizontal inset. Keep it,
|
||||
// but omit the scaffold's vertical inset because the viewport itself reserves
|
||||
// the header and action-bar space. Stacking both made chat narrow and too high.
|
||||
contentPadding = scaffoldPadding.horizontalOnly(layoutDirection)
|
||||
// while replacing its vertical inset with the overlay clearances used before the
|
||||
// shape fix. This avoids both duplicated padding and a shortened list viewport.
|
||||
contentPadding = scaffoldPadding.withVerticalClearance(
|
||||
layoutDirection = layoutDirection,
|
||||
top = CHAT_HEADER_CONTENT_CLEARANCE,
|
||||
bottom = CHAT_ACTION_BAR_CLEARANCE
|
||||
)
|
||||
) {
|
||||
if (messages.isEmpty()) {
|
||||
item {
|
||||
@ -356,6 +393,9 @@ 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 = 30.dp
|
||||
private val CHAT_ACTION_BAR_CLEARANCE = 64.dp
|
||||
// Magnetic zone geometry (px at watch density): the button starts reacting at
|
||||
// MAGNET_OUTER_PX from its center and fully blushes at MAGNET_INNER_PX (~the activation
|
||||
// boundary); it leans toward the finger by up to MAGNET_PULL_PX.
|
||||
|
||||
@ -31,12 +31,14 @@ internal fun PaddingValues.withAdditionalPadding(
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the responsive horizontal inset supplied by Wear Material without also reserving its
|
||||
* vertical list padding. Chat owns its fixed header and action-bar clearances, so stacking the
|
||||
* scaffold's vertical padding on top would unnecessarily shorten the message viewport.
|
||||
* Keep the responsive horizontal inset supplied by Wear Material while replacing its vertical
|
||||
* padding with screen-owned clearances. This keeps the lazy list full-screen for correct Wear
|
||||
* transformation and scroll calculations without stacking duplicate vertical insets.
|
||||
*/
|
||||
internal fun PaddingValues.horizontalOnly(
|
||||
layoutDirection: LayoutDirection
|
||||
internal fun PaddingValues.withVerticalClearance(
|
||||
layoutDirection: LayoutDirection,
|
||||
top: Dp,
|
||||
bottom: Dp
|
||||
): PaddingValues {
|
||||
val start = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> calculateLeftPadding(layoutDirection)
|
||||
@ -46,5 +48,5 @@ internal fun PaddingValues.horizontalOnly(
|
||||
LayoutDirection.Ltr -> calculateRightPadding(layoutDirection)
|
||||
LayoutDirection.Rtl -> calculateLeftPadding(layoutDirection)
|
||||
}
|
||||
return PaddingValues(start = start, end = end)
|
||||
return PaddingValues(start = start, top = top, end = end, bottom = bottom)
|
||||
}
|
||||
|
||||
@ -10,6 +10,46 @@ import org.junit.Test
|
||||
|
||||
class ChatAutoScrollTest {
|
||||
|
||||
@Test
|
||||
fun `new scroll range from appended message keeps follow intent`() {
|
||||
val updated = updatedFollowNewest(
|
||||
current = true,
|
||||
snapshot = ChatScrollSnapshot(
|
||||
canScrollForward = true,
|
||||
isScrollInProgress = false,
|
||||
position = 100
|
||||
),
|
||||
previousPosition = 100
|
||||
)
|
||||
|
||||
assertEquals(true, updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user scroll away disables follow until list reaches newest again`() {
|
||||
val browsingHistory = updatedFollowNewest(
|
||||
current = true,
|
||||
snapshot = ChatScrollSnapshot(
|
||||
canScrollForward = true,
|
||||
isScrollInProgress = true,
|
||||
position = 60
|
||||
),
|
||||
previousPosition = 100
|
||||
)
|
||||
assertFalse(browsingHistory)
|
||||
|
||||
val dockedAgain = updatedFollowNewest(
|
||||
current = browsingHistory,
|
||||
snapshot = ChatScrollSnapshot(
|
||||
canScrollForward = false,
|
||||
isScrollInProgress = false,
|
||||
position = 200
|
||||
),
|
||||
previousPosition = 60
|
||||
)
|
||||
assertEquals(true, dockedAgain)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newest scroll waits until appended message is measured`() = runTest {
|
||||
val measuredLayouts = MutableStateFlow(MeasuredChatLayout(3, null))
|
||||
|
||||
@ -24,13 +24,17 @@ class WearContentPaddingTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `horizontal only padding preserves width inset and restores vertical space`() {
|
||||
fun `vertical clearance preserves responsive width and replaces scaffold height inset`() {
|
||||
val resolved = PaddingValues(start = 10.dp, top = 20.dp, end = 12.dp, bottom = 20.dp)
|
||||
.horizontalOnly(LayoutDirection.Ltr)
|
||||
.withVerticalClearance(
|
||||
layoutDirection = LayoutDirection.Ltr,
|
||||
top = 30.dp,
|
||||
bottom = 64.dp
|
||||
)
|
||||
|
||||
assertEquals(10.dp, resolved.calculateLeftPadding(LayoutDirection.Ltr))
|
||||
assertEquals(12.dp, resolved.calculateRightPadding(LayoutDirection.Ltr))
|
||||
assertEquals(0.dp, resolved.calculateTopPadding())
|
||||
assertEquals(0.dp, resolved.calculateBottomPadding())
|
||||
assertEquals(30.dp, resolved.calculateTopPadding())
|
||||
assertEquals(64.dp, resolved.calculateBottomPadding())
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user