[go: nahoru, domu]

Preliminary implementation of DragGestureDetector

Bug: 124533040
Test: DragGestureDetectorTest

Change-Id: I936869fe9f52f85f63f7c4e54cb1e771ac276fba
diff --git a/ui/framework/src/main/java/androidx/ui/core/Wrapper.kt b/ui/framework/src/main/java/androidx/ui/core/Wrapper.kt
index a53f5f0..7a1217d 100644
--- a/ui/framework/src/main/java/androidx/ui/core/Wrapper.kt
+++ b/ui/framework/src/main/java/androidx/ui/core/Wrapper.kt
@@ -75,7 +75,7 @@
 
 val ContextAmbient = Ambient.of<Context>()
 
-internal val DensityAmbient = Ambient.of<Density>()
+val DensityAmbient = Ambient.of<Density>()
 
 /**
  * [ambient] to get a [Density] object from an internal [DensityAmbient].
diff --git a/ui/framework/src/main/java/androidx/ui/core/gesture/DragGestureDetector.kt b/ui/framework/src/main/java/androidx/ui/core/gesture/DragGestureDetector.kt
new file mode 100644
index 0000000..b1acdca
--- /dev/null
+++ b/ui/framework/src/main/java/androidx/ui/core/gesture/DragGestureDetector.kt
@@ -0,0 +1,248 @@
+/*
+ * 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.
+ */
+
+package androidx.ui.core.gesture
+
+import androidx.ui.core.DensityAmbient
+import androidx.ui.core.Direction
+import androidx.ui.core.PointerEventPass
+import androidx.ui.core.PointerInput
+import androidx.ui.core.PointerInputChange
+import androidx.ui.core.PxPosition
+import androidx.ui.core.changedToDown
+import androidx.ui.core.changedToDownIgnoreConsumed
+import androidx.ui.core.changedToUp
+import androidx.ui.core.changedToUpIgnoreConsumed
+import androidx.ui.core.consumeDownChange
+import androidx.ui.core.consumePositionChange
+import androidx.ui.core.gesture.util.VelocityTracker
+import androidx.ui.core.ipx
+import androidx.ui.core.positionChange
+import androidx.ui.core.px
+import androidx.ui.core.withDensity
+import com.google.r4a.Children
+import com.google.r4a.Component
+import com.google.r4a.composer
+
+open class DragObserver {
+    open fun onStart() {}
+    open fun onDrag(dragDistance: PxPosition) = dragDistance
+    open fun onStop(velocity: PxPosition) {}
+}
+
+// TODO(shepshapard): Convert to functional component with effects once effects are ready.
+/**
+ *
+ */
+class DragGestureDetector(
+    @Children var children: () -> Unit
+) : Component() {
+
+    private val recognizer = DragGestureRecognizer()
+
+    var canDrag: ((Direction) -> Boolean)?
+        get() = recognizer.canDrag
+        set(value) {
+            recognizer.canDrag = value
+        }
+    // TODO(b/129784010): Consider also allowing onStart, onDrag, and onEnd to be set individually.
+    var dragObserver
+        get() = recognizer.dragObserver
+        set(value) {
+            recognizer.dragObserver = value
+        }
+
+    override fun compose() {
+        <DensityAmbient.Consumer> density ->
+            withDensity(density) {
+                recognizer.touchSlop = TouchSlop.toIntPx()
+                <PointerInput pointerInputHandler=recognizer.pointerInputHandler>
+                    <children />
+                </PointerInput>
+            }
+        </DensityAmbient.Consumer>
+    }
+}
+
+internal class DragGestureRecognizer {
+    private val pointerTrackers: MutableMap<Int, PointerTrackingData> = mutableMapOf()
+    private var passedSlop = false
+    private var pointerCount = 0
+    var touchSlop = 0.ipx
+
+    var canDrag: ((Direction) -> Boolean)? = null
+    var dragObserver: DragObserver? = null
+
+    val pointerInputHandler = { pointerInputChange: PointerInputChange, pass: PointerEventPass ->
+        var change: PointerInputChange = pointerInputChange
+
+        if (pass == PointerEventPass.InitialDown && change.changedToDownIgnoreConsumed()) {
+            pointerCount++
+        }
+
+        if (pass == PointerEventPass.InitialDown && change.changedToDown() && passedSlop) {
+            // If we are passedSlop, we are actively dragging so we want to prevent any children
+            // from reacting to any down change.
+            change = change.consumeDownChange()
+        }
+
+        if (pass == PointerEventPass.PostUp) {
+            if (change.changedToUpIgnoreConsumed()) {
+                // This pointer is up (consumed or not), so we should stop tracking information
+                // about it.  Get a reference for the velocity tracker in case this is the last
+                // pointer and thus we are going to fling.
+                val velocityTracker = pointerTrackers.remove(change.id)?.velocityTracker
+                if (pointerCount == 1) {
+                    if (passedSlop && change.changedToUp()) {
+                        // There is one pointer, we have passed slop, and there was an unconsumed
+                        // up event, so we should fire the onStop with the velocity tracked
+                        // for the last pointer.
+
+                        // TODO(shepshapard): handle the case that the velocity tracker for the
+                        // given pointer is null, by throwing an exception or printing a warning,
+                        // or something else.
+                        val velocity =
+                            velocityTracker?.calculateVelocity()?.pixelsPerSecond
+                                ?: PxPosition.Origin
+                        velocityTracker?.resetTracking()
+                        dragObserver?.onStop(PxPosition(velocity.x, velocity.y))
+
+                        // We responded to the up change, so consume it.
+                        change = change.consumeDownChange()
+                    }
+                    // The last pointer is up whether or not up was consumed, so we should reset
+                    // that we passed slop.
+                    passedSlop = false
+                }
+            } else if (change.changedToDownIgnoreConsumed()) {
+                // If a pointer has changed to down, we should start tracking information about it.
+                pointerTrackers[change.id] = PointerTrackingData()
+                    .apply {
+                        velocityTracker.addPosition(
+                            change.current.timestamp!!,
+                            PxPosition(
+                                change.current.position!!.dx.px,
+                                change.current.position!!.dy.px
+                            )
+                        )
+                    }
+            } else if (change.current.down) {
+                // TODO(shepshapard): handle the case that the pointerTrackingData is null, either
+                // with an exception or a logged error, or something else.
+                val pointerTracker: PointerTrackingData? = pointerTrackers[change.id]
+
+                if (pointerTracker != null) {
+                    // If the pointer is currently down, we should track its velocity.
+                    pointerTracker.velocityTracker.addPosition(
+                        change.current.timestamp!!,
+                        PxPosition(
+                            change.current.position!!.dx.px,
+                            change.current.position!!.dy.px
+                        )
+                    )
+
+                    val dx = change.positionChange().dx
+                    val dy = change.positionChange().dy
+
+                    // If we aren't passed slop, calculate things related to slop, and start drag
+                    // if we do pass touch slop.
+                    if (!passedSlop) {
+                        // TODO(shepshapard): I believe the logic in this block could be simplified
+                        // to be much more clear.  Will need to revisit. The need to make
+                        // improvements may be rendered obsolete with upcoming changes however.
+
+                        val directionX = when {
+                            dx == 0f -> null
+                            dx < 0f -> Direction.LEFT
+                            else -> Direction.RIGHT
+                        }
+                        val directionY = when {
+                            dy == 0f -> null
+                            dy < 0f -> Direction.UP
+                            else -> Direction.DOWN
+                        }
+
+                        val canDragX =
+                            if (directionX != null) {
+                                canDrag?.invoke(directionX) ?: true
+                            } else false
+                        val canDragY =
+                            if (directionY != null) {
+                                canDrag?.invoke(directionY) ?: true
+                            } else false
+
+                        pointerTracker.dxUnderSlop += dx
+                        pointerTracker.dyUnderSlop += dy
+
+                        val passedSlopX =
+                            canDragX && Math.abs(pointerTracker.dxUnderSlop) > touchSlop.value
+                        val passedSlopY =
+                            canDragY && Math.abs(pointerTracker.dyUnderSlop) > touchSlop.value
+
+                        if (passedSlopX || passedSlopY) {
+                            passedSlop = true
+                            dragObserver?.onStart()
+                        } else {
+                            if (!canDragX &&
+                                ((directionX == Direction.LEFT && pointerTracker.dxUnderSlop < 0) ||
+                                        (directionX == Direction.RIGHT &&
+                                                pointerTracker.dxUnderSlop > 0))
+                            ) {
+                                pointerTracker.dxUnderSlop = 0f
+                            }
+                            if (!canDragY &&
+                                ((directionY == Direction.LEFT && pointerTracker.dyUnderSlop < 0) ||
+                                        (directionY == Direction.DOWN &&
+                                                pointerTracker.dyUnderSlop > 0))
+                            ) {
+                                pointerTracker.dyUnderSlop = 0f
+                            }
+                        }
+                    }
+
+                    // At this point, check to see if we have passed touch slop, and if we have
+                    // go ahead and drag and consume.
+                    if (passedSlop) {
+                        change = dragObserver?.run {
+                            val (consumedDx, consumedDy) = onDrag(
+                                PxPosition(
+                                    dx.px,
+                                    dy.px
+                                )
+                            )
+                            pointerInputChange.consumePositionChange(
+                                consumedDx.value,
+                                consumedDy.value
+                            )
+                        } ?: pointerInputChange
+                    }
+                }
+            }
+        }
+
+        if (pass == PointerEventPass.PostDown && change.changedToUpIgnoreConsumed()) {
+            pointerCount--
+        }
+
+        change
+    }
+
+    internal data class PointerTrackingData(
+        val velocityTracker: VelocityTracker = VelocityTracker(),
+        var dxUnderSlop: Float = 0f,
+        var dyUnderSlop: Float = 0f
+    )
+}
\ No newline at end of file
diff --git a/ui/framework/src/main/java/androidx/ui/core/gesture/constants.kt b/ui/framework/src/main/java/androidx/ui/core/gesture/constants.kt
new file mode 100644
index 0000000..714bd82
--- /dev/null
+++ b/ui/framework/src/main/java/androidx/ui/core/gesture/constants.kt
@@ -0,0 +1,143 @@
+/*
+ * 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.
+ */
+
+package androidx.ui.core.gesture
+
+import androidx.ui.core.Duration
+import androidx.ui.core.dp
+import androidx.ui.core.milliseconds
+
+/**
+ * Modeled after Android's ViewConfiguration:
+ * https://github.com/android/platform_frameworks_base/blob/master/core/java/android/view/ViewConfiguration.java
+ */
+
+/**
+ * The time that must elapse before a tap gesture sends onTapDown, if there's
+ * any doubt that the gesture is a tap.
+ */
+val PressTimeout: Duration = 100.milliseconds
+
+/**
+ * Maximum length of time between a tap down and a tap up for the gesture to be
+ * considered a tap. (Currently not honored by the TapGestureRecognizer.)
+ */
+// TODO(shepshapard): Remove this, or implement a hover-tap gesture recognizer which
+// uses this.
+val HoverTapTimeout: Duration = 150.milliseconds
+
+/**
+ * Maximum distance between the down and up pointers for a tap. (Currently not
+ * honored by the [TapGestureRecognizer]; [PrimaryPointerGestureRecognizer],
+ * which TapGestureRecognizer inherits from, uses [kTouchSlop].)
+ */
+// TODO(shepshapard): Remove this or implement it correctly.
+val HoverTapSlop = 20.dp
+
+/** The time before a long press gesture attempts to win. */
+val LongPressTimeout: Duration = 500.milliseconds
+
+/**
+ * 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.
+val DoubleTapTimeout: Duration = 300.milliseconds
+
+/**
+ * The minimum time from the end of the first tap to the start of the second
+ * tap in a double-tap gesture. (Currently not honored by the
+ * DoubleTapGestureRecognizer.)
+ */
+// TODO(shepshapard): Either implement this or remove the constant.
+val DoubleTapMinTime: Duration = 40.milliseconds
+
+/**
+ * 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.
+val TouchSlop = 18.dp
+
+/**
+ * The maximum distance that the first touch in a double-tap gesture can travel
+ * before deciding that it is not part of a double-tap gesture.
+ * DoubleTapGestureRecognizer also restricts the second touch to this distance.
+ */
+val DoubleTapTouchSlop = TouchSlop
+
+/**
+ * Distance between the initial position of the first touch and the start
+ * position of a potential second touch for the second touch to be considered
+ * the second touch of a double-tap gesture.
+ */
+val DoubleTapSlop = 100.dp
+
+/**
+ * The time for which zoom controls (e.g. in a map interface) are to be
+ * displayed on the screen, from the moment they were last requested.
+ */
+val ZoomControlsTimeout: Duration = 3000.milliseconds
+
+/**
+ * The distance a touch has to travel for the framework to be confident that
+ * the gesture is a paging gesture. (Currently not used, because paging uses a
+ * regular drag gesture, which uses kTouchSlop.)
+ */
+// TODO(shepshapard): Create variants of HorizontalDragGestureRecognizer et al for
+// paging, which use this constant.
+val PagingTouchSlop = TouchSlop * 2.dp
+
+/**
+ * The distance a touch has to travel for the framework to be confident that
+ * the gesture is a panning gesture.
+ */
+val PanSlop = TouchSlop * 2.dp
+
+/**
+ * The distance a touch has to travel for the framework to be confident that
+ * the gesture is a scale gesture.
+ */
+val ScaleSlop = TouchSlop
+
+/**
+ * The margin around a dialog, popup menu, or other window-like widget inside
+ * which we do not consider a tap to dismiss the widget. (Not currently used.)
+ */
+// TODO(shepshapard): Make ModalBarrier support this.
+val WindowTouchSlop = 16.dp
+
+/**
+ * The minimum velocity for a touch to consider that touch to trigger a fling
+ * gesture.
+ */
+// TODO(shepshapard): Make sure nobody has their own version of this.
+val MinFlingVelocity = 50.dp // Logical pixels / second
+
+/** Drag gesture fling velocities are clipped to this value. */
+// TODO(shepshapard): Make sure nobody has their own version of this.
+val MaxFlingVelocity = 8000.dp // Logical pixels / second
+
+/**
+ * The maximum time from the start of the first tap to the start of the second
+ * tap in a jump-tap gesture.
+ */
+// TODO(shepshapard): Implement jump-tap gestures.
+val JumpTapTimeout: Duration = 500.milliseconds
diff --git a/ui/framework/src/main/java/androidx/ui/core/gesture/util/VelocityTracker.kt b/ui/framework/src/main/java/androidx/ui/core/gesture/util/VelocityTracker.kt
index 9f969d7..2275aa1 100644
--- a/ui/framework/src/main/java/androidx/ui/core/gesture/util/VelocityTracker.kt
+++ b/ui/framework/src/main/java/androidx/ui/core/gesture/util/VelocityTracker.kt
@@ -17,14 +17,11 @@
 package androidx.ui.core.gesture.util
 
 import androidx.ui.core.Duration
