|
| 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