diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index ecddafa9ab..3bef0d5259 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -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(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, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt index 706c716538..d55f894491 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt @@ -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