multiplatform: fix command clicks lost on quick successive clicks (serialize gesture press scope, don't restart pointerInput on recomposition)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Narasimha-sc
2026-07-16 08:41:58 +00:00
co-authored by Claude Fable 5
parent bc0c6b80bc
commit 6cf93ddfaa
2 changed files with 32 additions and 16 deletions
@@ -403,31 +403,39 @@ fun ClickableText(
shouldConsumeEvent: (Int) -> Boolean
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick, onLongClick) {
// pointerInput keyed on these lambdas restarts on every recomposition (they are new
// instances each time), and a restart mid-gesture swallows the click/hover in flight;
// key on Unit and read the latest handlers via rememberUpdatedState instead
val currentOnClick = rememberUpdatedState(onClick)
val currentOnLongClick = rememberUpdatedState(onLongClick)
val currentOnHover = rememberUpdatedState(onHover)
val currentOnHoverExit = rememberUpdatedState(onHoverExit)
val currentShouldConsumeEvent = rememberUpdatedState(shouldConsumeEvent)
val pressIndicator = Modifier.pointerInput(Unit) {
detectGesture(onLongPress = { pos ->
layoutResult.value?.let { layoutResult ->
onLongClick(layoutResult.getOffsetForPosition(pos))
currentOnLongClick.value(layoutResult.getOffsetForPosition(pos))
}
}, onPress = { pos ->
layoutResult.value?.let { layoutResult ->
val res = tryAwaitRelease()
if (res) {
onClick(layoutResult.getOffsetForPosition(pos))
currentOnClick.value(layoutResult.getOffsetForPosition(pos))
}
}
}, shouldConsumeEvent = { pos ->
var consume = false
layoutResult.value?.let { layoutResult ->
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
consume = currentShouldConsumeEvent.value(layoutResult.getOffsetForPosition(pos))
}
consume
}
)
}.pointerInput(onHover, onHoverExit) {
}.pointerInput(Unit) {
if (appPlatform.isDesktop) {
detectCursorMove(onExit = onHoverExit) { pos ->
detectCursorMove(onExit = { currentOnHoverExit.value() }) { pos ->
layoutResult.value?.let { layoutResult ->
onHover(layoutResult.getOffsetForPosition(pos))
currentOnHover.value(layoutResult.getOffsetForPosition(pos))
}
}
}
@@ -57,8 +57,13 @@ suspend fun PointerInputScope.detectGesture(
val shouldConsume = shouldConsumeEvent(down.position)
if (shouldConsume)
down.consumeDownChange()
pressScope.reset()
// 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).
// Mirrors detectTapGestures in Compose 1.8.2.
val resetJob = launch { pressScope.reset() }
if (onPress !== NoPressGesture) launch {
resetJob.join()
pressScope.onPress(down.position)
}
val longPressTimeout = onLongPress?.let {
@@ -70,22 +75,22 @@ suspend fun PointerInputScope.detectGesture(
waitForUpOrCancellation()
}
if (upOrCancel == null) {
pressScope.cancel()
launch { resetJob.join(); pressScope.cancel() }
} else {
if (shouldConsume)
upOrCancel.consumeDownChange()
pressScope.release()
launch { resetJob.join(); pressScope.release() }
}
} catch (_: PointerEventTimeoutCancellationException) {
if (onLongPress != null) {
onLongPress(down.position)
if (shouldConsume)
consumeUntilUp()
pressScope.cancel()
launch { resetJob.join(); pressScope.cancel() }
} else {
if (shouldConsume)
consumeUntilUp()
pressScope.release()
launch { resetJob.join(); pressScope.release() }
}
}
}
@@ -167,16 +172,18 @@ private class PressGestureScopeImpl(
fun cancel() {
isCanceled = true
mutex.unlock()
if (mutex.isLocked) mutex.unlock()
}
fun release() {
isReleased = true
mutex.unlock()
if (mutex.isLocked) mutex.unlock()
}
fun reset() {
mutex.tryLock()
// suspends until the previous gesture's tryAwaitRelease finished (or was never started):
// the mutex is the serialization token between consecutive gestures
suspend fun reset() {
mutex.lock()
isReleased = false
isCanceled = false
}
@@ -184,6 +191,7 @@ private class PressGestureScopeImpl(
override suspend fun tryAwaitRelease(): Boolean {
if (!isReleased && !isCanceled) {
mutex.lock()
mutex.unlock()
}
return isReleased && !isCanceled
}