From 549fdaad5c8b4a76de5d6abc7f07532e0800b45d Mon Sep 17 00:00:00 2001 From: Doron Torangy Date: Thu, 23 Jan 2025 12:03:40 +0200 Subject: [PATCH] Preventing pointer event consumption in case parent passed clickable modifier and annotated string has urls that were not clicked --- .../compose/htmltext/example/MainActivity.kt | 26 +++++++- .../de/charlex/compose/material/HtmlText.kt | 61 ++++++++++++++++-- .../de/charlex/compose/material3/HtmlText.kt | 62 +++++++++++++++++-- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/example/src/main/java/de/charlex/compose/htmltext/example/MainActivity.kt b/example/src/main/java/de/charlex/compose/htmltext/example/MainActivity.kt index f6a4253..8429929 100644 --- a/example/src/main/java/de/charlex/compose/htmltext/example/MainActivity.kt +++ b/example/src/main/java/de/charlex/compose/htmltext/example/MainActivity.kt @@ -4,11 +4,17 @@ import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext @@ -45,7 +51,24 @@ fun Greeting() { @Composable fun StringGreeting() { - HtmlText(text = "Hello World. This textsentence is formatted in simple html. HtmlText") + HtmlText(text = "Hello World. This textsentence is formatted in simple html, . HtmlText") +} + +@Composable +fun ClickableContentWithLink() { + var isExpanded by remember { mutableStateOf(false) } + + Column { + HtmlText( + modifier = Modifier.clickable { + isExpanded = !isExpanded + }, + text = "Hello World. In case parent has a clickable modifier, it will be invoked (unless the annotated string has a clickable link). HtmlText" + ) + AnimatedVisibility(isExpanded) { + HtmlText(text = "I am expanded now") + } + } } @Composable @@ -95,6 +118,7 @@ fun DefaultPreview() { Column { Greeting() StringGreeting() + ClickableContentWithLink() } } } diff --git a/material-html-text/src/main/java/de/charlex/compose/material/HtmlText.kt b/material-html-text/src/main/java/de/charlex/compose/material/HtmlText.kt index 52f8c9f..5e221d8 100644 --- a/material-html-text/src/main/java/de/charlex/compose/material/HtmlText.kt +++ b/material-html-text/src/main/java/de/charlex/compose/material/HtmlText.kt @@ -10,6 +10,8 @@ import android.text.style.StyleSpan import android.text.style.URLSpan import android.text.style.UnderlineSpan import androidx.annotation.StringRes +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.text.InlineTextContent import androidx.compose.material.LocalTextStyle @@ -19,7 +21,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -41,7 +46,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach import androidx.core.text.getSpans +import kotlinx.coroutines.coroutineScope /** * Simple Text composable to show the text with html styling from string resources. @@ -233,19 +241,24 @@ fun HtmlText( Text( modifier = modifier.then(if (clickable) Modifier .pointerInput(Unit) { - detectTapGestures(onTap = { pos -> - layoutResult.value?.let { layoutResult -> + interceptTap(onTap = { pos -> + val shouldConsumeEvent = layoutResult.value?.let { layoutResult -> val position = layoutResult.getOffsetForPosition(pos) - annotatedString + return@let annotatedString .getStringAnnotations(position, position) .firstOrNull() ?.let { sa -> if (sa.tag == "url") { // NON-NLS val url = sa.item onUriClick?.let { it(url) } ?: uriHandler.openUri(url) + true + } else { + false } } - } + } ?: false + + return@interceptTap shouldConsumeEvent }) } .semantics { @@ -358,3 +371,43 @@ fun Spanned.toAnnotatedString( } } } + +typealias ShouldConsumePointerEvent = Boolean + +suspend fun PointerInputScope.interceptTap( + pass: PointerEventPass = PointerEventPass.Initial, + onTap: ((Offset) -> ShouldConsumePointerEvent)? = null, +) = coroutineScope { + if (onTap == null) return@coroutineScope + + awaitEachGesture { + val down = awaitFirstDown(pass = pass) + val downTime = System.currentTimeMillis() + val tapTimeout = viewConfiguration.longPressTimeoutMillis + val tapPosition = down.position + + do { + val event = awaitPointerEvent(pass) + val currentTime = System.currentTimeMillis() + + if (event.changes.size != 1) break // More than one event: not a tap + if (currentTime - downTime >= tapTimeout) break // Too slow: not a tap + + val change = event.changes[0] + + // Too much movement: not a tap + if ((change.position - tapPosition).getDistance() > viewConfiguration.touchSlop) break + + if (change.id == down.id && !change.pressed) { + if (onTap(change.position)) { + change.consume() + down.consume() + do { + val pointerEvent = awaitPointerEvent() + pointerEvent.changes.fastForEach { it.consume() } + } while (pointerEvent.changes.fastAny { it.pressed }) + } + } + } while (event.changes.any { it.id == down.id && it.pressed }) + } +} \ No newline at end of file diff --git a/material3-html-text/src/main/java/de/charlex/compose/material3/HtmlText.kt b/material3-html-text/src/main/java/de/charlex/compose/material3/HtmlText.kt index 8d91fde..9e544f2 100644 --- a/material3-html-text/src/main/java/de/charlex/compose/material3/HtmlText.kt +++ b/material3-html-text/src/main/java/de/charlex/compose/material3/HtmlText.kt @@ -10,7 +10,8 @@ import android.text.style.StyleSpan import android.text.style.URLSpan import android.text.style.UnderlineSpan import androidx.annotation.StringRes -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.text.InlineTextContent import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -19,7 +20,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler @@ -41,7 +45,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach import androidx.core.text.getSpans +import kotlinx.coroutines.coroutineScope /** * Simple Text composable to show the text with html styling from string resources. @@ -233,19 +240,24 @@ fun HtmlText( Text( modifier = modifier.then(if (clickable) Modifier .pointerInput(Unit) { - detectTapGestures(onTap = { pos -> - layoutResult.value?.let { layoutResult -> + interceptTap(onTap = { pos -> + val shouldConsumeEvent = layoutResult.value?.let { layoutResult -> val position = layoutResult.getOffsetForPosition(pos) - annotatedString + return@let annotatedString .getStringAnnotations(position, position) .firstOrNull() ?.let { sa -> if (sa.tag == "url") { // NON-NLS val url = sa.item onUriClick?.let { it(url) } ?: uriHandler.openUri(url) + true + } else { + false } } - } + } ?: false + + return@interceptTap shouldConsumeEvent }) } .semantics { @@ -358,3 +370,43 @@ fun Spanned.toAnnotatedString( } } } + +typealias ShouldConsumePointerEvent = Boolean + +suspend fun PointerInputScope.interceptTap( + pass: PointerEventPass = PointerEventPass.Initial, + onTap: ((Offset) -> ShouldConsumePointerEvent)? = null, +) = coroutineScope { + if (onTap == null) return@coroutineScope + + awaitEachGesture { + val down = awaitFirstDown(pass = pass) + val downTime = System.currentTimeMillis() + val tapTimeout = viewConfiguration.longPressTimeoutMillis + val tapPosition = down.position + + do { + val event = awaitPointerEvent(pass) + val currentTime = System.currentTimeMillis() + + if (event.changes.size != 1) break // More than one event: not a tap + if (currentTime - downTime >= tapTimeout) break // Too slow: not a tap + + val change = event.changes[0] + + // Too much movement: not a tap + if ((change.position - tapPosition).getDistance() > viewConfiguration.touchSlop) break + + if (change.id == down.id && !change.pressed) { + if (onTap(change.position)) { + change.consume() + down.consume() + do { + val pointerEvent = awaitPointerEvent() + pointerEvent.changes.fastForEach { it.consume() } + } while (pointerEvent.changes.fastAny { it.pressed }) + } + } + } while (event.changes.any { it.id == down.id && it.pressed }) + } +} \ No newline at end of file