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

commonMain.androidx.compose.foundation.gestures.DragGestureDetector.kt Maven / Gradle / Ivy

Go to download

Higher level abstractions of the Compose UI primitives. This library is design system agnostic, providing the high-level building blocks for both application and design-system developers

The newest version!
/*
 * 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

// Note, that there is a copy-paste version of this file (DragGestureDetectorCopy.kt), don't
// forget to change it too.
//
// We can't make *PointerSlop* functions public just yet because the new pointer API isn't ready.

// TODO(b/193549931): when the new pointer API will be ready we should make *PointerSlop*
//  functions public

import androidx.compose.foundation.ComposeFoundationFlags.DragGesturePickUpEnabled
import androidx.compose.foundation.ComposeFoundationFlags.DraggableAddDownEventFixEnabled
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.isSpecified
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.PointerId
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.changedToUp
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.isOutOfBounds
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed
import androidx.compose.ui.input.pointer.positionChangedIgnoreConsumed
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFirstOrNull
import androidx.compose.ui.util.fastForEach
import kotlin.math.absoluteValue
import kotlin.math.sign
import kotlinx.coroutines.CancellationException

/**
 * Waits for drag motion to pass [touch slop][ViewConfiguration.touchSlop], using [pointerId] as the
 * pointer to examine. If [pointerId] is raised, another pointer from those that are down will be
 * chosen to lead the gesture, and if none are down, `null` is returned. If [pointerId] is not down
 * when [awaitTouchSlopOrCancellation] is called, then `null` is returned.
 *
 * [onTouchSlopReached] is called after [ViewConfiguration.touchSlop] motion in the any direction
 * with the change that caused the motion beyond touch slop and the [Offset] beyond touch slop that
 * has passed. [onTouchSlopReached] should consume the position change if it accepts the motion. If
 * it does, then the method returns that [PointerInputChange]. If not, touch slop detection will
 * continue.
 *
 * @return The [PointerInputChange] that was consumed in [onTouchSlopReached] or `null` if all
 *   pointers are raised before touch slop is detected or another gesture consumed the position
 *   change.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.AwaitDragOrCancellationSample
 * @see awaitHorizontalTouchSlopOrCancellation
 * @see awaitVerticalTouchSlopOrCancellation
 */
suspend fun AwaitPointerEventScope.awaitTouchSlopOrCancellation(
    pointerId: PointerId,
    onTouchSlopReached: (change: PointerInputChange, overSlop: Offset) -> Unit
): PointerInputChange? {
    return awaitPointerSlopOrCancellation(
        pointerId,
        PointerType.Touch,
        onPointerSlopReached = onTouchSlopReached,
        orientation = null,
    )
}

/**
 * Reads position change events for [pointerId] and calls [onDrag] for every change in position. If
 * [pointerId] is raised, a new pointer is chosen from those that are down and if none exist, the
 * method returns. This does not wait for touch slop.
 *
 * @return `true` if the drag completed normally or `false` if the drag motion was canceled by
 *   another gesture detector consuming position change events.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.DragSample
 * @see awaitTouchSlopOrCancellation
 * @see awaitDragOrCancellation
 * @see horizontalDrag
 * @see verticalDrag
 */
suspend fun AwaitPointerEventScope.drag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit
): Boolean {
    var pointer = pointerId
    while (true) {
        val change = awaitDragOrCancellation(pointer) ?: return false

        if (change.changedToUpIgnoreConsumed()) {
            return true
        }

        onDrag(change)
        pointer = change.id
    }
}

/**
 * Reads pointer input events until a drag is detected or all pointers are up. When the final
 * pointer is raised, the up event is returned. When a drag event is detected, the drag change will
 * be returned. Note that if [pointerId] has been raised, another pointer that is down will be used,
 * if available, so the returned [PointerInputChange.id] may differ from [pointerId]. If the
 * position change in the any direction has been consumed by the [PointerEventPass.Main] pass, then
 * the drag is considered canceled and `null` is returned. If [pointerId] is not down when
 * [awaitDragOrCancellation] is called, then `null` is returned.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.AwaitDragOrCancellationSample
 * @see awaitVerticalDragOrCancellation
 * @see awaitHorizontalDragOrCancellation
 * @see drag
 */
suspend fun AwaitPointerEventScope.awaitDragOrCancellation(
    pointerId: PointerId,
): PointerInputChange? {
    if (currentEvent.isPointerUp(pointerId)) {
        return null // The pointer has already been lifted, so the gesture is canceled
    }
    val change = awaitDragOrUp(pointerId) { it.positionChangedIgnoreConsumed() }
    return if (change?.isConsumed == false) change else null
}

