multiplatform: don't cancel command click when chat list shifts under the pointer

A press is cancelled when it goes out of the node's bounds, but when a sent
message inserts into the chat the node moves out from under a stationary
pointer, which is not a drag-away. Exempt out-of-bounds cancellation when the
pointer did not move in window coordinates (within touch slop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Narasimha-sc
2026-07-16 13:05:53 +00:00
co-authored by Claude Fable 5
parent 15d2547713
commit b2a3edbc84
2 changed files with 21 additions and 5 deletions
@@ -11,8 +11,10 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.AnnotatedString.Range
@@ -411,6 +413,8 @@ fun ClickableText(
val currentOnHover = rememberUpdatedState(onHover)
val currentOnHoverExit = rememberUpdatedState(onHoverExit)
val currentShouldConsumeEvent = rememberUpdatedState(shouldConsumeEvent)
// to tell a moved pointer from text that shifted under a stationary pointer (see waitForUpOrCancellation)
val textCoordinates = remember { mutableStateOf<LayoutCoordinates?>(null) }
val pressIndicator = Modifier.pointerInput(Unit) {
detectGesture(onLongPress = { pos ->
layoutResult.value?.let { layoutResult ->
@@ -423,7 +427,7 @@ fun ClickableText(
currentOnClick.value(layoutResult.getOffsetForPosition(pos))
}
}
}, shouldConsumeEvent = { pos ->
}, positionInWindow = { textCoordinates.value?.positionInWindow() ?: Offset.Zero }, shouldConsumeEvent = { pos ->
var consume = false
layoutResult.value?.let { layoutResult ->
consume = currentShouldConsumeEvent.value(layoutResult.getOffsetForPosition(pos))
@@ -443,7 +447,8 @@ fun ClickableText(
BasicText(
text = text,
modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator),
modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator)
.onGloballyPositioned { textCoordinates.value = it },
style = style,
softWrap = softWrap,
overflow = overflow,
@@ -46,6 +46,7 @@ private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { }
suspend fun PointerInputScope.detectGesture(
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
positionInWindow: (() -> Offset)? = null,
shouldConsumeEvent: (Offset) -> Boolean
) = coroutineScope {
val pressScope = PressGestureScopeImpl(this@detectGesture)
@@ -57,6 +58,7 @@ suspend fun PointerInputScope.detectGesture(
val shouldConsume = shouldConsumeEvent(down.position)
if (shouldConsume)
down.consumeDownChange()
val downPositionInWindow = positionInWindow?.let { it() + down.position }
// reset() suspends until the previous gesture's press handler released the mutex,
// and release/cancel join resetJob, so flag writes can't be reordered across gestures
// (a fast second click would otherwise drop the first click's onClick).
@@ -72,7 +74,7 @@ suspend fun PointerInputScope.detectGesture(
try {
val upOrCancel: PointerInputChange? = withTimeout(longPressTimeout) {
waitForUpOrCancellation()
waitForUpOrCancellation(downPositionInWindow, positionInWindow)
}
if (upOrCancel == null) {
launch { resetJob.join(); pressScope.cancel() }
@@ -143,7 +145,16 @@ internal suspend fun AwaitPointerEventScope.awaitFirstDownOnPass(
return event.changes[0]
}
suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange? {
suspend fun AwaitPointerEventScope.waitForUpOrCancellation(
downPositionInWindow: Offset? = null,
positionInWindow: (() -> Offset)? = null
): PointerInputChange? {
// out of bounds in local coordinates while stationary in window coordinates is the node
// moving under the pointer (chat list shifted after a sent message), not the pointer
// leaving the node — such a press stays valid
fun stationaryInWindow(change: PointerInputChange): Boolean =
downPositionInWindow != null && positionInWindow != null &&
(positionInWindow() + change.position - downPositionInWindow).getDistance() <= viewConfiguration.touchSlop
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
if (event.changes.all { it.changedToUp() }) {
@@ -151,7 +162,7 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange
}
if (event.changes.any {
it.consumed.downChange || it.isOutOfBounds(size, extendedTouchPadding)
it.consumed.downChange || (it.isOutOfBounds(size, extendedTouchPadding) && !stationaryInWindow(it))
}
) {
return null