Skip to content

Commit 0a3b6cc

Browse files
authored
Merge pull request #97 from morrisseyai/feature/prevent-non-link-tap-consumption
Only consume Text click events if the tap occurs on a link
2 parents 696fcfe + f87799c commit 0a3b6cc

5 files changed

Lines changed: 279 additions & 10 deletions

File tree

buildSrc/src/main/kotlin/Dependencies.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ object Compose {
4747
val tooling = "androidx.compose.ui:ui-tooling:$version"
4848
val toolingData = "androidx.compose.ui:ui-tooling-data:$version"
4949
val desktopPreview = "org.jetbrains.compose.ui:ui-tooling-preview-desktop:$desktopVersion"
50+
val multiplatformUiUtil = "org.jetbrains.compose.ui:ui-util:$desktopVersion"
5051
val coil = "io.coil-kt:coil-compose:2.2.1"
5152
}
5253

richtext-ui/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import org.jetbrains.compose.compose
2+
13
plugins {
24
id("richtext-kmp-library")
35
id("org.jetbrains.compose") version Compose.desktopVersion
@@ -14,6 +16,7 @@ kotlin {
1416
dependencies {
1517
implementation(compose.runtime)
1618
implementation(compose.foundation)
19+
implementation(Compose.multiplatformUiUtil)
1720
}
1821
}
1922
val commonTest by getting

richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/RichTextLocals.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
package com.halilibo.richtext.ui
22

3-
import androidx.compose.foundation.gestures.detectTapGestures
43
import androidx.compose.foundation.text.BasicText
54
import androidx.compose.foundation.text.InlineTextContent
65
import androidx.compose.runtime.Composable
76
import androidx.compose.runtime.compositionLocalOf
87
import androidx.compose.runtime.mutableStateOf
98
import androidx.compose.runtime.remember
109
import androidx.compose.ui.Modifier
10+
import androidx.compose.ui.geometry.Offset
1111
import androidx.compose.ui.graphics.Color
1212
import androidx.compose.ui.graphics.takeOrElse
1313
import androidx.compose.ui.input.pointer.pointerInput
1414
import androidx.compose.ui.text.AnnotatedString
1515
import androidx.compose.ui.text.TextLayoutResult
1616
import androidx.compose.ui.text.TextStyle
1717
import androidx.compose.ui.text.style.TextOverflow
18+
import com.halilibo.richtext.ui.util.detectTapGesturesIf
1819

1920
/**
2021
* Carries the text style in Composition tree. [Heading], [CodeBlock],
@@ -111,11 +112,15 @@ internal fun RichTextScope.ClickableText(
111112
maxLines: Int = Int.MAX_VALUE,
112113
onTextLayout: (TextLayoutResult) -> Unit = {},
113114
inlineContent: Map<String, InlineTextContent> = mapOf(),
115+
isOffsetClickable: (Int) -> Boolean,
114116
onClick: (Int) -> Unit
115117
) {
116118
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
119+
val shouldHandle = { pos: Offset ->
120+
layoutResult.value?.getOffsetForPosition(pos)?.let { isOffsetClickable(it) } ?: false
121+
}
117122
val pressIndicator = Modifier.pointerInput(onClick) {
118-
detectTapGestures { pos ->
123+
detectTapGesturesIf(predicate = shouldHandle) { pos ->
119124
layoutResult.value?.let { layoutResult ->
120125
onClick(layoutResult.getOffsetForPosition(pos))
121126
}

richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/Text.kt

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints
44
import androidx.compose.runtime.Composable
55
import androidx.compose.runtime.remember
66
import androidx.compose.ui.Modifier
7+
import androidx.compose.ui.text.AnnotatedString
78
import androidx.compose.ui.text.TextLayoutResult
89
import androidx.compose.ui.text.style.TextOverflow
910
import com.halilibo.richtext.ui.ClickableText
@@ -48,18 +49,24 @@ public fun RichTextScope.Text(
4849
softWrap = softWrap,
4950
overflow = overflow,
5051
maxLines = maxLines,
52+
isOffsetClickable = { offset ->
53+
annotated.getConsumableAnnotations(text.formatObjects, offset).any()
54+
},
5155
onClick = { offset ->
52-
annotated.getStringAnnotations(Format.FormatAnnotationScope, offset, offset)
53-
.asSequence()
54-
.mapNotNull {
55-
Format.findTag(
56-
it.item,
57-
text.formatObjects
58-
) as? Format.Link
59-
}
56+
annotated.getConsumableAnnotations(text.formatObjects, offset)
6057
.firstOrNull()
6158
?.let { link -> link.onClick() }
6259
}
6360
)
6461
}
6562
}
63+
64+
private fun AnnotatedString.getConsumableAnnotations(textFormatObjects: Map<String, Any>, offset: Int): Sequence<Format.Link> =
65+
getStringAnnotations(Format.FormatAnnotationScope, offset, offset)
66+
.asSequence()
67+
.mapNotNull {
68+
Format.findTag(
69+
it.item,
70+
textFormatObjects
71+
) as? Format.Link
72+
}
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
@file:OptIn(ExperimentalComposeUiApi::class)
2+
3+
package com.halilibo.richtext.ui.util
4+
5+
import androidx.compose.foundation.gestures.GestureCancellationException
6+
import androidx.compose.foundation.gestures.PressGestureScope
7+
import androidx.compose.foundation.gestures.awaitFirstDown
8+
import androidx.compose.foundation.gestures.forEachGesture
9+
import androidx.compose.ui.ExperimentalComposeUiApi
10+
import androidx.compose.ui.geometry.Offset
11+
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
12+
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
13+
import androidx.compose.ui.input.pointer.PointerEventPass
14+
import androidx.compose.ui.input.pointer.PointerInputChange
15+
import androidx.compose.ui.input.pointer.PointerInputScope
16+
import androidx.compose.ui.input.pointer.changedToUp
17+
import androidx.compose.ui.input.pointer.isOutOfBounds
18+
import androidx.compose.ui.platform.ViewConfiguration
19+
import androidx.compose.ui.unit.Density
20+
import androidx.compose.ui.util.fastAll
21+
import androidx.compose.ui.util.fastAny
22+
import androidx.compose.ui.util.fastForEach
23+
import kotlinx.coroutines.coroutineScope
24+
import kotlinx.coroutines.launch
25+
import kotlinx.coroutines.sync.Mutex
26+
27+
private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { }
28+
29+
/**
30+
* If predicate returns true: detects tap, double-tap, and long press gestures and calls [onTap],
31+
* [onDoubleTap], and [onLongPress], respectively, when detected. [onPress] is called when the press
32+
* is detected and the [PressGestureScope.tryAwaitRelease] and [PressGestureScope.awaitRelease]
33+
* can be used to detect when pointers have released or the gesture was canceled.
34+
* The first pointer down and final pointer up are consumed, and in the
35+
* case of long press, all changes after the long press is detected are consumed.
36+
*
37+
* Each function parameter receives an [Offset] representing the position relative to the containing
38+
* element. The [Offset] can be outside the actual bounds of the element itself meaning the numbers
39+
* can be negative or larger than the element bounds if the touch target is smaller than the
40+
* [ViewConfiguration.minimumTouchTargetSize].
41+
*
42+
* When [onDoubleTap] is provided, the tap gesture is detected only after
43+
* the [ViewConfiguration.doubleTapMinTimeMillis] has passed and [onDoubleTap] is called if the
44+
* second tap is started before [ViewConfiguration.doubleTapTimeoutMillis]. If [onDoubleTap] is not
45+
* provided, then [onTap] is called when the pointer up has been received.
46+
*
47+
* After the initial [onPress], if the pointer moves out of the input area, the position change
48+
* is consumed, or another gesture consumes the down or up events, the gestures are considered
49+
* canceled. That means [onDoubleTap], [onLongPress], and [onTap] will not be called after a
50+
* gesture has been canceled.
51+
*
52+
* If the first down event is consumed somewhere else, the entire gesture will be skipped,
53+
* including [onPress].
54+
*/
55+
public suspend fun PointerInputScope.detectTapGesturesIf(
56+
predicate: (Offset) -> Boolean = { true },
57+
onDoubleTap: ((Offset) -> Unit)? = null,
58+
onLongPress: ((Offset) -> Unit)? = null,
59+
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
60+
onTap: ((Offset) -> Unit)? = null
61+
): Unit = coroutineScope {
62+
// special signal to indicate to the sending side that it shouldn't intercept and consume
63+
// cancel/up events as we're only require down events
64+
val pressScope =
65+
PressGestureScopeImpl(this@detectTapGesturesIf)
66+
67+
forEachGesture {
68+
awaitPointerEventScope {
69+
val down = awaitFirstDown()
70+
if (!predicate(down.position)) {
71+
pressScope.reset()
72+
return@awaitPointerEventScope
73+
}
74+
down.consume()
75+
pressScope.reset()
76+
if (onPress !== NoPressGesture) launch {
77+
pressScope.onPress(down.position)
78+
}
79+
val longPressTimeout = onLongPress?.let {
80+
viewConfiguration.longPressTimeoutMillis
81+
} ?: (Long.MAX_VALUE / 2)
82+
var upOrCancel: PointerInputChange? = null
83+
try {
84+
// wait for first tap up or long press
85+
upOrCancel = withTimeout(longPressTimeout) {
86+
waitForUpOrCancellation()
87+
}
88+
if (upOrCancel == null) {
89+
pressScope.cancel() // tap-up was canceled
90+
} else {
91+
upOrCancel.consume()
92+
pressScope.release()
93+
}
94+
} catch (_: PointerEventTimeoutCancellationException) {
95+
onLongPress?.invoke(down.position)
96+
consumeUntilUp()
97+
pressScope.release()
98+
}
99+
100+
if (upOrCancel != null) {
101+
// tap was successful.
102+
if (onDoubleTap == null) {
103+
onTap?.invoke(upOrCancel.position) // no need to check for double-tap.
104+
} else {
105+
// check for second tap
106+
val secondDown = awaitSecondDown(upOrCancel)
107+
108+
if (secondDown == null) {
109+
onTap?.invoke(upOrCancel.position) // no valid second tap started
110+
} else {
111+
// Second tap down detected
112+
pressScope.reset()
113+
if (onPress !== NoPressGesture) {
114+
launch { pressScope.onPress(secondDown.position) }
115+
}
116+
117+
try {
118+
// Might have a long second press as the second tap
119+
withTimeout(longPressTimeout) {
120+
val secondUp = waitForUpOrCancellation()
121+
if (secondUp != null) {
122+
secondUp.consume()
123+
pressScope.release()
124+
onDoubleTap(secondUp.position)
125+
} else {
126+
pressScope.cancel()
127+
onTap?.invoke(upOrCancel.position)
128+
}
129+
}
130+
} catch (e: PointerEventTimeoutCancellationException) {
131+
// The first tap was valid, but the second tap is a long press.
132+
// notify for the first tap
133+
onTap?.invoke(upOrCancel.position)
134+
135+
// notify for the long press
136+
onLongPress?.invoke(secondDown.position)
137+
consumeUntilUp()
138+
pressScope.release()
139+
}
140+
}
141+
}
142+
}
143+
}
144+
}
145+
}
146+
147+
/**
148+
* Consumes all pointer events until nothing is pressed and then returns. This method assumes
149+
* that something is currently pressed.
150+
*/
151+
private suspend fun AwaitPointerEventScope.consumeUntilUp() {
152+
do {
153+
val event = awaitPointerEvent()
154+
event.changes.fastForEach { it.consume() }
155+
} while (event.changes.fastAny { it.pressed })
156+
}
157+
158+
/**
159+
* Waits for [ViewConfiguration.doubleTapTimeoutMillis] for a second press event. If a
160+
* second press event is received before the time out, it is returned or `null` is returned
161+
* if no second press is received.
162+
*/
163+
private suspend fun AwaitPointerEventScope.awaitSecondDown(
164+
firstUp: PointerInputChange
165+
): PointerInputChange? = withTimeoutOrNull(viewConfiguration.doubleTapTimeoutMillis) {
166+
val minUptime = firstUp.uptimeMillis + viewConfiguration.doubleTapMinTimeMillis
167+
var change: PointerInputChange
168+
// The second tap doesn't count if it happens before DoubleTapMinTime of the first tap
169+
do {
170+
change = awaitFirstDown()
171+
} while (change.uptimeMillis < minUptime)
172+
change
173+
}
174+
175+
/**
176+
* Reads events until all pointers are up or the gesture was canceled. The gesture
177+
* is considered canceled when a pointer leaves the event region, a position change
178+
* has been consumed or a pointer down change event was consumed in the [PointerEventPass.Main]
179+
* pass. If the gesture was not canceled, the final up change is returned or `null` if the
180+
* event was canceled.
181+
*/
182+
private suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange? {
183+
while (true) {
184+
val event = awaitPointerEvent(PointerEventPass.Main)
185+
if (event.changes.fastAll { it.changedToUp() }) {
186+
// All pointers are up
187+
return event.changes[0]
188+
}
189+
190+
if (event.changes.fastAny {
191+
it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
192+
}
193+
) {
194+
return null // Canceled
195+
}
196+
197+
// Check for cancel by position consumption. We can look on the Final pass of the
198+
// existing pointer event because it comes after the Main pass we checked above.
199+
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
200+
if (consumeCheck.changes.fastAny { it.isConsumed }) {
201+
return null
202+
}
203+
}
204+
}
205+
206+
/**
207+
* [detectTapGesturesIf]'s implementation of [PressGestureScope].
208+
*/
209+
private class PressGestureScopeImpl(
210+
density: Density
211+
) : PressGestureScope, Density by density {
212+
private var isReleased = false
213+
private var isCanceled = false
214+
private val mutex = Mutex(locked = false)
215+
216+
/**
217+
* Called when a gesture has been canceled.
218+
*/
219+
fun cancel() {
220+
isCanceled = true
221+
mutex.unlock()
222+
}
223+
224+
/**
225+
* Called when all pointers are up.
226+
*/
227+
fun release() {
228+
isReleased = true
229+
mutex.unlock()
230+
}
231+
232+
/**
233+
* Called when a new gesture has started.
234+
*/
235+
fun reset() {
236+
mutex.tryLock() // If tryAwaitRelease wasn't called, this will be unlocked.
237+
isReleased = false
238+
isCanceled = false
239+
}
240+
241+
override suspend fun awaitRelease() {
242+
if (!tryAwaitRelease()) {
243+
throw GestureCancellationException("The press gesture was canceled.")
244+
}
245+
}
246+
247+
override suspend fun tryAwaitRelease(): Boolean {
248+
if (!isReleased && !isCanceled) {
249+
mutex.lock()
250+
}
251+
return isReleased
252+
}
253+
}

0 commit comments

Comments
 (0)