/*
* Copyright 2019 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.
*/
@file: Suppress("DEPRECATION")
package androidx.compose.foundation.legacygestures
import androidx.compose.foundation.fastFilter
import androidx.compose.foundation.fastFold
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputFilter
import androidx.compose.ui.input.pointer.PointerInputModifier
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.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.consumeDownChange
import androidx.compose.ui.input.pointer.consumePositionChange
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.positionChangeConsumed
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
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.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
internal enum class Direction {
LEFT, UP, RIGHT, DOWN
}
/**
* Defines the callbacks associated with dragging.
*/
@Deprecated("Use Modifier.pointerInput { detectDragGestures(...) }")
internal interface DragObserver {
/**
* Override to be notified when a drag has started.
*
* This will be called as soon as the DragGestureDetector is allowed to start (canStartDragging
* is null or returns true) and the average distance the pointers have moved are not 0 on
* both the x and y axes.
*
* Only called if the last called if the most recent call among [onStart], [onStop], and
* [onCancel] was [onStop] or [onCancel].
*
* @param downPosition The average position of all pointer positions when they first touched
* down.
*/
fun onStart(downPosition: Offset) {}
/**
* Override to be notified when a distance has been dragged.
*
* When overridden, return the amount of the [dragDistance] that has been consumed.
*
* Called immediately after [onStart] and for every subsequent pointer movement, as long as the
* movement was enough to constitute a drag (the average movement on the x or y axis is not
* equal to 0).
*
* Note: This may be called multiple times in a single pass and the values should be accumulated
* for each call.
*
* @param dragDistance The distance that has been dragged. Reflects the average drag distance
* of all pointers.
*/
fun onDrag(dragDistance: Offset) = Offset.Zero
/**
* Override to be notified when a drag has stopped.
*
* This is called once all pointers have stopped interacting with this DragGestureDetector.
*
* Only called if the last called if the most recent call among [onStart], [onStop], and
* [onCancel] was [onStart].
*
* @param velocity The velocity of the drag in both orientations at the point in time when all
* pointers have released the relevant PointerInputFilter. In pixels per second.
*/
fun onStop(velocity: Offset) {}
/**
* Override to be notified when the drag has been cancelled.
*
* This is called in response to a cancellation event such as the associated
* PointerInputFilter having been removed from the hierarchy.
*
* Only called if the last called if the most recent call among [onStart], [onStop], and
* [onCancel] was [onStart].
*/
fun onCancel() {}
}
/**
* Modeled after Android's ViewConfiguration:
* https://github.com/android/platform_frameworks_base/blob/master/core/java/android/view/ViewConfiguration.java
*/
/** The time before a long press gesture attempts to win. */
internal const val LongPressTimeoutMillis: Long = 500L
/**
* The maximum time from the start of the first tap to the start of the second
* tap in a double-tap gesture.
*/
// TODO(shepshapard): In Android, this is actually the time from the first's up event
// to the second's down event, according to the ViewConfiguration docs.
internal const val DoubleTapTimeoutMillis: Long = 300L
/**
* The distance a touch has to travel for the framework to be confident that
* the gesture is a scroll gesture, or, inversely, the maximum distance that a
* touch can travel before the framework becomes confident that it is not a
* tap.
*/
// This value was empirically derived. We started at 8.0 and increased it to
// 18.0 after getting complaints that it was too difficult to hit targets.
internal val TouchSlop = 18.dp
// TODO(b/146133703): Likely rename to PanGestureDetector as per b/146133703
/**
* This gesture detector detects dragging in any direction.
*
* Dragging normally begins when the touch slop distance (currently defined by [TouchSlop]) is
* surpassed in a supported direction (see [DragObserver.onDrag]). When dragging begins in this
* manner, [DragObserver.onStart] is called, followed immediately by a call to
* [DragObserver.onDrag]. [DragObserver.onDrag] is then continuously called whenever pointers
* have moved. The gesture ends with either a call to [DragObserver.onStop] or
* [DragObserver.onCancel], only after [DragObserver.onStart] is called. [DragObserver.onStop] is
* called when the dragging ends due to all of the pointers no longer interacting with the
* DragGestureDetector (for example, the last pointer has been lifted off of the
* DragGestureDetector). [DragObserver.onCancel] is called when the dragging ends due to a system
* cancellation event.
*
* If [startDragImmediately] is set to true, dragging will begin as soon as soon as a pointer comes
* in contact with it, effectively ignoring touch slop and blocking any descendants from reacting
* the "down" change. When dragging begins in this manner, [DragObserver.onStart] is called
* immediately and is followed by [DragObserver.onDrag] when some drag distance has occurred.
*
* When multiple pointers are touching the detector, the drag distance is taken as the average of
* all of the pointers.
*
* @param dragObserver The callback interface to report all events related to dragging.
* @param startDragImmediately Set to true to have dragging begin immediately when a pointer is
* "down", preventing children from responding to the "down" change. Generally, this parameter
* should be set to true when the child of the GestureDetector is animating, such that when a finger
* touches it, dragging is immediately started so the animation stops and dragging can occur.
*/
@Deprecated("Use Modifier.pointerInput{ detectDragGestures(... )} instead")
internal fun Modifier.dragGestureFilter(
dragObserver: DragObserver,
startDragImmediately: Boolean = false
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "dragGestureFilter"
properties["dragObserver"] = dragObserver
properties["startDragImmediately"] = startDragImmediately
}
) {
val glue = remember { TouchSlopDragGestureDetectorGlue() }
glue.touchSlopDragObserver = dragObserver
// TODO(b/146427920): There is a gap here where RawPressStartGestureDetector can cause a call to
// DragObserver.onStart but if the pointer doesn't move and releases, (or if cancel is called)
// The appropriate callbacks to DragObserver will not be called.
rawDragGestureFilter(glue.rawDragObserver, glue::enabledOrStarted)
.dragSlopExceededGestureFilter(glue::enableDrag)
.rawPressStartGestureFilter(
glue::startDrag,
startDragImmediately,
PointerEventPass.Initial
)
}
// TODO(shepshapard): Convert to functional component with effects once effects are ready.
// TODO(shepshapard): Should this calculate the drag distance as the average of all fingers
// (Shep thinks this is better), or should it only track the most recent finger to have
// touched the screen over the detector (this is how Android currently does it)?
// TODO(b/139020678): Probably has shared functionality with other movement based detectors.
/**
* This gesture detector detects dragging in any direction.
*
* Note: By default, this gesture detector only waits for a single pointer to have moved to start
* dragging. It is extremely likely that you don't want to use this gesture detector directly, but
* instead use a drag gesture detector that does wait for some other condition to have occurred
* (such as [dragGestureFilter] which waits for a single pointer to have passed touch
* slop before dragging starts).
*
* Dragging begins when the a single pointer has moved and either [canStartDragging] is null or
* returns true. When dragging begins, [DragObserver.onStart] is called. [DragObserver.onDrag] is
* then continuously called whenever the average movement of all pointers has movement along the x
* or y axis. The gesture ends with either a call to [DragObserver.onStop] or
* [DragObserver.onCancel], only after [DragObserver.onStart] is called. [DragObserver.onStop] is
* called when the dragging ends due to all of the pointers no longer interacting with the
* DragGestureDetector (for example, the last pointer has been lifted off of the
* DragGestureDetector). [DragObserver.onCancel] is called when the dragging ends due to a system
* cancellation event.
*
* When multiple pointers are touching the detector, the drag distance is taken as the average of
* all of the pointers.
*
* @param dragObserver The callback interface to report all events related to dragging.
* @param canStartDragging If set, Before dragging is started ([DragObserver.onStart] is called),
* canStartDragging is called to check to see if it is allowed to start.
*/
// TODO(b/129784010): Consider also allowing onStart, onDrag, and onStop to be set individually
// (instead of all being set via DragObserver).
@Deprecated("use Modifier.pointerInput { } with awaitFirstDown() and drag() functions")
internal fun Modifier.rawDragGestureFilter(
dragObserver: DragObserver,
canStartDragging: (() -> Boolean)? = null,
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "rawDragGestureFilter"
properties["dragObserver"] = dragObserver
properties["canStartDragging"] = canStartDragging
}
) {
val filter = remember { RawDragGestureFilter() }
filter.dragObserver = dragObserver
filter.canStartDragging = canStartDragging
DragPointerInputModifierImpl(filter)
}
internal class RawDragGestureFilter : PointerInputFilter() {
private val velocityTrackers: MutableMap<PointerId, VelocityTracker> = mutableMapOf()
private val downPositions: MutableMap<PointerId, Offset> = mutableMapOf()
internal lateinit var dragObserver: DragObserver
internal var canStartDragging: (() -> Boolean)? = null
private var started = false
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize
) {
val changes = pointerEvent.changes
if (pass == PointerEventPass.Initial) {
if (started) {
// If we are have started we want to prevent any descendants from reacting to
// any down change.
changes.fastForEach {
if (it.changedToDown()) {
it.consumeDownChange()
}
}
}
}
if (pass == PointerEventPass.Main) {
// Get the changes for pointers that are relevant to us due to orientation locking.
// Handle up changes, which includes removing individual pointer VelocityTrackers
// and potentially calling onStop().
if (changes.fastAny { it.changedToUpIgnoreConsumed() }) {
// TODO(b/162269614): Should be update to only have one velocity tracker that
// tracks the average change overtime, instead of one for each finger.
var velocityTracker: VelocityTracker? = null
changes.fastForEach {
// This pointer is up (consumed or not), so we should stop tracking
// information about it. If the pointer is not locked out of our
// orientation, get the velocity tracker because this might be a fling.
if (it.changedToUp() && changes.contains(it)) {
velocityTracker = velocityTrackers.remove(it.id)
} else if (it.changedToUpIgnoreConsumed()) {
velocityTrackers.remove(it.id)
}
// removing stored down position for the pointer.
if (it.changedToUp()) {
downPositions.remove(it.id)
}
}
if (changes.fastAll { it.changedToUpIgnoreConsumed() }) {
// All of the pointers are up, so reset and call onStop. If we have a
// velocityTracker at this point, that means at least one of the up events
// was not consumed so we should send velocity for flinging.
if (started) {
val velocity: Offset? =
if (velocityTracker != null) {
changes.fastForEach {
it.consumeDownChange()
}
val velocity = velocityTracker!!.calculateVelocity()
Offset(velocity.x, velocity.y)
} else {
null
}
started = false
dragObserver.onStop(velocity ?: Offset.Zero)
reset()
}
}
}
// Handle down changes: for each new pointer that has been added, start tracking
// information about it.
if (changes.fastAny { it.changedToDownIgnoreConsumed() }) {
changes.fastForEach {
// If a pointer has changed to down, we should start tracking information
// about it.
if (it.changedToDownIgnoreConsumed()) {
velocityTrackers[it.id] = VelocityTracker()
.apply {
addPosition(
it.uptimeMillis,
it.position
)
}
downPositions[it.id] = it.position
}
}
}
// Handle moved changes.
val movedChanges = changes.fastFilter {
it.pressed && !it.changedToDownIgnoreConsumed()
}
movedChanges.fastForEach {
// TODO(shepshapard): handle the case that the pointerTrackingData is null,
// either with an exception or a logged error, or something else.
// TODO(shepshapard): VelocityTracker needs to be updated to not accept
// position information, but rather vector information about movement.
// TODO(b/162269614): Should be update to only have one velocity tracker that
// tracks the average change overtime, instead of one for each finger.
velocityTrackers[it.id]?.addPosition(
it.uptimeMillis,
it.position
)
}
// Check to see if we are already started so we don't have to call canStartDragging
// again.
val canStart = !started && canStartDragging?.invoke() ?: true
// At this point, check to see if we have started, and if we have, we may
// be calling onDrag and updating change information on the PointerInputChanges.
if (started || canStart) {
var totalDx = 0f
var totalDy = 0f
movedChanges.fastForEach {
if (movedChanges.contains(it)) {
totalDx += it.positionChange().x
}
if (movedChanges.contains(it)) {
totalDy += it.positionChange().y
}
}
if (totalDx != 0f || totalDy != 0f) {
// At this point, if we have not started, check to see if we should start
// and if we should, update our state and call onStart().
if (!started) {
started = true
dragObserver.onStart(downPositions.values.averagePosition())
downPositions.clear()
}
val consumed = dragObserver.onDrag(
Offset(
totalDx / changes.size,
totalDy / changes.size
)
)
if (consumed.x != 0f || consumed.y != 0f) {
movedChanges.fastForEach { it.consumePositionChange() }
}
}
}
}
}
override fun onCancel() {
downPositions.clear()
velocityTrackers.clear()
if (started) {
started = false
dragObserver.onCancel()
}
reset()
}
private fun reset() {
downPositions.clear()
velocityTrackers.clear()
}
}
private fun Iterable<Offset>.averagePosition(): Offset {
var x = 0f
var y = 0f
this.forEach {
x += it.x
y += it.y
}
return Offset(x / count(), y / count())
}
/**
* Glues together the logic of RawDragGestureDetector, TouchSlopExceededGestureDetector, and
* InterruptFlingGestureDetector.
*/
private class TouchSlopDragGestureDetectorGlue {
lateinit var touchSlopDragObserver: DragObserver
var started = false
var enabled = false
val enabledOrStarted
get() = started || enabled
fun enableDrag() {
enabled = true
}
fun startDrag(downPosition: Offset) {
started = true
touchSlopDragObserver.onStart(downPosition)
}
val rawDragObserver: DragObserver =
object : DragObserver {
override fun onStart(downPosition: Offset) {
if (!started) {
touchSlopDragObserver.onStart(downPosition)
}
}
override fun onDrag(dragDistance: Offset): Offset {
return touchSlopDragObserver.onDrag(dragDistance)
}
override fun onStop(velocity: Offset) {
started = false
enabled = false
touchSlopDragObserver.onStop(velocity)
}
override fun onCancel() {
started = false
enabled = false
touchSlopDragObserver.onCancel()
}
}
}
/**
* Reacts if the first pointer input change it sees is an unconsumed down change, and if it reacts,
* consumes all further down changes.
*
* This GestureDetector is not generally intended to be used directly, but is instead intended to be
* used as a building block to create more complex GestureDetectors.
*
* This GestureDetector is a bit more experimental then the other GestureDetectors (the number and
* types of GestureDetectors is still very much a work in progress) and is intended to be a
* generically useful building block for more complicated GestureDetectors.
*
* The theory is that this GestureDetector can be reused in PressIndicatorGestureDetector, and there
* could be a corresponding RawPressReleasedGestureDetector.
*
* @param onPressStart Called when the first pointer "presses" on the GestureDetector. [Offset]
* is the position of that first pointer on press.
* @param enabled If false, this GestureDetector will effectively act as if it is not in the
* hierarchy.
* @param executionPass The [PointerEventPass] during which this GestureDetector will attempt to
* react to and consume down changes. Defaults to [PointerEventPass.Main].
*/
@Deprecated("Use Modifier.pointerInput{} with custom gesture detection code")
internal fun Modifier.rawPressStartGestureFilter(
onPressStart: (Offset) -> Unit,
enabled: Boolean = false,
executionPass: PointerEventPass = PointerEventPass.Main
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "rawPressStartGestureFilter"
properties["onPressStart"] = onPressStart
properties["enabled"] = enabled
properties["executionPass"] = executionPass
}
) {
val filter = remember { RawPressStartGestureFilter() }
filter.onPressStart = onPressStart
filter.setEnabled(enabled = enabled)
filter.setExecutionPass(executionPass)
DragPointerInputModifierImpl(filter)
}
internal class RawPressStartGestureFilter : PointerInputFilter() {
lateinit var onPressStart: (Offset) -> Unit
private var enabled: Boolean = true
private var executionPass = PointerEventPass.Initial
private var active = false
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize
) {
val changes = pointerEvent.changes
if (pass == executionPass) {
if (enabled && changes.fastAll { it.changedToDown() }) {
// If we have not yet started and all of the changes changed to down, we are
// starting.
active = true
onPressStart(changes.first().position)
} else if (changes.fastAll { it.changedToUp() }) {
// If we have started and all of the changes changed to up, we are stopping.
active = false
}
if (active) {
// If we have started, we should consume the down change on all changes.
changes.fastForEach {
it.consumeDownChange()
}
}
}
}
override fun onCancel() {
active = false
}
fun setEnabled(enabled: Boolean) {
this.enabled = enabled
// Whenever we are disabled, we can just go ahead and become inactive (which is the state we
// should be in if we are to pretend that we aren't in the hierarchy.
if (!enabled) {
onCancel()
}
}
fun setExecutionPass(executionPass: PointerEventPass) {
this.executionPass = executionPass
}
}
internal fun Modifier.dragSlopExceededGestureFilter(
onDragSlopExceeded: () -> Unit
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "dragSlopExceededGestureFilter"
properties["onDragSlopExceeded"] = onDragSlopExceeded
}
) {
val touchSlop = with(LocalDensity.current) { TouchSlop.toPx() }
val filter = remember {
DragSlopExceededGestureFilter(touchSlop)
}
filter.onDragSlopExceeded = onDragSlopExceeded
DragPointerInputModifierImpl(filter)
}
internal class DragSlopExceededGestureFilter(
private val touchSlop: Float
) : PointerInputFilter() {
private var dxForPass = 0f
private var dyForPass = 0f
private var dxUnderSlop = 0f
private var dyUnderSlop = 0f
private var passedSlop = false
private var canDrag: ((Direction) -> Boolean)? = null
var onDragSlopExceeded: () -> Unit = {}
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize
) {
val changes = pointerEvent.changes
if (pass == PointerEventPass.Main || pass == PointerEventPass.Final) {
// Filter changes for those that we can interact with due to our orientation.
if (!passedSlop) {
// Get current average change.
val averagePositionChange = getAveragePositionChange(changes)
val dx = averagePositionChange.x
val dy = averagePositionChange.y
// Track changes during main and during final. This allows for fancy dragging
// due to a parent being dragged and will likely be removed.
// TODO(b/157087973): Likely remove this two pass complexity.
if (pass == PointerEventPass.Main) {
dxForPass = dx
dyForPass = dy
dxUnderSlop += dx
dyUnderSlop += dy
} else {
dxUnderSlop += dx - dxForPass
dyUnderSlop += dy - dyForPass
}
// Map the distance to the direction enum for a call to canDrag.
val directionX = averagePositionChange.horizontalDirection()
val directionY = averagePositionChange.verticalDirection()
val canDragX = directionX != null && canDrag?.invoke(directionX) ?: true
val canDragY = directionY != null && canDrag?.invoke(directionY) ?: true
val passedSlopX = canDragX && abs(dxUnderSlop) > touchSlop
val passedSlopY = canDragY && abs(dyUnderSlop) > touchSlop
if (passedSlopX || passedSlopY) {
passedSlop = true
onDragSlopExceeded.invoke()
} else {
// If we have passed slop in a direction that we can't drag in, we should reset
// our tracking back to zero so that a user doesn't have to later scroll the slop
// + the extra distance they scrolled in the wrong direction.
if (!canDragX &&
(
(directionX == Direction.LEFT && dxUnderSlop < 0) ||
(directionX == Direction.RIGHT && dxUnderSlop > 0)
)
) {
dxUnderSlop = 0f
}
if (!canDragY &&
(
(directionY == Direction.UP && dyUnderSlop < 0) ||
(directionY == Direction.DOWN && dyUnderSlop > 0)
)
) {
dyUnderSlop = 0f
}
}
}
if (pass == PointerEventPass.Final &&
changes.fastAll { it.changedToUpIgnoreConsumed() }
) {
// On the final pass, check to see if all pointers have changed to up, and if they
// have, reset.
reset()
}
}
}
override fun onCancel() {
reset()
}
private fun reset() {
passedSlop = false
dxForPass = 0f
dyForPass = 0f
dxUnderSlop = 0f
dyUnderSlop = 0f
}
}
/**
* Gets the average distance change of all pointers as an Offset.
*/
private fun getAveragePositionChange(changes: List<PointerInputChange>): Offset {
if (changes.isEmpty()) {
return Offset.Zero
}
val sum = changes.fastFold(Offset.Zero) { sum, change ->
sum + change.positionChange()
}
val sizeAsFloat = changes.size.toFloat()
// TODO(b/148980115): Once PxPosition is removed, sum will be an Offset, and this line can
// just be straight division.
return Offset(sum.x / sizeAsFloat, sum.y / sizeAsFloat)
}
/**
* Maps an [Offset] value to a horizontal [Direction].
*/
private fun Offset.horizontalDirection() =
when {
this.x < 0f -> Direction.LEFT
this.x > 0f -> Direction.RIGHT
else -> null
}
/**
* Maps a [Offset] value to a vertical [Direction].
*/
private fun Offset.verticalDirection() =
when {
this.y < 0f -> Direction.UP
this.y > 0f -> Direction.DOWN
else -> null
}
private data class DragPointerInputModifierImpl(
override val pointerInputFilter: PointerInputFilter
) : PointerInputModifier
@Deprecated("Use Modifier.pointerInput {detectDragGesturesAfterLongPress(...)} instead")
internal interface LongPressDragObserver {
/**
* Override to be notified when a long press has occurred and thus dragging can start.
*
* Note that when this is called, dragging hasn't actually started, but rather, dragging can start. When dragging
* has actually started, [onDragStart] will be called. It is possible for [onDragStart] to be called immediately
* after this synchronously in the same event stream.
*
* This won't be called again until after [onStop] has been called.
*
* @see onDragStart
* @see onDrag
* @see onStop
*/
fun onLongPress(pxPosition: Offset) {}
/**
* Override to be notified when dragging has actually begun.
*
* Dragging has begun when both [onLongPress] has been called, and the average pointer distance change is not 0.
*
* This will not be called until after [onLongPress] has been called, and may be called synchronously,
* immediately afterward [onLongPress], as a result of the same pointer input event.
*
* This will not be called again until [onStop] has been called.
*
* @see onLongPress
* @see onDrag
* @see onStop
*/
fun onDragStart() {}
/**
* Override to be notified when a distance has been dragged.
*
* When overridden, return the amount of the [dragDistance] that has been consumed.
*
* Called after [onDragStart] and for every subsequent pointer movement, as long as the movement
* was enough to constitute a drag (the average movement on the x or y axis is not equal to
* 0). This may be called synchronously, immediately afterward [onDragStart], as a result of
* the same pointer input event.
*
* Note: This will be called multiple times for a single pointer input event and the values
* provided in each call should be considered unique.
*
* @param dragDistance The distance that has been dragged. Reflects the average drag distance
* of all pointers.
*/
fun onDrag(dragDistance: Offset) = Offset.Zero
/**
* Override to be notified when a drag has stopped.
*
* This is called once all pointers have stopped interacting with this DragGestureDetector and
* [onLongPress] was previously called.
*/
fun onStop(velocity: Offset) {}
/**
* Override to be notified when the drag has been cancelled.
*
* This is called if [onLongPress] has ben called and then a cancellation event has occurs
* (for example, due to the gesture detector being removed from the tree) before [onStop] is
* called.
*/
fun onCancel() {}
}
/**
* This gesture detector detects dragging in any direction, but only after a long press has first
* occurred.
*
* Dragging begins once a long press has occurred and then dragging occurs. When long press occurs,
* [LongPressDragObserver.onLongPress] is called. Once dragging has occurred,
* [LongPressDragObserver.onDragStart] will be called. [LongPressDragObserver.onDrag] is then
* continuously called whenever pointer movement results in a drag. The gesture will end
* with either a call to [LongPressDragObserver.onStop] or [LongPressDragObserver.onCancel]. Either
* will be called after [LongPressDragObserver.onLongPress] is called.
* [LongPressDragObserver.onStop] is called when the gesture ends due to all of the pointers
* no longer interacting with the LongPressDragGestureDetector (for example, the last finger has
* been lifted off of the LongPressDragGestureDetector). [LongPressDragObserver.onCancel] is
* called in response to a system cancellation event.
*
* When multiple pointers are touching the detector, the drag distance is taken as the average of
* all of the pointers.
*
* @param longPressDragObserver The callback interface to report all events.
* @see LongPressDragObserver
*/
@Deprecated("Use Modifier.pointerInput { detectDragGesturesAfterLongPress(...)} instead.")
internal fun Modifier.longPressDragGestureFilter(
longPressDragObserver: LongPressDragObserver
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "longPressDragGestureFilter"
properties["longPressDragObserver"] = longPressDragObserver
}
) {
val glue = remember { LongPressDragGestureDetectorGlue() }
glue.longPressDragObserver = longPressDragObserver
rawDragGestureFilter(glue.dragObserver, glue::dragEnabled)
.then(LongPointerInputModifierImpl(glue))
.longPressGestureFilter(glue.onLongPress)
}
private class LongPressDragGestureDetectorGlue : PointerInputFilter() {
lateinit var longPressDragObserver: LongPressDragObserver
private var dragStarted: Boolean = false
var dragEnabled: Boolean = false
val dragObserver: DragObserver =
object : DragObserver {
override fun onStart(downPosition: Offset) {
longPressDragObserver.onDragStart()
dragStarted = true
}
override fun onDrag(dragDistance: Offset): Offset {
return longPressDragObserver.onDrag(dragDistance)
}
override fun onStop(velocity: Offset) {
dragEnabled = false
dragStarted = false
longPressDragObserver.onStop(velocity)
}
override fun onCancel() {
dragEnabled = false
dragStarted = false
longPressDragObserver.onCancel()
}
}
// This handler ensures that onStop will be called after long press happened, but before
// dragging actually has begun.
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize
) {
if (pass == PointerEventPass.Main &&
dragEnabled &&
!dragStarted &&
pointerEvent.changes.fastAll { it.changedToUpIgnoreConsumed() }
) {
dragEnabled = false
longPressDragObserver.onStop(Offset.Zero)
}
}
// This handler ensures that onCancel is called if onLongPress was previously called but
// dragging has not yet started.
override fun onCancel() {
if (dragEnabled && !dragStarted) {
dragEnabled = false
longPressDragObserver.onCancel()
}
}
val onLongPress = { pxPosition: Offset ->
dragEnabled = true
longPressDragObserver.onLongPress(pxPosition)
}
}
internal fun Modifier.longPressGestureFilter(
onLongPress: (Offset) -> Unit
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "longPressGestureFilter"
properties["onLongPress"] = onLongPress
}
) {
@Suppress("DEPRECATION")
val scope = rememberCoroutineScope()
val filter = remember { LongPressGestureFilter(scope) }
filter.onLongPress = onLongPress
LongPointerInputModifierImpl(filter)
}
internal class LongPressGestureFilter(
private val coroutineScope: CoroutineScope
) : PointerInputFilter() {
lateinit var onLongPress: (Offset) -> Unit
/*@VisibleForTesting*/
internal var longPressTimeout = LongPressTimeoutMillis
private enum class State {
Idle, Primed, Fired
}
private var state = State.Idle
private val pointerPositions = linkedMapOf<PointerId, Offset>()
private var job: Job? = null
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize
) {
val changes = pointerEvent.changes
if (pass == PointerEventPass.Initial) {
if (state == State.Fired) {
// If we fired and have not reset, we should prevent other pointer input nodes from
// responding to up, so consume it early on.
changes.fastForEach {
if (it.changedToUp()) {
it.consumeDownChange()
}
}
}
return
}
if (pass == PointerEventPass.Main) {
if (state == State.Idle && changes.fastAll { it.changedToDown() }) {
// If we are idle and all of the changes changed to down, we are prime to fire
// the event.
primeToFire()
} else if (state != State.Idle && changes.fastAll { it.changedToUpIgnoreConsumed() }) {
// If we have started and all of the changes changed to up, reset to idle.
resetToIdle()
} else if (!changes.anyPointersInBounds(bounds)) {
// If all pointers have gone out of bounds, reset to idle.
resetToIdle()
}
if (state == State.Primed) {
// If we are primed, keep track of all down pointer positions so we can pass
// pointer position information to the event we will fire.
changes.fastForEach {
if (it.pressed) {
pointerPositions[it.id] = it.position
} else {
pointerPositions.remove(it.id)
}
}
}
}
if (
pass == PointerEventPass.Final &&
state != State.Idle &&
changes.fastAny { it.positionChangeConsumed() }
) {
// If we are anything but Idle and something consumed movement, reset.
resetToIdle()
}
}
// TODO(shepshapard): This continues to be very confusing to use. Have to come up with a better
// way of easily expressing this.
/**
* Utility method that determines if any pointers are currently in [bounds].
*
* A pointer is considered in bounds if it is currently down and it's current
* position is within the provided [bounds]
*
* @return True if at least one pointer is in bounds.
*/
private fun List<PointerInputChange>.anyPointersInBounds(bounds: IntSize) =
fastAny {
it.pressed &&
it.position.x >= 0 &&
it.position.x < bounds.width &&
it.position.y >= 0 &&
it.position.y < bounds.height
}
override fun onCancel() {
resetToIdle()
}
private fun fireLongPress() {
state = State.Fired
onLongPress.invoke(pointerPositions.asIterable().first().value)
}
private fun primeToFire() {
state = State.Primed
job = coroutineScope.launch {
delay(longPressTimeout)
fireLongPress()
}
}
private fun resetToIdle() {
state = State.Idle
job?.cancel()
pointerPositions.clear()
}
}
private data class LongPointerInputModifierImpl(
override val pointerInputFilter: PointerInputFilter
) : PointerInputModifier