/**
 * Gesture detector that waits for pointer down and touch slop in any direction and then calls
 * [onDrag] for each drag event. It follows the touch slop detection of
 * [awaitTouchSlopOrCancellation] but will consume the position change automatically once the touch
 * slop has been crossed.
 *
 * [onDragStart] called when the touch slop has been passed and includes an [Offset] representing
 * the last known pointer 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].
 *
 * [onDragEnd] is called after all pointers are up and [onDragCancel] is called if another gesture
 * has consumed pointer input, canceling this gesture.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.DetectDragGesturesSample
 * @see detectVerticalDragGestures
 * @see detectHorizontalDragGestures
 * @see detectDragGesturesAfterLongPress to detect gestures after long press
 */
@OptIn(ExperimentalFoundationApi::class)
suspend fun PointerInputScope.detectDragGestures(
    onDragStart: (Offset) -> Unit = {},
    onDragEnd: () -> Unit = {},
    onDragCancel: () -> Unit = {},
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) =
    if (DraggableAddDownEventFixEnabled) {
        detectDragGestures(
            onDragStart = { _, slopTriggerChange, _ -> onDragStart(slopTriggerChange.position) },
            onDragEnd = { onDragEnd.invoke() },
            onDragCancel = onDragCancel,
            shouldAwaitTouchSlop = { true },
            orientationLock = null,
            onDrag = onDrag
        )
    } else {
        legacyDetectDragGestures(
            onDragStart = { change, _ -> onDragStart(change.position) },
            onDragEnd = { onDragEnd.invoke() },
            onDragCancel = onDragCancel,
            shouldAwaitTouchSlop = { true },
            orientationLock = null,
            onDrag = onDrag
        )
    }

/**
 * A Gesture detector that waits for pointer down and touch slop in the direction specified by
 * [orientationLock] and then calls [onDrag] for each drag event. It follows the touch slop
 * detection of [awaitTouchSlopOrCancellation] but will consume the position change automatically
 * once the touch slop has been crossed, the amount of drag over the touch slop is reported as the
 * first drag event [onDrag] after the slop is crossed. If [shouldAwaitTouchSlop] returns true the
 * touch slop recognition phase will be ignored and the drag gesture will be recognized
 * immediately.The first [onDrag] in this case will report an [Offset.Zero].
 *
 * [onDragStart] is called when the touch slop has been passed and includes an [Offset] representing
 * the last known pointer position relative to the containing element as well as the initial down
 * event that triggered this gesture detection cycle. 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].
 *
 * [onDragEnd] is called after all pointers are up with the event change of the up event and
 * [onDragCancel] is called if another gesture has consumed pointer input, canceling this gesture.
 *
 * @param onDragStart A lambda to be called when the drag gesture starts, it contains information
 *   about the last known [PointerInputChange] relative to the containing element and the post slop
 *   delta, slopTriggerChange. It also contains information about the down event where this gesture
 *   started and the overSlopOffset.
 * @param onDragEnd A lambda to be called when the gesture ends. It contains information about the
 *   up [PointerInputChange] that finished the gesture.
 * @param onDragCancel A lambda to be called when the gesture is cancelled either by an error or
 *   when it was consumed.
 * @param shouldAwaitTouchSlop Indicates if touch slop detection should be skipped.
 * @param orientationLock Optionally locks detection to this orientation, this means, when this is
 *   provided, touch slop detection and drag event detection will be conditioned to the given
 *   orientation axis. [onDrag] will still dispatch events on with information in both axis, but if
 *   orientation lock is provided, only events that happen on the given orientation will be
 *   considered. If no value is provided (i.e. null) touch slop and drag detection will happen on an
 *   "any" orientation basis, that is, touch slop will be detected if crossed in either direction
 *   and drag events will be dispatched if present in either direction.
 * @param onDrag A lambda to be called for each delta event in the gesture. It contains information
 *   about the [PointerInputChange] and the movement offset.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.DetectDragGesturesSample
 * @see detectVerticalDragGestures
 * @see detectHorizontalDragGestures
 * @see detectDragGesturesAfterLongPress to detect gestures after long press
 */
