commonMain.androidx.compose.foundation.gestures.TapGestureDetector.kt Maven / Gradle / Ivy
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.foundation.gestures
import androidx.compose.foundation.internal.JvmDefaultWithCompatibility
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
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.input.pointer.PointerType
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.input.pointer.changedToDown
import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.isOutOfBounds
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
/**
* Receiver scope for [detectTapGestures]'s `onPress` lambda. This offers
* two methods to allow waiting for the press to be released.
*/
@JvmDefaultWithCompatibility
interface PressGestureScope : Density {
/**
* Waits for the press to be released before returning. If the gesture was canceled by
* motion being consumed by another gesture, [GestureCancellationException] will be
* thrown.
*/
suspend fun awaitRelease()
/**
* Waits for the press to be released before returning. If the press was released,
* `true` is returned, or if the gesture was canceled by motion being consumed by
* another gesture, `false` is returned .
*/
suspend fun tryAwaitRelease(): Boolean
}
private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { }
/**
* Detects tap, double-tap, and long press gestures and calls [onTap], [onDoubleTap], and
* [onLongPress], respectively, when detected. [onPress] is called when the press is detected
* and the [PressGestureScope.tryAwaitRelease] and [PressGestureScope.awaitRelease] can be
* used to detect when pointers have released or the gesture was canceled.
* The first pointer down and final pointer up are consumed, and in the
* case of long press, all changes after the long press is detected are consumed.
*
* Each function parameter receives an [Offset] representing the position relative to the containing
* element. The [Offset] can be outside the actual bounds of the element itself meaning the numbers
* can be negative or larger than the element bounds if the touch target is smaller than the
* [ViewConfiguration.minimumTouchTargetSize].
*
* When [onDoubleTap] is provided, the tap gesture is detected only after
* the [ViewConfiguration.doubleTapMinTimeMillis] has passed and [onDoubleTap] is called if the
* second tap is started before [ViewConfiguration.doubleTapTimeoutMillis]. If [onDoubleTap] is not
* provided, then [onTap] is called when the pointer up has been received.
*
* After the initial [onPress], if the pointer moves out of the input area, the position change
* is consumed, or another gesture consumes the down or up events, the gestures are considered
* canceled. That means [onDoubleTap], [onLongPress], and [onTap] will not be called after a
* gesture has been canceled.
*
* If the first down event is consumed somewhere else, the entire gesture will be skipped,
* including [onPress].
*/
suspend fun PointerInputScope.detectTapGestures(
onDoubleTap: ((Offset) -> Unit)? = null,
onLongPress: ((Offset) -> Unit)? = null,
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
onTap: ((Offset) -> Unit)? = null
) = coroutineScope {
// special signal to indicate to the sending side that it shouldn't intercept and consume
// cancel/up events as we're only require down events
val pressScope = PressGestureScopeImpl(this@detectTapGestures)
awaitEachGesture {
val down = awaitFirstDown()
down.consume()
launch {
pressScope.reset()
}
if (onPress !== NoPressGesture) launch {
pressScope.onPress(down.position)
}
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()
}
if (upOrCancel == null) {
launch {
pressScope.cancel() // tap-up was canceled
}
} else {
upOrCancel.consume()
launch {
pressScope.release()
}
}
} catch (_: PointerEventTimeoutCancellationException) {
onLongPress?.invoke(down.position)
consumeUntilUp()
launch {
pressScope.release()
}
}
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 {
// Second tap down detected
launch {
pressScope.reset()
}
if (onPress !== NoPressGesture) {
launch { pressScope.onPress(secondDown.position) }
}
try {
// Might have a long second press as the second tap
withTimeout(longPressTimeout) {
val secondUp = waitForUpOrCancellation()
if (secondUp != null) {
secondUp.consume()
launch {
pressScope.release()
}
onDoubleTap(secondUp.position)
} else {
launch {
pressScope.cancel()
}
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()
launch {
pressScope.release()
}
}
}
}
}
}
}
/**
* 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
}
/**
* Shortcut for cases when we only need to get press/click logic, as for cases without long press
* and double click we don't require channelling or any other complications.
*
* Each function parameter receives an [Offset] representing the position relative to the containing
* element. The [Offset] can be outside the actual bounds of the element itself meaning the numbers
* can be negative or larger than the element bounds if the touch target is smaller than the
* [ViewConfiguration.minimumTouchTargetSize].
*/
internal suspend fun PointerInputScope.detectTapAndPress(
onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
onTap: ((Offset) -> Unit)? = null
) {
val pressScope = PressGestureScopeImpl(this)
coroutineScope {
awaitEachGesture {
launch {
pressScope.reset()
}
val down = awaitFirstDown().also { it.consume() }
if (onPress !== NoPressGesture) {
launch {
pressScope.onPress(down.position)
}
}
val up = waitForUpOrCancellation()
if (up == null) {
launch {
pressScope.cancel() // tap-up was canceled
}
} else {
up.consume()
launch {
pressScope.release()
}
onTap?.invoke(up.position)
}
}
}
}
@Deprecated(
"Maintained for binary compatibility. Use version with PointerEventPass instead.",
level = DeprecationLevel.HIDDEN)
suspend fun AwaitPointerEventScope.awaitFirstDown(
requireUnconsumed: Boolean = true
): PointerInputChange =
awaitFirstDown(requireUnconsumed = requireUnconsumed, pass = PointerEventPass.Main)
/**
* Reads events until the first down is received. If [requireUnconsumed] is `true` and the first
* down is consumed in the [PointerEventPass.Main] pass, that gesture is ignored.
* If it was down caused by [PointerType.Mouse], this function reacts only on primary button.
*/
suspend fun AwaitPointerEventScope.awaitFirstDown(
requireUnconsumed: Boolean = true,
pass: PointerEventPass = PointerEventPass.Main,
): PointerInputChange {
var event: PointerEvent
do {
event = awaitPointerEvent(pass)
} while (!event.isPrimaryChangedDown(requireUnconsumed))
return event.changes[0]
}
private fun PointerEvent.isPrimaryChangedDown(requireUnconsumed: Boolean): Boolean {
val primaryButtonCausesDown = changes.fastAll { it.type == PointerType.Mouse }
val changedToDown = changes.fastAll {
if (requireUnconsumed) it.changedToDown() else it.changedToDownIgnoreConsumed()
}
return changedToDown && (buttons.isPrimaryPressed || !primaryButtonCausesDown)
}
@Deprecated(
"Maintained for binary compatibility. Use version with PointerEventPass instead.",
level = DeprecationLevel.HIDDEN)
suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange? =
waitForUpOrCancellation(PointerEventPass.Main)
/**
* Reads events in the given [pass] until all pointers are up or the gesture was canceled.
* The gesture is considered canceled when a pointer leaves the event region, a position
* change has been consumed or a pointer down change event was already consumed in the given
* pass. If the gesture was not canceled, the final up change is returned or `null` if the
* event was canceled.
*/
suspend fun AwaitPointerEventScope.waitForUpOrCancellation(
pass: PointerEventPass = PointerEventPass.Main
): PointerInputChange? {
while (true) {
val event = awaitPointerEvent(pass)
if (event.changes.fastAll { it.changedToUp() }) {
// All pointers are up
return event.changes[0]
}
if (event.changes.fastAny {
it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
}
) {
return null // Canceled
}
// Check for cancel by position consumption. We can look on the Final pass of the
// existing pointer event because it comes after the pass we checked above.
val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
if (consumeCheck.changes.fastAny { it.isConsumed }) {
return null
}
}
}
/**
* [detectTapGestures]'s implementation of [PressGestureScope].
*/
internal class PressGestureScopeImpl(
density: Density
) : PressGestureScope, Density by density {
private var isReleased = false
private var isCanceled = false
private val mutex = Mutex(locked = false)
/**
* Called when a gesture has been canceled.
*/
fun cancel() {
isCanceled = true
mutex.unlock()
}
/**
* Called when all pointers are up.
*/
fun release() {
isReleased = true
mutex.unlock()
}
/**
* Called when a new gesture has started.
*/
suspend fun reset() {
mutex.lock()
isReleased = false
isCanceled = false
}
override suspend fun awaitRelease() {
if (!tryAwaitRelease()) {
throw GestureCancellationException("The press gesture was canceled.")
}
}
override suspend fun tryAwaitRelease(): Boolean {
if (!isReleased && !isCanceled) {
mutex.lock()
mutex.unlock()
}
return isReleased
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy