From 4f88c738136915236b598eba4643bad04b98aa3d Mon Sep 17 00:00:00 2001 From: MohamedRejeb Date: Sun, 31 Dec 2023 20:33:34 +0100 Subject: [PATCH] Fix RichText intercepts click events --- .../richeditor/gesture/TapGestureDetector.kt | 102 ++++++++++++++++++ .../richeditor/ui/BasicRichText.kt | 7 +- .../mohamedrejeb/richeditor/utils/ListExt.kt | 19 ++-- .../sample/common/slack/SlackDemoContent.kt | 1 - 4 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/gesture/TapGestureDetector.kt diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/gesture/TapGestureDetector.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/gesture/TapGestureDetector.kt new file mode 100644 index 00000000..a82f127f --- /dev/null +++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/gesture/TapGestureDetector.kt @@ -0,0 +1,102 @@ +package com.mohamedrejeb.richeditor.gesture + +import androidx.compose.foundation.gestures.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.platform.ViewConfiguration +import com.mohamedrejeb.richeditor.utils.fastAny +import com.mohamedrejeb.richeditor.utils.fastForEach +import kotlinx.coroutines.coroutineScope + +/** + * Consumes all pointer events until nothing is pressed and then returns. This method assumes + * that something is currently pressed. + */ +private suspend fun AwaitPointerEventScope.consumeUntilUp() { + do { + val event = awaitPointerEvent() + event.changes.fastForEach { it.consume() } + } while (event.changes.fastAny { it.pressed }) +} + +/** + * Waits for [ViewConfiguration.doubleTapTimeoutMillis] for a second press event. If a + * second press event is received before the time out, it is returned or `null` is returned + * if no second press is received. + */ +private suspend fun AwaitPointerEventScope.awaitSecondDown( + firstUp: PointerInputChange +): PointerInputChange? = withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) { + val minUptime = firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis + var change: PointerInputChange + // The second tap doesn't count if it happens before DoubleTapMinTime of the first tap + do { + change = awaitFirstDown() + } while (change.uptimeMillis < minUptime) + change +} + +internal suspend fun PointerInputScope.detectTapGestures( + onDoubleTap: ((Offset) -> Unit)? = null, + onLongPress: ((Offset) -> Unit)? = null, + onTap: ((Offset) -> Unit)? = null, + consumeDown: (Offset) -> Boolean, +) = coroutineScope { + awaitEachGesture { + val down = awaitFirstDown() + if (!consumeDown(down.position)) return@awaitEachGesture + down.consume() + val longPressTimeout = onLongPress?.let { + viewConfiguration.longPressTimeoutMillis + } ?: (Long.MAX_VALUE / 2) + var upOrCancel: PointerInputChange? = null + try { + // wait for first tap up or long press + upOrCancel = withTimeout(longPressTimeout) { + waitForUpOrCancellation() + } + upOrCancel?.consume() + } catch (_: PointerEventTimeoutCancellationException) { + onLongPress?.invoke(down.position) + consumeUntilUp() + } + + if (upOrCancel != null) { + // tap was successful. + if (onDoubleTap == null) { + onTap?.invoke(upOrCancel.position) // no need to check for double-tap. + } else { + // check for second tap + val secondDown = awaitSecondDown(upOrCancel) + + if (secondDown == null) { + onTap?.invoke(upOrCancel.position) // no valid second tap started + } else { + try { + // Might have a long second press as the second tap + withTimeout(longPressTimeout) { + val secondUp = waitForUpOrCancellation() + if (secondUp != null) { + secondUp.consume() + onDoubleTap(secondUp.position) + } else { + onTap?.invoke(upOrCancel.position) + } + } + } catch (e: PointerEventTimeoutCancellationException) { + // The first tap was valid, but the second tap is a long press. + // notify for the first tap + onTap?.invoke(upOrCancel.position) + + // notify for the long press + onLongPress?.invoke(secondDown.position) + consumeUntilUp() + } + } + } + } + } +} \ No newline at end of file diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/ui/BasicRichText.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/ui/BasicRichText.kt index 7c8fe057..b65cdf91 100644 --- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/ui/BasicRichText.kt +++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/ui/BasicRichText.kt @@ -1,7 +1,6 @@ package com.mohamedrejeb.richeditor.ui import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable @@ -16,6 +15,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextOverflow +import com.mohamedrejeb.richeditor.gesture.detectTapGestures import com.mohamedrejeb.richeditor.model.RichTextState @Composable @@ -63,7 +63,10 @@ fun BasicRichText( e.printStackTrace() } } - } + }, + consumeDown = { offset -> + state.isLink(offset) + }, ) }, style = style, diff --git a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/ListExt.kt b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/ListExt.kt index 51b86d3c..d21cbfc6 100644 --- a/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/ListExt.kt +++ b/richeditor-compose/src/commonMain/kotlin/com/mohamedrejeb/richeditor/utils/ListExt.kt @@ -52,17 +52,18 @@ internal inline fun List.fastMap( } /** - * Copied from [androidx](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui-text/src/commonMain/kotlin/androidx/compose/ui/text/TempListUtils.kt;l=107;drc=ceaa7640c065146360515e598a3d09f6f66553dd). + * Returns `true` if at least one element matches the given [predicate]. + * + * **Do not use for collections that come from public APIs**, since they may not support random + * access in an efficient way, and this method may actually be a lot slower. Only use for + * collections that are created by code we control and are known to support random access. */ -@Suppress("BanInlineOptIn") // Treat Kotlin Contracts as non-experimental. +@Suppress("BanInlineOptIn") @OptIn(ExperimentalContracts::class) -internal inline fun List.fastFold(initial: R, operation: (acc: R, T) -> R): R { - contract { callsInPlace(operation) } - var accumulator = initial - fastForEach { e -> - accumulator = operation(accumulator, e) - } - return accumulator +internal inline fun List.fastAny(predicate: (T) -> Boolean): Boolean { + contract { callsInPlace(predicate) } + fastForEach { if (predicate(it)) return true } + return false } @OptIn(ExperimentalContracts::class) diff --git a/sample/common/src/commonMain/kotlin/com/mohamedrejeb/richeditor/sample/common/slack/SlackDemoContent.kt b/sample/common/src/commonMain/kotlin/com/mohamedrejeb/richeditor/sample/common/slack/SlackDemoContent.kt index 7517b6df..10cb4dda 100644 --- a/sample/common/src/commonMain/kotlin/com/mohamedrejeb/richeditor/sample/common/slack/SlackDemoContent.kt +++ b/sample/common/src/commonMain/kotlin/com/mohamedrejeb/richeditor/sample/common/slack/SlackDemoContent.kt @@ -72,7 +72,6 @@ fun SlackDemoContent() { topBar = { Column( modifier = Modifier - ) { TopAppBar( title = { Text("Slack Demo") },