@OptIn(ExperimentalFoundationApi::class)
internal suspend fun PointerInputScope.detectDragGestures(
    onDragStart:
        (
            down: PointerInputChange, slopTriggerChange: PointerInputChange, overSlopOffset: Offset
        ) -> Unit,
    onDragEnd: (change: PointerInputChange) -> Unit,
    onDragCancel: () -> Unit,
    shouldAwaitTouchSlop: () -> Boolean,
    orientationLock: Orientation?,
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
    var overSlop: Offset

    awaitEachGesture {
        val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
        val awaitTouchSlop = shouldAwaitTouchSlop()

        if (!awaitTouchSlop) {
            initialDown.consume()
        }
        val down = awaitFirstDown(requireUnconsumed = false)
        var drag: PointerInputChange?
        overSlop = Offset.Zero

        if (awaitTouchSlop) {
            do {
                drag =
                    awaitPointerSlopOrCancellation(
                        down.id,
                        down.type,
                        orientation = orientationLock
                    ) { change, over ->
                        change.consume()
                        overSlop = over
                    }
            } while (drag != null && !drag.isConsumed)
        } else {
            drag = initialDown
        }

        // if the pointer is still down, keep reading events in case we need to pick up the gesture.
        if (
            DragGesturePickUpEnabled && drag == null && currentEvent.changes.fastAny { it.pressed }
        ) {
            var event: PointerEvent
            do {
                event = awaitPointerEvent()
            } while (
                event.changes.fastAny { it.isConsumed } && event.changes.fastAny { it.pressed }
            )

            // an event was not consumed and there's still a pointer in the screen
            if (event.changes.fastAny { it.pressed }) {
                // await touch slop again, using the initial down as starting point.
                // For most cases this should return immediately since we probably moved
                // far enough from the initial down event.
                drag =
                    awaitPointerSlopOrCancellation(
                        down.id,
                        down.type,
                        orientation = orientationLock
                    ) { change, over ->
                        change.consume()
                        overSlop = over
                    }
            }
        }

        if (drag != null) {
            onDragStart.invoke(down, drag, overSlop)
            onDrag(drag, overSlop)
            val upEvent =
                drag(
                    pointerId = drag.id,
                    onDrag = {
                        onDrag(it, it.positionChange())
                        it.consume()
                    },
                    orientation = orientationLock,
                    motionConsumed = { it.isConsumed }
                )
            if (upEvent == null) {
                onDragCancel()
            } else {
                onDragEnd(upEvent)
            }
        }
    }
}

internal suspend fun PointerInputScope.legacyDetectDragGestures(
    onDragStart: (change: PointerInputChange, initialDelta: Offset) -> Unit,
    onDragEnd: (change: PointerInputChange) -> Unit,
    onDragCancel: () -> Unit,
    shouldAwaitTouchSlop: () -> Boolean,
    orientationLock: Orientation?,
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
    var overSlop: Offset

    awaitEachGesture {
        val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
        val awaitTouchSlop = shouldAwaitTouchSlop()

        if (!awaitTouchSlop) {
            initialDown.consume()
        }
        val down = awaitFirstDown(requireUnconsumed = false)
        var drag: PointerInputChange?
        var initialDelta = Offset.Zero
        overSlop = Offset.Zero

        if (awaitTouchSlop) {
            do {
                drag =
                    awaitPointerSlopOrCancellation(
                        down.id,
                        down.type,
                        orientation = orientationLock
                    ) { change, over ->
                        change.consume()
                        overSlop = over
                    }
            } while (drag != null && !drag.isConsumed)
            initialDelta = overSlop
        } else {
            drag = initialDown
        }

        if (drag != null) {
            onDragStart.invoke(drag, initialDelta)
            onDrag(drag, overSlop)
            val upEvent =
                drag(
                    pointerId = drag.id,
                    onDrag = {
                        onDrag(it, it.positionChange())
                        it.consume()
                    },
                    orientation = orientationLock,
                    motionConsumed = { it.isConsumed }
                )
            if (upEvent == null) {
                onDragCancel()
            } else {
                onDragEnd(upEvent)
            }
        }
    }
}

/**
 * Gesture detector that waits for pointer down and long press, after which it calls [onDrag] for
 * each drag event.
 *
 * [onDragStart] called when a long press is detected and includes an [Offset] representing the last
 * known pointer 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].
 *
 * [onDragEnd] is called after all pointers are up and [onDragCancel] is called if another gesture
 * has consumed pointer input, canceling this gesture. This function will automatically consume all
 * the position change after the long press.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.DetectDragWithLongPressGesturesSample
 * @see detectVerticalDragGestures
 * @see detectHorizontalDragGestures
 * @see detectDragGestures
 */
suspend fun PointerInputScope.detectDragGesturesAfterLongPress(
    onDragStart: (Offset) -> Unit = {},
    onDragEnd: () -> Unit = {},
    onDragCancel: () -> Unit = {},
    onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
    awaitEachGesture {
        try {
            val down = awaitFirstDown(requireUnconsumed = false)
            val drag = awaitLongPressOrCancellation(down.id)
            if (drag != null) {
                onDragStart.invoke(drag.position)

                if (
                    drag(drag.id) {
                        onDrag(it, it.positionChange())
                        it.consume()
                    }
                ) {
                    // consume up if we quit drag gracefully with the up
                    currentEvent.changes.fastForEach { if (it.changedToUp()) it.consume() }
                    onDragEnd()
                } else {
                    onDragCancel()
                }
            }
        } catch (c: CancellationException) {
            onDragCancel()
            throw c
        }
    }
}