-import androidx.ui.core.Px
 import androidx.ui.core.PxPosition
 import androidx.ui.core.Timestamp
 import androidx.ui.core.Velocity
-import androidx.ui.core.getDistance
 import androidx.ui.core.inMilliseconds
 import androidx.ui.core.px
-import java.lang.IllegalArgumentException
 import kotlin.math.absoluteValue
 
 private const val AssumePointerMoveStoppedMilliseconds: Int = 40
@@ -63,29 +60,11 @@
     }
 
     /**
-     * Computes the velocity of the pointer at the time of the last provided data point.
+     * Computes the estimated velocity of the pointer at the time of the last provided data point.
      *
      * This can be expensive. Only call this when you need the velocity.
-     *
-     * @param min Pixels per second.  If the velocity falls below this, the returned velocity will
-     * be 0.
-     * @param max Pixels per second.  If the velocity exceeds this, the returned velocity will be 0.
-     * @return `null` if there is no data from which to compute an estimate.
      */
-    fun calculateVelocity(
-        min: Px = 0f.px,
-        max: Px = Float.MAX_VALUE.px
-    ): Velocity {
-        val estimate: VelocityEstimate = getVelocityEstimate()
-
-        // If we are outside the bounds of allowable velocity, return a velocity of 0.
-        val distance = estimate.pixelsPerSecond.getDistance()
-        if (distance < min || distance > max) {
-            return Velocity.Zero
-        }
-
-        return Velocity(pixelsPerSecond = estimate.pixelsPerSecond)
-    }
+    fun calculateVelocity() = Velocity(pixelsPerSecond = getVelocityEstimate().pixelsPerSecond)
 
     /**
      * Clears the tracked positions added by [addPosition].
diff --git a/ui/framework/src/test/java/androidx/ui/core/gesture/DragGestureDetectorTest.kt b/ui/framework/src/test/java/androidx/ui/core/gesture/DragGestureDetectorTest.kt
new file mode 100644
index 0000000..119b417
--- /dev/null
+++ b/ui/framework/src/test/java/androidx/ui/core/gesture/DragGestureDetectorTest.kt
@@ -0,0 +1,754 @@
+/*
+ * 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.
+ */
+
+package androidx.ui.core.gesture
+
+import androidx.ui.core.Direction
+import androidx.ui.core.Duration
+import androidx.ui.core.PxPosition
+import androidx.ui.core.anyPositionChangeConsumed
+import androidx.ui.core.consumeDownChange
+import androidx.ui.core.consumePositionChange
+import androidx.ui.core.ipx
+import androidx.ui.core.millisecondsToTimestamp
+import androidx.ui.core.px
+import androidx.ui.testutils.consume
+import androidx.ui.testutils.down
+import androidx.ui.testutils.invokeOverAllPasses
+import androidx.ui.testutils.moveBy
+import androidx.ui.testutils.moveTo
+import androidx.ui.testutils.up
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+// TODO(shepshapard): Write the following tests.
+// Verify correct shape of slop area (should it be a square or circle)?
+// Test for cases with more than one pointer
+// Test for cases where things are reset when last pointer goes up
+// Verify all methods called during onPostUp
+// Verify default behavior when no callback provided for recognizer or canDrag
+
+// Changing this value will break tests that expect the value to be 10.
+private const val TestTouchSlop = 10
+
+@RunWith(JUnit4::class)
+class DragGestureDetectorTest {
+
+    private lateinit var recognizer: DragGestureRecognizer
+    private lateinit var canDragMockTrue: MockCanDrag
+    private lateinit var log: MutableList<LogItem>
+
+    @Before
+    fun setup() {
+        log = mutableListOf()
+        recognizer = DragGestureRecognizer()
+        recognizer.touchSlop = TestTouchSlop.ipx
+        canDragMockTrue = MockCanDrag(Direction.values(), log)
+    }
+
+    // Verify the circumstances under which canDrag should not be called.
+
+    @Test
+    fun pointerInputHandler_down_canDragNotCalled() {
+        recognizer.canDrag = canDragMockTrue
+        recognizer.pointerInputHandler.invokeOverAllPasses(down())
+        assertThat(log).isEmpty()
+    }
+
+    @Test
+    fun pointerInputHandler_downMoveFullyConsumed_canDragNotCalled() {
+        recognizer.canDrag = canDragMockTrue
+
+        val down = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        recognizer.pointerInputHandler.invokeOverAllPasses(
+            down.moveBy(Duration(milliseconds = 10), 3f, 5f).consume(3f, 5f)
+        )
+
+        assertThat(log).isEmpty()
+    }
+
+    // Verify the circumstances under which canDrag should be called.
+
+    @Test
+    fun pointerInputHandler_downMove1Dimension_canDragCalledOnce() {
+        recognizer.canDrag = canDragMockTrue
+
+        val down = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        recognizer.pointerInputHandler.invokeOverAllPasses(
+            down.moveBy(Duration(milliseconds = 10), 3f, 0f)
+        )
+
+        assertThat(log.filter { it.methodName == "canDrag" }).hasSize(1)
+    }
+
+    @Test
+    fun pointerInputHandler_downMove2Dimensions_canDragCalledTwice() {
+        recognizer.canDrag = canDragMockTrue
+
+        val down = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        recognizer.pointerInputHandler.invokeOverAllPasses(
+            down.moveBy(Duration(milliseconds = 10), 3f, 5f)
+        )
+
+        assertThat(log.filter { it.methodName == "canDrag" }).hasSize(2)
+    }
+
+    @Test
+    fun pointerInputHandler_downMoveOneDimensionPartiallyConsumed_canDragCalledOnce() {
+        recognizer.canDrag = canDragMockTrue
+
+        val down = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        recognizer.pointerInputHandler.invokeOverAllPasses(
+            down.moveBy(Duration(milliseconds = 10), 0f, 5f).consume(0f, 4f)
+        )
+
+        assertThat(log.filter { it.methodName == "canDrag" }).hasSize(1)
+    }
+
+    @Test
+    fun pointerInputHandler_downMoveTwoDimensionPartiallyConsumed_canDragCalledTwice() {
+        recognizer.canDrag = canDragMockTrue
+        val down = down()
+
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        recognizer.pointerInputHandler.invokeOverAllPasses(
+            down.moveBy(Duration(milliseconds = 10), 3f, 5f).consume(2f, 4f)
+        )
+
+        assertThat(log.filter { it.methodName == "canDrag" }).hasSize(2)
+    }
+
+    @Test
+    fun pointerInputHandler_dragPastTouchSlopOneDimensionAndDrag3MoreTimes_canDragCalledOnce() {
+        val justBeyondSlop = (TestTouchSlop + 1).toFloat()
+        recognizer.canDrag = canDragMockTrue
+
+        val down = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        var move = down.moveTo(10L.millisecondsToTimestamp(), 0f, justBeyondSlop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(move)
+        repeat(3) {
+            move = move.moveBy(Duration(milliseconds = 10), 0f, 1f)
+            recognizer.pointerInputHandler.invokeOverAllPasses(move)
+        }
+
+        assertThat(log.filter { it.methodName == "canDrag" }).hasSize(1)
+    }
+
+    @Test
+    fun pointerInputHandler_downMoveUnderSlop3Times_canDragCalled3Times() {
+        val thirdSlop = TestTouchSlop.toFloat() / 3
+        recognizer.canDrag = canDragMockTrue
+
+        var event = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(event)
+        repeat(3) {
+            event = event.moveBy(Duration(milliseconds = 10), 0f, thirdSlop)
+            recognizer.pointerInputHandler.invokeOverAllPasses(event)
+        }
+
+        assertThat(log.filter { it.methodName == "canDrag" }).hasSize(3)
+    }
+
+    @Test
+    fun pointerInputHandler_moveBeyondSlopThenIntoTouchSlopAreaAndOutAgain_canDragCalledOnce() {
+        val beyondTouchSlop = (TestTouchSlop + 1).toFloat()
+        recognizer.canDrag = canDragMockTrue
+
+        var event = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(event)
+        // Out of touch slop region
+        event = event.moveBy(Duration(milliseconds = 10), 0f, beyondTouchSlop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(event)
+        // Back into touch slop region
+        event = event.moveBy(Duration(milliseconds = 10), 0f, -beyondTouchSlop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(event)
+        // Out of touch slop region again
+        event = event.moveBy(Duration(milliseconds = 10), 0f, beyondTouchSlop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(event)
+
+        assertThat(log.filter { it.methodName == "canDrag" }).hasSize(1)
+    }
+
+    // Verification of correctness of values passed to onDrag.
+
+    @Test
+    fun pointerInputHandler_canDragCalledWithCorrectDirection() {
+        pointerInputHandler_canDragCalledWithCorrectDirection(-1f, 0f, arrayOf(Direction.LEFT))
+        pointerInputHandler_canDragCalledWithCorrectDirection(0f, -1f, arrayOf(Direction.UP))
+        pointerInputHandler_canDragCalledWithCorrectDirection(1f, 0f, arrayOf(Direction.RIGHT))
+        pointerInputHandler_canDragCalledWithCorrectDirection(0f, 1f, arrayOf(Direction.DOWN))
+
+        pointerInputHandler_canDragCalledWithCorrectDirection(
+            -1f,
+            -1f,
+            arrayOf(Direction.LEFT, Direction.UP)
+        )
+        pointerInputHandler_canDragCalledWithCorrectDirection(
+            -1f,
+            1f,
+            arrayOf(Direction.LEFT, Direction.DOWN)
+        )
+        pointerInputHandler_canDragCalledWithCorrectDirection(
+            1f,
+            -1f,
+            arrayOf(Direction.RIGHT, Direction.UP)
+        )
+        pointerInputHandler_canDragCalledWithCorrectDirection(
+            1f,
+            1f,
+            arrayOf(Direction.RIGHT, Direction.DOWN)
+        )
+    }
+
+    private fun pointerInputHandler_canDragCalledWithCorrectDirection(
+        dx: Float,
+        dy: Float,
+        expectedDirections: Array<Direction>
+    ) {
+        log.clear()
+        recognizer.canDrag = canDragMockTrue
+
+        val down = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        recognizer.pointerInputHandler.invokeOverAllPasses(
+            down.moveBy(Duration(milliseconds = 10), dx, dy)
+        )
+
+        assertThat(log).hasSize(expectedDirections.size)
+        expectedDirections.forEach {
+            log.contains(LogItem("canDrag", direction = it))
+        }
+    }
+
+    // Verify the circumstances under which onStart/OnDrag should not be called.
+
+    // TODO(b/129701831): This test assumes that if a pointer moves by slop in both x and y, we are
+    // still under slop even though sqrt(slop^2 + slop^2) > slop.  This may be inaccurate and this
+    // test may therefore need to be updated.
+    @Test
+    fun pointerInputHandler_downMoveWithinSlop_onStartAndOnDragNotCalled() {
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        val down = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        recognizer.pointerInputHandler.invokeOverAllPasses(
+            down.moveBy(
+                Duration(milliseconds = 10),
+                TestTouchSlop.toFloat(),
+                TestTouchSlop.toFloat()
+            )
+        )
+
+        assertThat(log.filter { it.methodName == "onStart" }).hasSize(0)
+        assertThat(log.filter { it.methodName == "onDrag" }).hasSize(0)
+    }
+
+    @Test
+    fun pointerInputHandler_moveBeyondSlopInUnsupportedDirection_onStartAndOnDragNotCalled() {
+        val beyondSlop = (TestTouchSlop + 1).toFloat()
+        recognizer.canDrag = MockCanDrag(arrayOf(), log)
+        recognizer.dragObserver = MockDragObserver(log)
+
+        val down = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        recognizer.pointerInputHandler.invokeOverAllPasses(
+            down.moveBy(
+                Duration(milliseconds = 10),
+                beyondSlop,
+                beyondSlop
+            )
+        )
+
+        assertThat(log.filter { it.methodName == "onStart" }).hasSize(0)
+        assertThat(log.filter { it.methodName == "onDrag" }).hasSize(0)
+    }
+
+    // TODO(b/129701831): This test assumes that if a pointer moves by slop in both x and y, we are
+    // still under slop even though sqrt(slop^2 + slop^2) > slop.  This may be inaccurate and this
+    // test may therefore need to be updated.
+    @Test
+    fun pointerInputHandler_moveAroundWithinSlop_onStartAndOnDragNotCalled() {
+        val slop = TestTouchSlop.toFloat()
+        val doubleSlop = slop * 2
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        // Go around the border of the touch slop area
+
+        // To top left
+        change = change.moveTo(10L.millisecondsToTimestamp(), -slop, -slop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        // To bottom left
+        change = change.moveTo(20L.millisecondsToTimestamp(), -slop, slop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        // To bottom right
+        change = change.moveTo(30L.millisecondsToTimestamp(), slop, slop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        // To top right
+        change = change.moveTo(40L.millisecondsToTimestamp(), slop, -slop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        // Jump from corner to opposite corner and back
+
+        // To bottom left
+        change = change.moveTo(50L.millisecondsToTimestamp(), -slop, slop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        // To top right
+        change = change.moveTo(60L.millisecondsToTimestamp(), slop, -slop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        // Move the other diagonal
+
+        // To top left
+        change = change.moveTo(70L.millisecondsToTimestamp(), -slop, -slop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        // Jump from corner to opposite corner and back
+
+        // To bottom right
+        change = change.moveTo(80L.millisecondsToTimestamp(), slop, slop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        // To top left
+        change = change.moveTo(90L.millisecondsToTimestamp(), -slop, -slop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(log.filter { it.methodName == "onStart" }).hasSize(0)
+        assertThat(log.filter { it.methodName == "onDrag" }).hasSize(0)
+    }
+
+    // Verify the circumstances under which onStart/OnDrag should be called.
+
+    @Test
+    fun pointerInputHandler_movePassedSlop_onStartAndOnDragCalledOnce() {
+        val beyondTouchSlop = (TestTouchSlop + 1).toFloat()
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+        val down = down()
+
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        recognizer.pointerInputHandler.invokeOverAllPasses(
+            down.moveBy(
+                Duration(milliseconds = 100),
+                beyondTouchSlop,
+                0f
+            )
+        )
+
+        assertThat(log.filter { it.methodName == "onStart" }).hasSize(1)
+        assertThat(log.filter { it.methodName == "onDrag" }).hasSize(1)
+    }
+
+    @Test
+    fun pointerInputHandler_passSlopThenIntoSlopAreaThenOut_onStartCalledOnceAndOnDrag3() {
+        val beyondTouchSlop = (TestTouchSlop + 1).toFloat()
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var event = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(event)
+        // Out of touch slop region
+        event = event.moveBy(Duration(milliseconds = 10), 0f, beyondTouchSlop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(event)
+        // Back into touch slop region
+        event = event.moveBy(Duration(milliseconds = 10), 0f, -beyondTouchSlop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(event)
+        // Out of touch slop region again
+        event = event.moveBy(Duration(milliseconds = 10), 0f, beyondTouchSlop)
+        recognizer.pointerInputHandler.invokeOverAllPasses(event)
+
+        assertThat(log.filter { it.methodName == "onStart" }).hasSize(1)
+        assertThat(log.filter { it.methodName == "onDrag" }).hasSize(3)
+    }
+
+    @Test
+    fun pointerInputHandler_downConsumedMovePassedSlop_onStartAndOnDragCalledOnce() {
+        val beyondTouchSlop = (TestTouchSlop + 1).toFloat()
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+        val down = down().consumeDownChange()
+
+        recognizer.pointerInputHandler.invokeOverAllPasses(down)
+        recognizer.pointerInputHandler.invokeOverAllPasses(
+            down.moveBy(
+                Duration(milliseconds = 100),
+                beyondTouchSlop,
+                0f
+            )
+        )
+
+        assertThat(log.filter { it.methodName == "onStart" }).hasSize(1)
+        assertThat(log.filter { it.methodName == "onDrag" }).hasSize(1)
+    }
+
+    @Test
+    fun pointerInputHandler_beyondInUnsupportThenBeyondInSupport_onStartAndOnDragCalledOnce() {
+        val doubleTouchSlop = (TestTouchSlop * 2).toFloat()
+        val beyondTouchSlop = (TestTouchSlop + 1).toFloat()
+        recognizer.canDrag = MockCanDrag(arrayOf(Direction.UP), log)
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveBy(
+            Duration(milliseconds = 10),
+            0f,
+            doubleTouchSlop
+        )
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveBy(
+            Duration(milliseconds = 10),
+            0f,
+            -beyondTouchSlop
+        )
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(log.filter { it.methodName == "onStart" }).hasSize(1)
+        assertThat(log.filter { it.methodName == "onDrag" }).hasSize(1)
+    }
+
+    // onDrag called with correct values verification
+
+    @Test
+    fun pointerInputHandler_justPassedSlop_onDragCalledWithTotalDistance() {
+        val beyondTouchSlop = (TestTouchSlop + 1).toFloat()
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveBy(
+            Duration(milliseconds = 100),
+            beyondTouchSlop,
+            0f
+        )
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(log).contains(LogItem("onDrag", pxPosition = PxPosition(11.px, 0.px)))
+    }
+
+    @Test
+    fun pointerInputHandler_moveAndMoveConsumed_onDragCalledWithCorrectDistance() {
+        val beyondTouchSlop = (TestTouchSlop + 1).toFloat()
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveBy(Duration(milliseconds = 100), beyondTouchSlop, 0f)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveBy(Duration(milliseconds = 100), 3f, -5f)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveBy(Duration(milliseconds = 100), -3f, 7f)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveBy(Duration(milliseconds = 100), 11f, 13f)
+            .consumePositionChange(5f, 3f)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveBy(Duration(milliseconds = 100), -13f, -11f)
+            .consumePositionChange(-3f, -5f)
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        val  { it.methodName == "onDrag" }
+        assertThat(onDragLog).hasSize(5)
+        assertThat(onDragLog[0].pxPosition).isEqualTo(PxPosition(11.px, 0.px))
+        assertThat(onDragLog[1].pxPosition).isEqualTo(PxPosition(3.px, (-5).px))
+        assertThat(onDragLog[2].pxPosition).isEqualTo(PxPosition((-3).px, 7.px))
+        assertThat(onDragLog[3].pxPosition).isEqualTo(PxPosition(6.px, 10.px))
+        assertThat(onDragLog[4].pxPosition).isEqualTo(PxPosition((-10).px, (-6).px))
+    }
+
+    // onStop not called verification
+
+    @Test
+    fun pointerInputHandler_downMoveWithinSlopUp_onStopNotCalled() {
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveTo(
+            10L.millisecondsToTimestamp(),
+            TestTouchSlop.toFloat(),
+            TestTouchSlop.toFloat()
+        )
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.up(20L.millisecondsToTimestamp())
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(log.filter { it.methodName == "onStop" }).hasSize(0)
+    }
+
+    @Test
+    fun pointerInputHandler_downMoveBeyondSlop_onStopNotCalled() {
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveTo(
+            10L.millisecondsToTimestamp(),
+            TestTouchSlop.toFloat() + 1,
+            TestTouchSlop.toFloat()
+        )
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(log.filter { it.methodName == "onStop" }).hasSize(0)
+    }
+
+    // onStop called verification
+
+    @Test
+    fun pointerInputHandler_downMoveBeyondSlopUp_onStopCalledOnce() {
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveTo(
+            10L.millisecondsToTimestamp(),
+            TestTouchSlop.toFloat() + 1,
+            TestTouchSlop.toFloat()
+        )
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.up(20L.millisecondsToTimestamp())
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(log.filter { it.methodName == "onStop" }).hasSize(1)
+    }
+
+    // onStop called with correct values verification
+
+    @Test
+    fun pointerInputHandler_flingBeyondSlop_onStopCalledWithCorrectVelocity() {
+        pointerInputHandler_flingBeyondSlop_onStopCalledWithCorrectVelocity(0f, 1f, 0f, 100f)
+        pointerInputHandler_flingBeyondSlop_onStopCalledWithCorrectVelocity(0f, -1f, 0f, -100f)
+        pointerInputHandler_flingBeyondSlop_onStopCalledWithCorrectVelocity(1f, 0f, 100f, 0f)
+        pointerInputHandler_flingBeyondSlop_onStopCalledWithCorrectVelocity(-1f, 0f, -100f, 0f)
+
+        pointerInputHandler_flingBeyondSlop_onStopCalledWithCorrectVelocity(1f, 1f, 100f, 100f)
+        pointerInputHandler_flingBeyondSlop_onStopCalledWithCorrectVelocity(-1f, 1f, -100f, 100f)
+        pointerInputHandler_flingBeyondSlop_onStopCalledWithCorrectVelocity(1f, -1f, 100f, -100f)
+        pointerInputHandler_flingBeyondSlop_onStopCalledWithCorrectVelocity(-1f, -1f, -100f, -100f)
+    }
+
+    private fun pointerInputHandler_flingBeyondSlop_onStopCalledWithCorrectVelocity(
+        incrementPerMilliX: Float,
+        incrementPerMilliY: Float,
+        expectedPxPerSecondDx: Float,
+        expectedPxPerSecondDy: Float
+    ) {
+        log = mutableListOf()
+        recognizer = DragGestureRecognizer()
+        recognizer.touchSlop = TestTouchSlop.ipx
+        recognizer.canDrag = MockCanDrag(Direction.values(), log)
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        repeat(11) {
+            change = change.moveBy(
+                Duration(milliseconds = 10),
+                incrementPerMilliX,
+                incrementPerMilliY
+            )
+            recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        }
+
+        change = change.up(20L.millisecondsToTimestamp())
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        val velocity = log.find { it.methodName == "onStop" }!!.pxPosition!!
+        assertThat(velocity.x.value).isWithin(.01f).of(expectedPxPerSecondDx)
+        assertThat(velocity.y.value).isWithin(.01f).of(expectedPxPerSecondDy)
+    }
+
+    // Verification that callbacks occur in the correct order
+
+    @Test
+    fun pointerInputHandler_downMoveBeyondSlopUp_callBacksOccurInCorrectOrder() {
+        recognizer.canDrag = MockCanDrag(arrayOf(Direction.DOWN), log)
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveTo(
+            10L.millisecondsToTimestamp(),
+            TestTouchSlop.toFloat(),
+            TestTouchSlop.toFloat() + 1
+        )
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.up(20L.millisecondsToTimestamp())
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(log).hasSize(5)
+        assertThat(log[0].methodName).isEqualTo("canDrag")
+        assertThat(log[1].methodName).isEqualTo("canDrag")
+        assertThat(log[2].methodName).isEqualTo("onStart")
+        assertThat(log[3].methodName).isEqualTo("onDrag")
+        assertThat(log[4].methodName).isEqualTo("onStop")
+    }
+
+    // Verification about what events are, or aren't consumed.
+
+    @Test
+    fun pointerInputHandler_down_downNotConsumed() {
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        val result = recognizer.pointerInputHandler.invokeOverAllPasses(down())
+
+        assertThat(result.consumed.downChange).isFalse()
+    }
+
+    @Test
+    fun pointerInputHandler_downMoveWithinTouchSlop_distanceChangeNotConsumed() {
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveTo(
+            10L.millisecondsToTimestamp(),
+            TestTouchSlop.toFloat(),
+            TestTouchSlop.toFloat()
+        )
+        val result = recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(result.anyPositionChangeConsumed()).isFalse()
+    }
+
+    @Test
+    fun pointerInputHandler_downMoveBeyondSlopInUnsupportedDirection_distanceChangeNotConsumed() {
+        recognizer.canDrag = MockCanDrag(arrayOf(), log)
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveTo(
+            10L.millisecondsToTimestamp(),
+            TestTouchSlop.toFloat() + 1,
+            TestTouchSlop.toFloat() + 1
+        )
+        val result = recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(result.anyPositionChangeConsumed()).isFalse()
+    }
+
+    @Test
+    fun pointerInputHandler_doneMoveCallBackDoesNotConsume_distanceChangeNotConsumed() {
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveTo(
+            10L.millisecondsToTimestamp(),
+            TestTouchSlop.toFloat() + 1,
+            TestTouchSlop.toFloat() + 1
+        )
+        val result = recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(result.anyPositionChangeConsumed()).isFalse()
+    }
+
+    @Test
+    fun pointerInputHandler_moveCallBackConsumes_changeDistanceConsumedByCorrectAmount() {
+        val thirdTouchSlop = TestTouchSlop.toFloat() / 3
+        val quarterTouchSlop = TestTouchSlop.toFloat() / 4
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver =
+            MockDragObserver(log, PxPosition(thirdTouchSlop.px, quarterTouchSlop.px))
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveTo(
+            10L.millisecondsToTimestamp(),
+            TestTouchSlop.toFloat() + 1,
+            TestTouchSlop.toFloat() + 1
+        )
+        val result = recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(result.consumed.positionChange.dx).isEqualTo(thirdTouchSlop)
+        assertThat(result.consumed.positionChange.dy).isEqualTo(quarterTouchSlop)
+    }
+
+    @Test
+    fun pointerInputHandler_onStopConsumesUp() {
+        recognizer.canDrag = canDragMockTrue
+        recognizer.dragObserver = MockDragObserver(log)
+
+        var change = down()
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.moveTo(
+            10L.millisecondsToTimestamp(),
+            TestTouchSlop.toFloat() + 1,
+            TestTouchSlop.toFloat()
+        )
+        recognizer.pointerInputHandler.invokeOverAllPasses(change)
+        change = change.up(20L.millisecondsToTimestamp())
+        val result = recognizer.pointerInputHandler.invokeOverAllPasses(change)
+
+        assertThat(result.consumed.downChange).isTrue()
+    }
+
+    class MockCanDrag(
+        private val directionsToReturnTrue: Array<Direction>,
+        val log: MutableList<LogItem>
+    ) :
+            (Direction) -> Boolean {
+        override fun invoke(direction: Direction): Boolean {
+            log.add(LogItem("canDrag", direction = direction))
+            return directionsToReturnTrue.contains(direction)
+        }
+    }
+
+    data class LogItem(
+        val methodName: String,
+        val direction: Direction? = null,
+        val pxPosition: PxPosition? = null
+    )
+
+    class MockDragObserver(
+        val log: MutableList<LogItem>,
+        var dragConsume: PxPosition = PxPosition.Origin
+    ) : DragObserver() {
+        override fun onStart() {
+            log.add(LogItem("onStart"))
+            super.onStart()
+        }
+
+        override fun onDrag(dragDistance: PxPosition): PxPosition {
+            log.add(LogItem("onDrag", pxPosition = dragDistance))
+            return dragConsume
+        }
+
+        override fun onStop(velocity: PxPosition) {
+            log.add(LogItem("onStop", pxPosition = velocity))
+            super.onStop(velocity)
+        }
+    }
+}
\ No newline at end of file