All Downloads are FREE. Search and download functionalities are using the official Maven repository.

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.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.CoroutineStart
import kotlinx.coroutines.Job
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].
 */
@OptIn(ExperimentalTapGestureDetectorBehaviorApi::class)
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()
        var resetJob =
            launch(start = coroutineStartForCurrentDispatchBehavior) { pressScope.reset() }
        // In some cases, coroutine cancellation of the reset job might still be processing when we
        // are already processing an up or cancel pointer event. We need to wait for the reset job
        // to cancel and complete so it can clean up properly (e.g. unlock the underlying mutex)
        suspend fun awaitResetOrSkip() {
            if (DetectTapGesturesEnableNewDispatchingBehavior) {
                resetJob.join()
            }
        }
        if (onPress !== NoPressGesture) launch { pressScope.onPress(down.position) }
        val upOrCancel: PointerInputChange?
        val cancelOrReleaseJob: Job?

        // wait for first tap up or long press
        if (onLongPress == null) {
            upOrCancel = waitForUpOrCancellation()
        } else {
            upOrCancel =
                when (val longPressResult = waitForLongPress()) {
                    LongPressResult.Success -> {
                        onLongPress.invoke(down.position)
                        consumeUntilUp()
                        launch(start = coroutineStartForCurrentDispatchBehavior) {
                            awaitResetOrSkip()
                            pressScope.release()
                        }
                        // End the current gesture
                        return@awaitEachGesture
                    }
                    is LongPressResult.Released -> longPressResult.finalUpChange
                    is LongPressResult.Canceled -> null
                }
        }

        if (upOrCancel == null) {
            cancelOrReleaseJob =
                launch(start = coroutineStartForCurrentDispatchBehavior) {
                    awaitResetOrSkip()
                    // tap-up was canceled
                    pressScope.cancel()
                }
        } else {
            upOrCancel.consume()
            cancelOrReleaseJob =
                launch(start = coroutineStartForCurrentDispatchBehavior) {
                    awaitResetOrSkip()
                    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
                    resetJob =
                        launch(start = coroutineStartForCurrentDispatchBehavior) {
                            cancelOrReleaseJob.join()
                            pressScope.reset()
                        }
                    if (onPress !== NoPressGesture) {
                        launch { pressScope.onPress(secondDown.position) }
                    }

                    // Might have a long second press as the second tap
                    val secondUp =
                        if (onLongPress == null) {
                            waitForUpOrCancellation()
                        } else {
                            when (val longPressResult = waitForLongPress()) {
                                LongPressResult.Success -> {
                                    // The first tap was valid, but the second tap is a long press -
                                    // we
                                    // intentionally do not invoke onClick() for the first tap,
                                    // since the 'main'
                                    // gesture here is a long press, which canceled the double tap
                                    // / tap.

                                    // notify for the long press
                                    onLongPress.invoke(secondDown.position)
                                    consumeUntilUp()

                                    launch(start = coroutineStartForCurrentDispatchBehavior) {
                                        awaitResetOrSkip()
                                        pressScope.release()
                                    }
                                    return@awaitEachGesture
                                }
                                is LongPressResult.Released -> longPressResult.finalUpChange
                                is LongPressResult.Canceled -> null
                            }
                        }
                    if (secondUp != null) {
                        secondUp.consume()
                        launch(start = coroutineStartForCurrentDispatchBehavior) {
                            awaitResetOrSkip()
                            pressScope.release()
                        }
                        onDoubleTap(secondUp.position)
                    } else {
                        launch(start = coroutineStartForCurrentDispatchBehavior) {
                            awaitResetOrSkip()
                            pressScope.cancel()
                        }
                        onTap?.invoke(upOrCancel.position)
                    }
                }
            }
        }
    }
}

/**
 * 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].
 */
@OptIn(ExperimentalTapGestureDetectorBehaviorApi::class)
internal suspend fun PointerInputScope.detectTapAndPress(
    onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture,
    onTap: ((Offset) -> Unit)? = null
) {
    val pressScope = PressGestureScopeImpl(this)
    coroutineScope {
        awaitEachGesture {
            val resetJob =
                launch(start = coroutineStartForCurrentDispatchBehavior) { pressScope.reset() }
            suspend fun awaitResetOrSkip() {
                if (DetectTapGesturesEnableNewDispatchingBehavior) {
                    resetJob.join()
                }
            }

            val down = awaitFirstDown().also { it.consume() }

            if (onPress !== NoPressGesture) {
                launch { pressScope.onPress(down.position) }
            }

            val up = waitForUpOrCancellation()
            if (up == null) {
                launch(start = coroutineStartForCurrentDispatchBehavior) {
                    awaitResetOrSkip()
                    // tap-up was canceled
                    pressScope.cancel()
                }
            } else {
                up.consume()
                launch(start = coroutineStartForCurrentDispatchBehavior) {
                    awaitResetOrSkip()
                    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)

/**
 * Whether the event is considered a deep press, and should trigger long click before the timeout
 * has been reached.
 */
internal expect val PointerEvent.isDeepPress: Boolean

/**
 * 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
        }
    }
}

/**
 * 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.
 */
internal suspend fun AwaitPointerEventScope.waitForLongPress(
    pass: PointerEventPass = PointerEventPass.Main
): LongPressResult {
    var result: LongPressResult = LongPressResult.Canceled
    try {
        withTimeout(viewConfiguration.longPressTimeoutMillis) {
            while (true) {
                val event = awaitPointerEvent(pass)
                if (event.changes.fastAll { it.changedToUp() }) {
                    // All pointers are up
                    result = LongPressResult.Released(event.changes[0])
                    break
                }

                if (event.isDeepPress) {
                    result = LongPressResult.Success
                    break
                }

                if (
                    event.changes.fastAny {
                        it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
                    }
                ) {
                    result = LongPressResult.Canceled
                    break
                }

                // 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 }) {
                    result = LongPressResult.Canceled
                    break
                }
            }
        }
    } catch (_: PointerEventTimeoutCancellationException) {
        return LongPressResult.Success
    }
    return result
}

internal sealed class LongPressResult {
    /** Long press was triggered */
    object Success : LongPressResult()

    /** All pointers were released without long press being triggered */
    class Released(val finalUpChange: PointerInputChange) : LongPressResult()

    /** The gesture was canceled */
    object Canceled : LongPressResult()
}

@Retention(AnnotationRetention.BINARY)
@RequiresOptIn("This API feature-flags new behavior and will be removed in the future.")
annotation class ExperimentalTapGestureDetectorBehaviorApi

/**
 * Whether to use more immediate coroutine dispatching in [detectTapGestures] and
 * [detectTapAndPress], true by default. This might affect some implicit timing guarantees. Please
 * file a bug if this change is affecting your use case.
 */
// This lint does not translate well to top-level declarations
@get:Suppress("GetterSetterNames")
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
@ExperimentalTapGestureDetectorBehaviorApi
@get:ExperimentalTapGestureDetectorBehaviorApi
@set:ExperimentalTapGestureDetectorBehaviorApi
var DetectTapGesturesEnableNewDispatchingBehavior = false

@OptIn(ExperimentalTapGestureDetectorBehaviorApi::class)
private val coroutineStartForCurrentDispatchBehavior
    get() =
        if (DetectTapGesturesEnableNewDispatchingBehavior) {
            CoroutineStart.UNDISPATCHED
        } else {
            CoroutineStart.DEFAULT
        }

/** [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
        if (mutex.isLocked) {
            mutex.unlock()
        }
    }

    /** Called when all pointers are up. */
    fun release() {
        isReleased = true
        if (mutex.isLocked) {
            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