/**
 * Waits for vertical drag motion to pass [touch slop][ViewConfiguration.touchSlop], using
 * [pointerId] as the pointer to examine. If [pointerId] is raised, another pointer from those that
 * are down will be chosen to lead the gesture, and if none are down, `null` is returned. If
 * [pointerId] is not down when [awaitVerticalTouchSlopOrCancellation] is called, then `null` is
 * returned.
 *
 * [onTouchSlopReached] is called after [ViewConfiguration.touchSlop] motion in the vertical
 * direction with the change that caused the motion beyond touch slop and the pixels beyond touch
 * slop. [onTouchSlopReached] should consume the position change if it accepts the motion. If it
 * does, then the method returns that [PointerInputChange]. If not, touch slop detection will
 * continue.
 *
 * @return The [PointerInputChange] that was consumed in [onTouchSlopReached] or `null` if all
 *   pointers are raised before touch slop is detected or another gesture consumed the position
 *   change.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.AwaitVerticalDragOrCancellationSample
 * @see awaitHorizontalTouchSlopOrCancellation
 * @see awaitTouchSlopOrCancellation
 */
suspend fun AwaitPointerEventScope.awaitVerticalTouchSlopOrCancellation(
    pointerId: PointerId,
    onTouchSlopReached: (change: PointerInputChange, overSlop: Float) -> Unit
) =
    awaitPointerSlopOrCancellation(
        pointerId = pointerId,
        pointerType = PointerType.Touch,
        onPointerSlopReached = { change, overSlop -> onTouchSlopReached(change, overSlop.y) },
        orientation = Orientation.Vertical
    )

internal suspend fun AwaitPointerEventScope.awaitVerticalPointerSlopOrCancellation(
    pointerId: PointerId,
    pointerType: PointerType,
    onTouchSlopReached: (change: PointerInputChange, overSlop: Float) -> Unit
) =
    awaitPointerSlopOrCancellation(
        pointerId = pointerId,
        pointerType = pointerType,
        onPointerSlopReached = { change, overSlop -> onTouchSlopReached(change, overSlop.y) },
        orientation = Orientation.Vertical
    )

/**
 * Reads vertical position change events for [pointerId] and calls [onDrag] for every change in
 * position. If [pointerId] is raised, a new pointer is chosen from those that are down and if none
 * exist, the method returns. This does not wait for touch slop
 *
 * @return `true` if the vertical drag completed normally or `false` if the drag motion was canceled
 *   by another gesture detector consuming position change events.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.VerticalDragSample
 * @see awaitVerticalTouchSlopOrCancellation
 * @see awaitVerticalDragOrCancellation
 * @see horizontalDrag
 * @see drag
 */
suspend fun AwaitPointerEventScope.verticalDrag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit
): Boolean =
    drag(
        pointerId = pointerId,
        onDrag = onDrag,
        orientation = Orientation.Vertical,
        motionConsumed = { it.isConsumed }
    ) != null

/**
 * Reads pointer input events until a vertical drag is detected or all pointers are up. When the
 * final pointer is raised, the up event is returned. When a drag event is detected, the drag change
 * will be returned. Note that if [pointerId] has been raised, another pointer that is down will be
 * used, if available, so the returned [PointerInputChange.id] may differ from [pointerId]. If the
 * position change has been consumed by the [PointerEventPass.Main] pass, then the drag is
 * considered canceled and `null` is returned. If [pointerId] is not down when
 * [awaitVerticalDragOrCancellation] is called, then `null` is returned.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.AwaitVerticalDragOrCancellationSample
 * @see awaitHorizontalDragOrCancellation
 * @see awaitDragOrCancellation
 * @see verticalDrag
 */
suspend fun AwaitPointerEventScope.awaitVerticalDragOrCancellation(
    pointerId: PointerId,
): PointerInputChange? {
    if (currentEvent.isPointerUp(pointerId)) {
        return null // The pointer has already been lifted, so the gesture is canceled
    }
    val change = awaitDragOrUp(pointerId) { it.positionChangeIgnoreConsumed().y != 0f }
    return if (change?.isConsumed == false) change else null
}

/**
 * Gesture detector that waits for pointer down and touch slop in the vertical direction and then
 * calls [onVerticalDrag] for each vertical drag event. It follows the touch slop detection of
 * [awaitVerticalTouchSlopOrCancellation], but will consume the position change automatically once
 * the touch slop has been crossed.
 *
 * [onDragStart] called when the touch slop has been passed and includes an [Offset] representing
 * the last known pointer 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].
 *
 * [onDragEnd] is called after all pointers are up and [onDragCancel] is called if another gesture
 * has consumed pointer input, canceling this gesture.
 *
 * This gesture detector will coordinate with [detectHorizontalDragGestures] and
 * [awaitHorizontalTouchSlopOrCancellation] to ensure only vertical or horizontal dragging is
 * locked, but not both.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.DetectVerticalDragGesturesSample
 * @see detectDragGestures
 * @see detectHorizontalDragGestures
 */
suspend fun PointerInputScope.detectVerticalDragGestures(
    onDragStart: (Offset) -> Unit = {},
    onDragEnd: () -> Unit = {},
    onDragCancel: () -> Unit = {},
    onVerticalDrag: (change: PointerInputChange, dragAmount: Float) -> Unit
) {
    awaitEachGesture {
        val down = awaitFirstDown(requireUnconsumed = false)
        var overSlop = 0f
        val drag =
            awaitVerticalPointerSlopOrCancellation(down.id, down.type) { change, over ->
                change.consume()
                overSlop = over
            }
        if (drag != null) {
            onDragStart.invoke(drag.position)
            onVerticalDrag.invoke(drag, overSlop)
            if (
                verticalDrag(drag.id) {
                    onVerticalDrag(it, it.positionChange().y)
                    it.consume()
                }
            ) {
                onDragEnd()
            } else {
                onDragCancel()
            }
        }
    }
}

/**
 * Waits for horizontal drag motion to pass [touch slop][ViewConfiguration.touchSlop], using
 * [pointerId] as the pointer to examine. If [pointerId] is raised, another pointer from those that
 * are down will be chosen to lead the gesture, and if none are down, `null` is returned.
 *
 * [onTouchSlopReached] is called after [ViewConfiguration.touchSlop] motion in the horizontal
 * direction with the change that caused the motion beyond touch slop and the pixels beyond touch
 * slop. [onTouchSlopReached] should consume the position change if it accepts the motion. If it
 * does, then the method returns that [PointerInputChange]. If not, touch slop detection will
 * continue. If [pointerId] is not down when [awaitHorizontalTouchSlopOrCancellation] is called,
 * then `null` is returned.
 *
 * @return The [PointerInputChange] that was consumed in [onTouchSlopReached] or `null` if all
 *   pointers are raised before touch slop is detected or another gesture consumed the position
 *   change.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.AwaitHorizontalDragOrCancellationSample
 * @see awaitVerticalTouchSlopOrCancellation
 * @see awaitTouchSlopOrCancellation
 */
suspend fun AwaitPointerEventScope.awaitHorizontalTouchSlopOrCancellation(
    pointerId: PointerId,
    onTouchSlopReached: (change: PointerInputChange, overSlop: Float) -> Unit
) =
    awaitPointerSlopOrCancellation(
        pointerId = pointerId,
        pointerType = PointerType.Touch,
        onPointerSlopReached = { change, overSlop -> onTouchSlopReached(change, overSlop.x) },
        orientation = Orientation.Horizontal
    )

internal suspend fun AwaitPointerEventScope.awaitHorizontalPointerSlopOrCancellation(
    pointerId: PointerId,
    pointerType: PointerType,
    onPointerSlopReached: (change: PointerInputChange, overSlop: Float) -> Unit
) =
    awaitPointerSlopOrCancellation(
        pointerId = pointerId,
        pointerType = pointerType,
        onPointerSlopReached = { change, overSlop -> onPointerSlopReached(change, overSlop.x) },
        orientation = Orientation.Horizontal
    )

/**
 * Reads horizontal position change events for [pointerId] and calls [onDrag] for every change in
 * position. If [pointerId] is raised, a new pointer is chosen from those that are down and if none
 * exist, the method returns. This does not wait for touch slop.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.HorizontalDragSample
 * @see awaitHorizontalTouchSlopOrCancellation
 * @see awaitDragOrCancellation
 * @see verticalDrag
 * @see drag
 */
suspend fun AwaitPointerEventScope.horizontalDrag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit
): Boolean =
    drag(
        pointerId = pointerId,
        onDrag = onDrag,
        orientation = Orientation.Horizontal,
        motionConsumed = { it.isConsumed }
    ) != null

/**
 * Reads pointer input events until a horizontal drag is detected or all pointers are up. When the
 * final pointer is raised, the up event is returned. When a drag event is detected, the drag change
 * will be returned. Note that if [pointerId] has been raised, another pointer that is down will be
 * used, if available, so the returned [PointerInputChange.id] may differ from [pointerId]. If the
 * position change has been consumed by the [PointerEventPass.Main] pass, then the drag is
 * considered canceled and `null` is returned. If [pointerId] is not down when
 * [awaitHorizontalDragOrCancellation] is called, then `null` is returned.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.AwaitHorizontalDragOrCancellationSample
 * @see horizontalDrag
 * @see awaitVerticalDragOrCancellation
 * @see awaitDragOrCancellation
 */
