Skip to content

Commit 2e9132a

Browse files
committed
Introduce conditional detectTapGestures function
This is the same as the detectTapGestures from the Compose Foundation dependency except it takes a predicate with an offset parameter, which dictates whether it should consume detected gestures or not.
1 parent 696fcfe commit 2e9132a

3 files changed

Lines changed: 257 additions & 0 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
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)