suspend fun AwaitPointerEventScope.awaitHorizontalDragOrCancellation(
    pointerId: PointerId,
): PointerInputChange? {
    if (currentEvent.isPointerUp(pointerId)) {
        return null // The pointer has already been lifted, so the gesture is canceled
    }
    val change = awaitDragOrUp(pointerId) { it.positionChangeIgnoreConsumed().x != 0f }
    return if (change?.isConsumed == false) change else null
}

/**
 * Gesture detector that waits for pointer down and touch slop in the horizontal direction and then
 * calls [onHorizontalDrag] for each horizontal drag event. It follows the touch slop detection of
 * [awaitHorizontalTouchSlopOrCancellation], but will consume the position change automatically once
 * the touch slop has been crossed.
 *
 * [onDragStart] called when the touch slop has been passed and includes an [Offset] representing
 * the last known pointer 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].
 *
 * [onDragEnd] is called after all pointers are up and [onDragCancel] is called if another gesture
 * has consumed pointer input, canceling this gesture.
 *
 * This gesture detector will coordinate with [detectVerticalDragGestures] and
 * [awaitVerticalTouchSlopOrCancellation] to ensure only vertical or horizontal dragging is locked,
 * but not both.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.DetectHorizontalDragGesturesSample
 * @see detectVerticalDragGestures
 * @see detectDragGestures
 */
suspend fun PointerInputScope.detectHorizontalDragGestures(
    onDragStart: (Offset) -> Unit = {},
    onDragEnd: () -> Unit = {},
    onDragCancel: () -> Unit = {},
    onHorizontalDrag: (change: PointerInputChange, dragAmount: Float) -> Unit
) {
    awaitEachGesture {
        val down = awaitFirstDown(requireUnconsumed = false)
        var overSlop = 0f
        val drag =
            awaitHorizontalPointerSlopOrCancellation(down.id, down.type) { change, over ->
                change.consume()
                overSlop = over
            }
        if (drag != null) {
            onDragStart.invoke(drag.position)
            onHorizontalDrag(drag, overSlop)
            if (
                horizontalDrag(drag.id) {
                    onHorizontalDrag(it, it.positionChange().x)
                    it.consume()
                }
            ) {
                onDragEnd()
            } else {
                onDragCancel()
            }
        }
    }
}

/**
 * Continues to read drag events until all pointers are up or the drag event is canceled. The
 * initial pointer to use for driving the drag is [pointerId]. [onDrag] is called whenever the
 * pointer moves. The up event is returned at the end of the drag gesture.
 *
 * @param pointerId The pointer where that is driving the gesture.
 * @param onDrag Callback for every new drag event.
 * @param motionConsumed If the PointerInputChange should be considered as consumed.
 * @return The last pointer input event change when gesture ended with all pointers up and null when
 *   the gesture was canceled.
 */
internal suspend inline fun AwaitPointerEventScope.drag(
    pointerId: PointerId,
    onDrag: (PointerInputChange) -> Unit,
    orientation: Orientation?,
    motionConsumed: (PointerInputChange) -> Boolean
): PointerInputChange? {
    if (currentEvent.isPointerUp(pointerId)) {
        return null // The pointer has already been lifted, so the gesture is canceled
    }
    var pointer = pointerId
    while (true) {
        val change =
            awaitDragOrUp(pointer) {
                val positionChange = it.positionChangeIgnoreConsumed()
                val motionChange =
                    if (orientation == null) {
                        positionChange.getDistance()
                    } else {
                        if (orientation == Orientation.Vertical) positionChange.y
                        else positionChange.x
                    }
                motionChange != 0.0f
            } ?: return null

        if (motionConsumed(change)) {
            return null
        }

        if (change.changedToUpIgnoreConsumed()) {
            return change
        }

        onDrag(change)
        pointer = change.id
    }
}

/**
 * Waits for a single drag in one axis, final pointer up, or all pointers are up. When [pointerId]
 * has lifted, another pointer that is down is chosen to be the finger governing the drag. When the
 * final pointer is lifted, that [PointerInputChange] is returned. When a drag is detected, that
 * [PointerInputChange] is returned. A drag is only detected when [hasDragged] returns `true`.
 *
 * `null` is returned if there was an error in the pointer input stream and the pointer that was
 * down was dropped before the 'up' was received.
 */
private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
    pointerId: PointerId,
    hasDragged: (PointerInputChange) -> Boolean
): PointerInputChange? {
    var pointer = pointerId
    while (true) {
        val event = awaitPointerEvent()
        val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null
        if (dragEvent.changedToUpIgnoreConsumed()) {
            val otherDown = event.changes.fastFirstOrNull { it.pressed }
            if (otherDown == null) {
                // This is the last "up"
                return dragEvent
            } else {
                pointer = otherDown.id
            }
        } else if (hasDragged(dragEvent)) {
            return dragEvent
        }
    }
}

/**
 * Waits for drag motion and uses [orientation] to detect the direction of touch slop detection. It
 * passes [pointerId] as the pointer to examine. If [pointerId] is raised, another pointer from
 * those that are down will be chosen to lead the gesture, and if none are down, `null` is returned.
 * If [pointerId] is not down when [awaitPointerSlopOrCancellation] is called, then `null` is
 * returned.
 *
 * When pointer slop is detected, [onPointerSlopReached] is called with the change and the distance
 * beyond the pointer slop. If [onPointerSlopReached] does not consume the position change, pointer
 * slop will not have been considered detected and the detection will continue or, if it is
 * consumed, the [PointerInputChange] that was consumed will be returned.
 *
 * This works with [awaitTouchSlopOrCancellation] for the other axis to ensure that only horizontal
 * or vertical dragging is done, but not both. It also works for dragging in two ways when using
 * [awaitTouchSlopOrCancellation]
 *
 * @return The [PointerInputChange] of the event that was consumed in [onPointerSlopReached] or
 *   `null` if all pointers are raised or the position change was consumed by another gesture
 *   detector.
 */
internal suspend inline fun AwaitPointerEventScope.awaitPointerSlopOrCancellation(
    pointerId: PointerId,
    pointerType: PointerType,
    orientation: Orientation?,
    onPointerSlopReached: (PointerInputChange, Offset) -> Unit,
): PointerInputChange? {
    if (currentEvent.isPointerUp(pointerId)) {
        return null // The pointer has already been lifted, so the gesture is canceled
    }

    val touchSlop = viewConfiguration.pointerSlop(pointerType)
    var pointer: PointerId = pointerId
    val touchSlopDetector = TouchSlopDetector(orientation)
    while (true) {
        val event = awaitPointerEvent()
        val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null
        if (dragEvent.isConsumed) {
            return null
        } else if (dragEvent.changedToUpIgnoreConsumed()) {
            val otherDown = event.changes.fastFirstOrNull { it.pressed }
            if (otherDown == null) {
                // This is the last "up"
                return null
            } else {
                pointer = otherDown.id
            }
        } else {
            val postSlopOffset = touchSlopDetector.addPointerInputChange(dragEvent, touchSlop)
            if (postSlopOffset.isSpecified) {
                onPointerSlopReached(dragEvent, postSlopOffset)
                if (dragEvent.isConsumed) {
                    return dragEvent
                } else {
                    touchSlopDetector.reset()
                }
            } else {
                // verify that nothing else consumed the drag event
                awaitPointerEvent(PointerEventPass.Final)
                if (dragEvent.isConsumed) {
                    return null
                }
            }
        }
    }
}

/**
 * Detects if touch slop has been crossed after adding a series of [PointerInputChange]. For every
 * new [PointerInputChange] one should add it to this detector using [addPointerInputChange]. If the
 * position change causes the touch slop to be crossed, [addPointerInputChange] will return true.
 */
internal class TouchSlopDetector(val orientation: Orientation? = null) {

    fun Offset.mainAxis() = if (orientation == Orientation.Horizontal) x else y

    fun Offset.crossAxis() = if (orientation == Orientation.Horizontal) y else x

    /** The accumulation of drag deltas in this detector. */
    private var totalPositionChange: Offset = Offset.Zero

    /**
     * Adds [dragEvent] to this detector. If the accumulated position changes crosses the touch slop
     * provided by [touchSlop], this method will return the post slop offset, that is the total
     * accumulated delta change minus the touch slop value, otherwise this should return null.
     */
    fun addPointerInputChange(dragEvent: PointerInputChange, touchSlop: Float): Offset {
        val currentPosition = dragEvent.position
        val previousPosition = dragEvent.previousPosition
        val positionChange = currentPosition - previousPosition
        totalPositionChange += positionChange

        val inDirection =
            if (orientation == null) {
                totalPositionChange.getDistance()
            } else {
                totalPositionChange.mainAxis().absoluteValue
            }

        val hasCrossedSlop = inDirection >= touchSlop

        return if (hasCrossedSlop) {
            calculatePostSlopOffset(touchSlop)
        } else {
            Offset.Unspecified
        }
    }

    /** Resets the accumulator associated with this detector. */
    fun reset() {
        totalPositionChange = Offset.Zero
    }

    private fun calculatePostSlopOffset(touchSlop: Float): Offset {
        return if (orientation == null) {
            val touchSlopOffset =
                totalPositionChange / totalPositionChange.getDistance() * touchSlop
            // update postSlopOffset
            totalPositionChange - touchSlopOffset
        } else {
            val finalMainAxisChange =
                totalPositionChange.mainAxis() - (sign(totalPositionChange.mainAxis()) * touchSlop)
            val finalCrossAxisChange = totalPositionChange.crossAxis()
            if (orientation == Orientation.Horizontal) {
                Offset(finalMainAxisChange, finalCrossAxisChange)
            } else {
                Offset(finalCrossAxisChange, finalMainAxisChange)
            }
        }
    }
}

/**
 * Waits for a long press by examining [pointerId].
 *
 * If that [pointerId] is raised (that is, the user lifts their finger), but another finger
 * ([PointerId]) is down at that time, another pointer will be chosen as the lead for the gesture,
 * and if none are down, `null` is returned.
 *
 * @return The latest [PointerInputChange] associated with a long press or `null` if all pointers
 *   are raised before a long press is detected or another gesture consumed the change.
 *
 * Example Usage:
 *
 * @sample androidx.compose.foundation.samples.AwaitLongPressOrCancellationSample
 */
suspend fun AwaitPointerEventScope.awaitLongPressOrCancellation(
    pointerId: PointerId
): PointerInputChange? {
    if (currentEvent.isPointerUp(pointerId)) {
        return null // The pointer has already been lifted, so the long press is cancelled.
    }

    val initialDown = currentEvent.changes.fastFirstOrNull { it.id == pointerId } ?: return null

    var longPress: PointerInputChange? = null
    var currentDown = initialDown
    val longPressTimeout = viewConfiguration.longPressTimeoutMillis
    return try {
        var deepPress = false
        // wait for first tap up or long press
        withTimeout(longPressTimeout) {
            var finished = false
            while (!finished) {
                val event = awaitPointerEvent(PointerEventPass.Main)
                if (event.changes.fastAll { it.changedToUpIgnoreConsumed() }) {
                    // All pointers are up
                    finished = true
                }

                if (
                    event.changes.fastAny {
                        it.isConsumed || it.isOutOfBounds(size, extendedTouchPadding)
                    }
                ) {
                    finished = true // Canceled
                }

                if (event.isDeepPress) {
                    deepPress = true
                    finished = true
                }

                // Check for cancel by position consumption. We can look on the Final pass of
                // the existing pointer event because it comes after the Main pass we checked
                // above.
                val consumeCheck = awaitPointerEvent(PointerEventPass.Final)
                if (consumeCheck.changes.fastAny { it.isConsumed }) {
                    finished = true
                }
                if (event.isPointerUp(currentDown.id)) {
                    val newPressed = event.changes.fastFirstOrNull { it.pressed }
                    if (newPressed != null) {
                        currentDown = newPressed
                        longPress = currentDown
                    } else {
                        // should technically never happen as we checked it above
                        finished = true
                    }
                    // Pointer (id) stayed down.
                } else {
                    longPress = event.changes.fastFirstOrNull { it.id == currentDown.id }
                }
            }
        }
        // If we finished early because of a deep press, return the relevant change as this counts
        // as a long press
        if (deepPress) {
            longPress ?: initialDown
        } else {
            null
        }
    } catch (_: PointerEventTimeoutCancellationException) {
        longPress ?: initialDown
    }
}

private fun PointerEvent.isPointerUp(pointerId: PointerId): Boolean =
    changes.fastFirstOrNull { it.id == pointerId }?.pressed != true

// This value was determined using experiments and common sense.
// We can't use zero slop, because some hypothetical desktop/mobile devices can send
// pointer events with a very high precision (but I haven't encountered any that send
// events with less than 1px precision)
private val mouseSlop = 0.125.dp
private val defaultTouchSlop = 18.dp // The default touch slop on Android devices
private val mouseToTouchSlopRatio = mouseSlop / defaultTouchSlop

// TODO(demin): consider this as part of ViewConfiguration class after we make *PointerSlop*
//  functions public (see the comment at the top of the file).
//  After it will be a public API, we should get rid of `touchSlop / 144` and return absolute
//  value 0.125.dp.toPx(). It is not possible right now, because we can't access density.
internal fun ViewConfiguration.pointerSlop(pointerType: PointerType): Float {
    return when (pointerType) {
        PointerType.Mouse -> touchSlop * mouseToTouchSlopRatio
        else -> touchSlop
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy