Use NestedDraggable to handle STL gestures

This CL makes SceneTransitionLayout use Modifier.nestedDraggable(). This
is a pure refactoring that should be invisible to consumers, and should
make it easier to reason about nested scrolling and gesture handling in
STL.

Bug: 378470603
Test: atest PlatformComposeSceneTransitionLayoutTests
Flag: com.android.systemui.scene_container
Change-Id: Ib0af0b57a00401170596c1b4d140068cd6a8dadf
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
index 2ca8464..633328a 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/DraggableHandler.kt
@@ -16,62 +16,29 @@
 
 package com.android.compose.animation.scene
 
+import androidx.compose.foundation.OverscrollEffect
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.pointer.PointerInputChange
+import androidx.compose.ui.input.pointer.PointerType
+import androidx.compose.ui.unit.Velocity
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.round
 import androidx.compose.ui.util.fastCoerceIn
 import com.android.compose.animation.scene.content.Content
 import com.android.compose.animation.scene.content.state.TransitionState.Companion.DistanceUnspecified
-import com.android.compose.nestedscroll.OnStopScope
-import com.android.compose.nestedscroll.PriorityNestedScrollConnection
-import com.android.compose.nestedscroll.ScrollController
+import com.android.compose.animation.scene.effect.GestureEffect
+import com.android.compose.gesture.NestedDraggable
 import com.android.compose.ui.util.SpaceVectorConverter
 import kotlin.math.absoluteValue
-import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.NonCancellable
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
 
-internal interface DraggableHandler {
-    /**
-     * Start a drag with the given [pointersDown] and [overSlop].
-     *
-     * The returned [DragController] should be used to continue or stop the drag.
-     */
-    fun onDragStarted(pointersDown: PointersInfo.PointersDown?, overSlop: Float): DragController
-}
-
-/**
- * The [DragController] provides control over the transition between two scenes through the [onDrag]
- * and [onStop] methods.
- */
-internal interface DragController {
-    /**
-     * Drag the current scene by [delta] pixels.
-     *
-     * @param delta The distance to drag the scene in pixels.
-     * @return the consumed [delta]
-     */
-    fun onDrag(delta: Float): Float
-
-    /**
-     * Stop the current drag with the given [velocity].
-     *
-     * @param velocity The velocity of the drag when it stopped.
-     * @return the consumed [velocity] when the animation complete
-     */
-    suspend fun onStop(velocity: Float): Float
-
-    /** Cancels the current drag. */
-    fun onCancel()
-}
-
-internal class DraggableHandlerImpl(
+internal class DraggableHandler(
     internal val layoutImpl: SceneTransitionLayoutImpl,
     internal val orientation: Orientation,
-) : DraggableHandler {
+    private val gestureEffectProvider: (ContentKey) -> GestureEffect,
+) : NestedDraggable {
     /** The [DraggableHandler] can only have one active [DragController] at a time. */
     private var dragController: DragControllerImpl? = null
 
@@ -92,20 +59,36 @@
     internal val positionalThreshold
         get() = with(layoutImpl.density) { 56.dp.toPx() }
 
+    /** The [OverscrollEffect] that should consume any overscroll on this draggable. */
+    internal val overscrollEffect: OverscrollEffect = DelegatingOverscrollEffect()
+
+    override fun shouldStartDrag(change: PointerInputChange): Boolean {
+        return layoutImpl.swipeDetector.detectSwipe(change)
+    }
+
+    override fun shouldConsumeNestedScroll(sign: Float): Boolean {
+        return this.enabled()
+    }
+
     override fun onDragStarted(
-        pointersDown: PointersInfo.PointersDown?,
-        overSlop: Float,
-    ): DragController {
-        check(overSlop != 0f)
-        val swipes = computeSwipes(pointersDown)
+        position: Offset,
+        sign: Float,
+        pointersDown: Int,
+        pointerType: PointerType?,
+    ): NestedDraggable.Controller {
+        check(sign != 0f)
+        val swipes = computeSwipes(position, pointersDown, pointerType)
         val fromContent = layoutImpl.contentForUserActions()
 
         swipes.updateSwipesResults(fromContent)
+        val upOrLeft = swipes.upOrLeftResult
+        val downOrRight = swipes.downOrRightResult
         val result =
-            (if (overSlop < 0f) swipes.upOrLeftResult else swipes.downOrRightResult)
-                // As we were unable to locate a valid target scene, the initial SwipeAnimation
-                // cannot be defined. Consequently, a simple NoOp Controller will be returned.
-                ?: return NoOpDragController
+            when {
+                sign < 0 -> upOrLeft ?: downOrRight
+                sign >= 0f -> downOrRight ?: upOrLeft
+                else -> null
+            } ?: return NoOpDragController
 
         val swipeAnimation = createSwipeAnimation(swipes, result)
         return updateDragController(swipes, swipeAnimation)
@@ -143,20 +126,109 @@
         )
     }
 
-    private fun computeSwipes(pointersDown: PointersInfo.PointersDown?): Swipes {
-        val fromSource = pointersDown?.let { resolveSwipeSource(it.startedPosition) }
+    private fun computeSwipes(
+        position: Offset,
+        pointersDown: Int,
+        pointerType: PointerType?,
+    ): Swipes {
+        val fromSource = resolveSwipeSource(position)
         return Swipes(
-            upOrLeft = resolveSwipe(orientation, isUpOrLeft = true, pointersDown, fromSource),
-            downOrRight = resolveSwipe(orientation, isUpOrLeft = false, pointersDown, fromSource),
+            upOrLeft =
+                resolveSwipe(orientation, isUpOrLeft = true, fromSource, pointersDown, pointerType),
+            downOrRight =
+                resolveSwipe(orientation, isUpOrLeft = false, fromSource, pointersDown, pointerType),
         )
     }
+
+    /**
+     * An implementation of [OverscrollEffect] that delegates to the correct content effect
+     * depending on the current scene/overlays and transition.
+     */
+    private inner class DelegatingOverscrollEffect :
+        OverscrollEffect, SpaceVectorConverter by SpaceVectorConverter(orientation) {
+        private var currentContent: ContentKey? = null
+        private var currentDelegate: GestureEffect? = null
+            set(value) {
+                field?.let { delegate ->
+                    if (delegate.isInProgress) {
+                        layoutImpl.animationScope.launch { delegate.ensureApplyToFlingIsCalled() }
+                    }
+                }
+
+                field = value
+            }
+
+        override val isInProgress: Boolean
+            get() = currentDelegate?.isInProgress ?: false
+
+        override fun applyToScroll(
+            delta: Offset,
+            source: NestedScrollSource,
+            performScroll: (Offset) -> Offset,
+        ): Offset {
+            val available = delta.toFloat()
+            if (available == 0f) {
+                return performScroll(delta)
+            }
+
+            ensureDelegateIsNotNull(available)
+            val delegate = checkNotNull(currentDelegate)
+            return if (delegate.node.node.isAttached) {
+                delegate.applyToScroll(delta, source, performScroll)
+            } else {
+                performScroll(delta)
+            }
+        }
+
+        override suspend fun applyToFling(
+            velocity: Velocity,
+            performFling: suspend (Velocity) -> Velocity,
+        ) {
+            val available = velocity.toFloat()
+            if (available != 0f && isDrivingTransition) {
+                ensureDelegateIsNotNull(available)
+            }
+
+            // Note: we set currentDelegate and currentContent to null before calling performFling,
+            // which can suspend and take a lot of time.
+            val delegate = currentDelegate
+            currentDelegate = null
+            currentContent = null
+
+            if (delegate != null && delegate.node.node.isAttached) {
+                delegate.applyToFling(velocity, performFling)
+            } else {
+                performFling(velocity)
+            }
+        }
+
+        private fun ensureDelegateIsNotNull(direction: Float) {
+            require(direction != 0f)
+            if (isInProgress) {
+                return
+            }
+
+            val content =
+                if (isDrivingTransition) {
+                    checkNotNull(dragController).swipeAnimation.contentByDirection(direction)
+                } else {
+                    layoutImpl.contentForUserActions().key
+                }
+
+            if (content != currentContent) {
+                currentContent = content
+                currentDelegate = gestureEffectProvider(content)
+            }
+        }
+    }
 }
 
 private fun resolveSwipe(
     orientation: Orientation,
     isUpOrLeft: Boolean,
-    pointersDown: PointersInfo.PointersDown?,
     fromSource: SwipeSource.Resolved?,
+    pointersDown: Int,
+    pointerType: PointerType?,
 ): Swipe.Resolved {
     return Swipe.Resolved(
         direction =
@@ -175,28 +247,22 @@
                         SwipeDirection.Resolved.Down
                     }
             },
-        // If the number of pointers is not specified, 1 is assumed.
-        pointerCount = pointersDown?.count ?: 1,
-        // Resolves the pointer type only if all pointers are of the same type.
-        pointersType = pointersDown?.countByType?.keys?.singleOrNull(),
+        pointerCount = pointersDown,
+        pointerType = pointerType,
         fromSource = fromSource,
     )
 }
 
 /** @param swipes The [Swipes] associated to the current gesture. */
 private class DragControllerImpl(
-    private val draggableHandler: DraggableHandlerImpl,
+    private val draggableHandler: DraggableHandler,
     val swipes: Swipes,
     var swipeAnimation: SwipeAnimation<*>,
-) : DragController, SpaceVectorConverter by SpaceVectorConverter(draggableHandler.orientation) {
+) :
+    NestedDraggable.Controller,
+    SpaceVectorConverter by SpaceVectorConverter(draggableHandler.orientation) {
     val layoutState = draggableHandler.layoutImpl.state
 
-    val overscrollableContent: OverscrollableContent =
-        when (draggableHandler.orientation) {
-            Orientation.Vertical -> draggableHandler.layoutImpl.verticalOverscrollableContent
-            Orientation.Horizontal -> draggableHandler.layoutImpl.horizontalOverscrollableContent
-        }
-
     /**
      * Whether this handle is active. If this returns false, calling [onDrag] and [onStop] will do
      * nothing.
@@ -231,57 +297,25 @@
         if (delta == 0f || !isDrivingTransition || initialAnimation.isAnimatingOffset()) {
             return 0f
         }
+
         // swipeAnimation can change during the gesture, we want to always use the initial reference
         // during the whole drag gesture.
-        return dragWithOverscroll(delta, animation = initialAnimation)
-    }
-
-    private fun <T : ContentKey> dragWithOverscroll(
-        delta: Float,
-        animation: SwipeAnimation<T>,
-    ): Float {
-        require(delta != 0f) { "delta should not be 0" }
-        var overscrollEffect = overscrollableContent.currentOverscrollEffect
-
-        // If we're already overscrolling, continue with the current effect for a smooth finish.
-        if (overscrollEffect == null || !overscrollEffect.isInProgress) {
-            // Otherwise, determine the target content (toContent or fromContent) for the new
-            // overscroll effect based on the gesture's direction.
-            val content = animation.contentByDirection(delta)
-            overscrollEffect = overscrollableContent.applyOverscrollEffectOn(content)
-        }
-
-        // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
-        if (!overscrollEffect.node.node.isAttached) {
-            return drag(delta, animation)
-        }
-
-        return overscrollEffect
-            .applyToScroll(
-                delta = delta.toOffset(),
-                source = NestedScrollSource.UserInput,
-                performScroll = {
-                    val preScrollAvailable = it.toFloat()
-                    drag(preScrollAvailable, animation).toOffset()
-                },
-            )
-            .toFloat()
+        return drag(delta, animation = initialAnimation)
     }
 
     private fun <T : ContentKey> drag(delta: Float, animation: SwipeAnimation<T>): Float {
-        if (delta == 0f) return 0f
-
         val distance = animation.distance()
         val previousOffset = animation.dragOffset
         val desiredOffset = previousOffset + delta
-        val desiredProgress = animation.computeProgress(desiredOffset)
 
         // Note: the distance could be negative if fromContent is above or to the left of toContent.
         val newOffset =
             when {
-                distance == DistanceUnspecified ||
-                    animation.contentTransition.isWithinProgressRange(desiredProgress) ->
-                    desiredOffset
+                distance == DistanceUnspecified -> {
+                    // Consume everything so that we don't overscroll, this will be coerced later
+                    // when the distance is defined.
+                    delta
+                }
                 distance > 0f -> desiredOffset.fastCoerceIn(0f, distance)
                 else -> desiredOffset.fastCoerceIn(distance, 0f)
             }
@@ -290,12 +324,8 @@
         return newOffset - previousOffset
     }
 
-    override suspend fun onStop(velocity: Float): Float {
-        // To ensure that any ongoing animation completes gracefully and avoids an undefined state,
-        // we execute the actual `onStop` logic in a non-cancellable context. This prevents the
-        // coroutine from being cancelled prematurely, which could interrupt the animation.
-        // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
-        return withContext(NonCancellable) { onStop(velocity, swipeAnimation) }
+    override suspend fun onDragStopped(velocity: Float, awaitFling: suspend () -> Unit): Float {
+        return onStop(velocity, swipeAnimation, awaitFling)
     }
 
     private suspend fun <T : ContentKey> onStop(
@@ -306,6 +336,7 @@
         // callbacks (like onAnimationCompleted()) might incorrectly finish a new transition that
         // replaced this one.
         swipeAnimation: SwipeAnimation<T>,
+        awaitFling: suspend () -> Unit,
     ): Float {
         // The state was changed since the drag started; don't do anything.
         if (!isDrivingTransition || swipeAnimation.isAnimatingOffset()) {
@@ -337,33 +368,7 @@
                 fromContent
             }
 
-        val overscrollEffect = overscrollableContent.applyOverscrollEffectOn(targetContent)
-
-        // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
-        if (!overscrollEffect.node.node.isAttached) {
-            return swipeAnimation.animateOffset(velocity, targetContent)
-        }
-
-        val overscrollCompletable = CompletableDeferred<Unit>()
-        try {
-            overscrollEffect.applyToFling(
-                velocity = velocity.toVelocity(),
-                performFling = {
-                    val velocityLeft = it.toFloat()
-                    swipeAnimation
-                        .animateOffset(
-                            velocityLeft,
-                            targetContent,
-                            overscrollCompletable = overscrollCompletable,
-                        )
-                        .toVelocity()
-                },
-            )
-        } finally {
-            overscrollCompletable.complete(Unit)
-        }
-
-        return velocity
+        return swipeAnimation.animateOffset(velocity, targetContent, awaitFling = awaitFling)
     }
 
     /**
@@ -408,10 +413,6 @@
                 isCloserToTarget()
         }
     }
-
-    override fun onCancel() {
-        swipeAnimation.contentTransition.coroutineScope.launch { onStop(velocity = 0f) }
-    }
 }
 
 /** The [Swipe] associated to a given fromScene, startedPosition and pointersDown. */
@@ -453,15 +454,15 @@
                     (actionSwipe.fromSource != null &&
                         actionSwipe.fromSource != swipe.fromSource) ||
                     // The action requires a specific pointerType.
-                    (actionSwipe.pointersType != null &&
-                        actionSwipe.pointersType != swipe.pointersType)
+                    (actionSwipe.pointerType != null &&
+                        actionSwipe.pointerType != swipe.pointerType)
             ) {
                 // This action is not eligible.
                 return@forEach
             }
 
             val sameFromSource = actionSwipe.fromSource == swipe.fromSource
-            val samePointerType = actionSwipe.pointersType == swipe.pointersType
+            val samePointerType = actionSwipe.pointerType == swipe.pointerType
             // Prioritize actions with a perfect match.
             if (sameFromSource && samePointerType) {
                 return actionResult
@@ -496,82 +497,6 @@
     }
 }
 
-internal class NestedScrollHandlerImpl(
-    private val draggableHandler: DraggableHandlerImpl,
-    private val pointersInfoOwner: PointersInfoOwner,
-) {
-    val connection: PriorityNestedScrollConnection = nestedScrollConnection()
-
-    private fun nestedScrollConnection(): PriorityNestedScrollConnection {
-        var lastPointersDown: PointersInfo.PointersDown? = null
-
-        return PriorityNestedScrollConnection(
-            orientation = draggableHandler.orientation,
-            canStartPreScroll = { _, _, _ -> false },
-            canStartPostScroll = { offsetAvailable, _, _ ->
-                if (offsetAvailable == 0f) return@PriorityNestedScrollConnection false
-
-                lastPointersDown =
-                    when (val info = pointersInfoOwner.pointersInfo()) {
-                        PointersInfo.MouseWheel -> {
-                            // Do not support mouse wheel interactions
-                            return@PriorityNestedScrollConnection false
-                        }
-
-                        is PointersInfo.PointersDown -> info
-                        null -> null
-                    }
-
-                draggableHandler.layoutImpl
-                    .contentForUserActions()
-                    .shouldEnableSwipes(draggableHandler.orientation)
-            },
-            onStart = { firstScroll ->
-                scrollController(
-                    dragController =
-                        draggableHandler.onDragStarted(
-                            pointersDown = lastPointersDown,
-                            overSlop = firstScroll,
-                        ),
-                    pointersInfoOwner = pointersInfoOwner,
-                )
-            },
-        )
-    }
-}
-
-private fun scrollController(
-    dragController: DragController,
-    pointersInfoOwner: PointersInfoOwner,
-): ScrollController {
-    return object : ScrollController {
-        override fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float {
-            if (pointersInfoOwner.pointersInfo() == PointersInfo.MouseWheel) {
-                // Do not support mouse wheel interactions
-                return 0f
-            }
-
-            return dragController.onDrag(delta = deltaScroll)
-        }
-
-        override suspend fun OnStopScope.onStop(initialVelocity: Float): Float {
-            return dragController.onStop(velocity = initialVelocity)
-        }
-
-        override fun onCancel() {
-            dragController.onCancel()
-        }
-
-        /**
-         * We need to maintain scroll priority even if the scene transition can no longer consume
-         * the scroll gesture to allow us to return to the previous scene.
-         */
-        override fun canCancelScroll(available: Float, consumed: Float) = false
-
-        override fun canStopOnPreFling() = true
-    }
-}
-
 /**
  * The number of pixels below which there won't be a visible difference in the transition and from
  * which the animation can stop.
@@ -580,12 +505,8 @@
 // account instead.
 internal const val OffsetVisibilityThreshold = 0.5f
 
-private object NoOpDragController : DragController {
+private object NoOpDragController : NestedDraggable.Controller {
     override fun onDrag(delta: Float) = 0f
 
-    override suspend fun onStop(velocity: Float) = 0f
-
-    override fun onCancel() {
-        /* do nothing */
-    }
+    override suspend fun onDragStopped(velocity: Float, awaitFling: suspend () -> Unit): Float = 0f
 }
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt
deleted file mode 100644
index 89320f13..0000000
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MultiPointerDraggable.kt
+++ /dev/null
@@ -1,678 +0,0 @@
-/*
- * Copyright (C) 2023 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 com.android.compose.animation.scene
-
-import androidx.annotation.VisibleForTesting
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
-import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation
-import androidx.compose.runtime.Stable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
-import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
-import androidx.compose.ui.input.nestedscroll.NestedScrollSource
-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.PointerEventType
-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.SuspendingPointerInputModifierNode
-import androidx.compose.ui.input.pointer.changedToDown
-import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
-import androidx.compose.ui.input.pointer.positionChange
-import androidx.compose.ui.input.pointer.positionChangeIgnoreConsumed
-import androidx.compose.ui.input.pointer.util.VelocityTracker
-import androidx.compose.ui.input.pointer.util.addPointerInputChange
-import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
-import androidx.compose.ui.node.DelegatingNode
-import androidx.compose.ui.node.ModifierNodeElement
-import androidx.compose.ui.node.PointerInputModifierNode
-import androidx.compose.ui.node.currentValueOf
-import androidx.compose.ui.platform.LocalViewConfiguration
-import androidx.compose.ui.unit.IntSize
-import androidx.compose.ui.unit.Velocity
-import androidx.compose.ui.util.fastAll
-import androidx.compose.ui.util.fastAny
-import androidx.compose.ui.util.fastFilter
-import androidx.compose.ui.util.fastFirstOrNull
-import androidx.compose.ui.util.fastForEach
-import androidx.compose.ui.util.fastSumBy
-import com.android.compose.ui.util.SpaceVectorConverter
-import kotlin.coroutines.cancellation.CancellationException
-import kotlin.math.sign
-import kotlinx.coroutines.currentCoroutineContext
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-
-/**
- * Make an element draggable in the given [orientation].
- *
- * The main difference with [multiPointerDraggable] and
- * [androidx.compose.foundation.gestures.draggable] is that [onDragStarted] also receives the number
- * of pointers that are down when the drag is started. If you don't need this information, you
- * should use `draggable` instead.
- *
- * Note that the current implementation is trivial: we wait for the touch slope on the *first* down
- * pointer, then we count the number of distinct pointers that are down right before calling
- * [onDragStarted]. This means that the drag won't start when a first pointer is down (but not
- * dragged) and a second pointer is down and dragged. This is an implementation detail that might
- * change in the future.
- */
-@VisibleForTesting
-@Stable
-internal fun Modifier.multiPointerDraggable(
-    orientation: Orientation,
-    onDragStarted: (pointersDown: PointersInfo.PointersDown, overSlop: Float) -> DragController,
-    onFirstPointerDown: () -> Unit = {},
-    swipeDetector: SwipeDetector = DefaultSwipeDetector,
-    dispatcher: NestedScrollDispatcher,
-): Modifier =
-    this.then(
-        MultiPointerDraggableElement(
-            orientation,
-            onDragStarted,
-            onFirstPointerDown,
-            swipeDetector,
-            dispatcher,
-        )
-    )
-
-private data class MultiPointerDraggableElement(
-    private val orientation: Orientation,
-    private val onDragStarted:
-        (pointersDown: PointersInfo.PointersDown, overSlop: Float) -> DragController,
-    private val onFirstPointerDown: () -> Unit,
-    private val swipeDetector: SwipeDetector,
-    private val dispatcher: NestedScrollDispatcher,
-) : ModifierNodeElement<MultiPointerDraggableNode>() {
-    override fun create(): MultiPointerDraggableNode =
-        MultiPointerDraggableNode(
-            orientation = orientation,
-            onDragStarted = onDragStarted,
-            onFirstPointerDown = onFirstPointerDown,
-            swipeDetector = swipeDetector,
-            dispatcher = dispatcher,
-        )
-
-    override fun update(node: MultiPointerDraggableNode) {
-        node.orientation = orientation
-        node.onDragStarted = onDragStarted
-        node.onFirstPointerDown = onFirstPointerDown
-        node.swipeDetector = swipeDetector
-    }
-}
-
-internal class MultiPointerDraggableNode(
-    orientation: Orientation,
-    var onDragStarted: (pointersDown: PointersInfo.PointersDown, overSlop: Float) -> DragController,
-    var onFirstPointerDown: () -> Unit,
-    swipeDetector: SwipeDetector = DefaultSwipeDetector,
-    private val dispatcher: NestedScrollDispatcher,
-) : DelegatingNode(), PointerInputModifierNode, CompositionLocalConsumerModifierNode {
-    private val pointerTracker = delegate(SuspendingPointerInputModifierNode { pointerTracker() })
-    private val pointerInput = delegate(SuspendingPointerInputModifierNode { pointerInput() })
-    private val velocityTracker = VelocityTracker()
-
-    var swipeDetector: SwipeDetector = swipeDetector
-        set(value) {
-            if (value != field) {
-                field = value
-                pointerInput.resetPointerInputHandler()
-            }
-        }
-
-    private var converter = SpaceVectorConverter(orientation)
-
-    fun Offset.toFloat(): Float = with(converter) { this@toFloat.toFloat() }
-
-    fun Velocity.toFloat(): Float = with(converter) { this@toFloat.toFloat() }
-
-    fun Float.toOffset(): Offset = with(converter) { this@toOffset.toOffset() }
-
-    fun Float.toVelocity(): Velocity = with(converter) { this@toVelocity.toVelocity() }
-
-    var orientation: Orientation = orientation
-        set(value) {
-            // Reset the pointer input whenever orientation changed.
-            if (value != field) {
-                field = value
-                converter = SpaceVectorConverter(value)
-                pointerInput.resetPointerInputHandler()
-            }
-        }
-
-    override fun onCancelPointerInput() {
-        pointerTracker.onCancelPointerInput()
-        pointerInput.onCancelPointerInput()
-    }
-
-    override fun onPointerEvent(
-        pointerEvent: PointerEvent,
-        pass: PointerEventPass,
-        bounds: IntSize,
-    ) {
-        // The order is important here: the tracker is always called first.
-        pointerTracker.onPointerEvent(pointerEvent, pass, bounds)
-        pointerInput.onPointerEvent(pointerEvent, pass, bounds)
-    }
-
-    private var lastPointerEvent: PointerEvent? = null
-    private var startedPosition: Offset? = null
-    private var countPointersDown: Int = 0
-
-    internal fun pointersInfo(): PointersInfo? {
-        // This may be null, i.e. when the user uses TalkBack
-        val lastPointerEvent = lastPointerEvent ?: return null
-
-        if (lastPointerEvent.type == PointerEventType.Scroll) return PointersInfo.MouseWheel
-
-        val startedPosition = startedPosition ?: return null
-
-        return PointersInfo.PointersDown(
-            startedPosition = startedPosition,
-            count = countPointersDown,
-            countByType =
-                buildMap {
-                    lastPointerEvent.changes.fastForEach { change ->
-                        if (!change.pressed) return@fastForEach
-                        val newValue = (get(change.type) ?: 0) + 1
-                        put(change.type, newValue)
-                    }
-                },
-        )
-    }
-
-    private suspend fun PointerInputScope.pointerTracker() {
-        val currentContext = currentCoroutineContext()
-        awaitPointerEventScope {
-            var velocityPointerId: PointerId? = null
-            // Intercepts pointer inputs and exposes [PointersInfo], via
-            // [requireAncestorPointersInfoOwner], to our descendants.
-            while (currentContext.isActive) {
-                // During the Initial pass, we receive the event after our ancestors.
-                val pointerEvent = awaitPointerEvent(PointerEventPass.Initial)
-
-                // Ignore cursor has entered the input region.
-                // This will only be sent after the cursor is hovering when in the input region.
-                if (pointerEvent.type == PointerEventType.Enter) continue
-
-                val changes = pointerEvent.changes
-                lastPointerEvent = pointerEvent
-                countPointersDown = changes.countDown()
-
-                when {
-                    // There are no more pointers down.
-                    countPointersDown == 0 -> {
-                        startedPosition = null
-
-                        // In case of multiple events with 0 pointers down (not pressed) we may have
-                        // already removed the velocityPointer
-                        val lastPointerUp = changes.fastFilter { it.id == velocityPointerId }
-                        check(lastPointerUp.isEmpty() || lastPointerUp.size == 1) {
-                            "There are ${lastPointerUp.size} pointers up: $lastPointerUp"
-                        }
-                        if (lastPointerUp.size == 1) {
-                            velocityTracker.addPointerInputChange(lastPointerUp.first())
-                        }
-                    }
-
-                    // The first pointer down, startedPosition was not set.
-                    startedPosition == null -> {
-                        // Mouse wheel could start with multiple pointer down
-                        val firstPointerDown = changes.first()
-                        velocityPointerId = firstPointerDown.id
-                        velocityTracker.resetTracking()
-                        velocityTracker.addPointerInputChange(firstPointerDown)
-                        startedPosition = firstPointerDown.position
-                        onFirstPointerDown()
-                    }
-
-                    // Changes with at least one pointer
-                    else -> {
-                        val pointerChange = changes.first()
-
-                        // Assuming that the list of changes doesn't have two changes with the same
-                        // id (PointerId), we can check:
-                        // - If the first change has `id` equals to `velocityPointerId` (this should
-                        //   always be true unless the pointer has been removed).
-                        // - If it does, we've found our change event (assuming there aren't any
-                        //   others changes with the same id in this PointerEvent - not checked).
-                        // - If it doesn't, we can check that the change with that id isn't in first
-                        //   place (which should never happen - this will crash).
-                        check(
-                            pointerChange.id == velocityPointerId ||
-                                !changes.fastAny { it.id == velocityPointerId }
-                        ) {
-                            "$velocityPointerId is present, but not the first: $changes"
-                        }
-
-                        // If the previous pointer has been removed, we use the first available
-                        // change to keep tracking the velocity.
-                        velocityPointerId =
-                            if (pointerChange.pressed) {
-                                pointerChange.id
-                            } else {
-                                changes.first { it.pressed }.id
-                            }
-
-                        velocityTracker.addPointerInputChange(pointerChange)
-                    }
-                }
-            }
-        }
-    }
-
-    private suspend fun PointerInputScope.pointerInput() {
-        val currentContext = currentCoroutineContext()
-        awaitPointerEventScope {
-            while (currentContext.isActive) {
-                try {
-                    detectDragGestures(
-                        orientation = orientation,
-                        onDragStart = { pointersDown, overSlop ->
-                            onDragStarted(pointersDown, overSlop)
-                        },
-                        onDrag = { controller, amount ->
-                            dispatchScrollEvents(
-                                availableOnPreScroll = amount,
-                                onScroll = { controller.onDrag(it) },
-                                source = NestedScrollSource.UserInput,
-                            )
-                        },
-                        onDragEnd = { controller ->
-                            startFlingGesture(
-                                initialVelocity =
-                                    currentValueOf(LocalViewConfiguration)
-                                        .maximumFlingVelocity
-                                        .let {
-                                            val maxVelocity = Velocity(it, it)
-                                            velocityTracker.calculateVelocity(maxVelocity)
-                                        }
-                                        .toFloat(),
-                                onFling = { controller.onStop(it) },
-                            )
-                        },
-                        onDragCancel = { controller ->
-                            startFlingGesture(
-                                initialVelocity = 0f,
-                                onFling = { controller.onStop(it) },
-                            )
-                        },
-                        swipeDetector = swipeDetector,
-                    )
-                } catch (exception: CancellationException) {
-                    // If the coroutine scope is active, we can just restart the drag cycle.
-                    if (!currentContext.isActive) {
-                        throw exception
-                    }
-                }
-            }
-        }
-    }
-
-    /**
-     * Start a fling gesture in another CoroutineScope, this is to ensure that even when the pointer
-     * input scope is reset we will continue any coroutine scope that we started from these methods
-     * while the pointer input scope was active.
-     *
-     * Note: Inspired by [androidx.compose.foundation.gestures.ScrollableNode.onDragStopped]
-     */
-    private fun startFlingGesture(
-        initialVelocity: Float,
-        onFling: suspend (velocity: Float) -> Float,
-    ) {
-        // Note: [AwaitPointerEventScope] is annotated as @RestrictsSuspension, we need another
-        // CoroutineScope to run the fling gestures.
-        // We do not need to cancel this [Job], the source will take care of emitting an
-        // [onPostFling] before starting a new gesture.
-        dispatcher.coroutineScope.launch {
-            dispatchFlingEvents(availableOnPreFling = initialVelocity, onFling = onFling)
-        }
-    }
-
-    /**
-     * Use the nested scroll system to fire scroll events. This allows us to consume events from our
-     * ancestors during the pre-scroll and post-scroll phases.
-     *
-     * @param availableOnPreScroll amount available before the scroll, this can be partially
-     *   consumed by our ancestors.
-     * @param onScroll function that returns the amount consumed during a scroll given the amount
-     *   available after the [NestedScrollConnection.onPreScroll].
-     * @param source the source of the scroll event
-     * @return Total offset consumed.
-     */
-    private inline fun dispatchScrollEvents(
-        availableOnPreScroll: Float,
-        onScroll: (delta: Float) -> Float,
-        source: NestedScrollSource,
-    ): Float {
-        // PreScroll phase
-        val consumedByPreScroll =
-            dispatcher
-                .dispatchPreScroll(available = availableOnPreScroll.toOffset(), source = source)
-                .toFloat()
-
-        // Scroll phase
-        val availableOnScroll = availableOnPreScroll - consumedByPreScroll
-        val consumedBySelfScroll = onScroll(availableOnScroll)
-
-        // PostScroll phase
-        val availableOnPostScroll = availableOnScroll - consumedBySelfScroll
-        val consumedByPostScroll =
-            dispatcher
-                .dispatchPostScroll(
-                    consumed = consumedBySelfScroll.toOffset(),
-                    available = availableOnPostScroll.toOffset(),
-                    source = source,
-                )
-                .toFloat()
-
-        return consumedByPreScroll + consumedBySelfScroll + consumedByPostScroll
-    }
-
-    /**
-     * Use the nested scroll system to fire fling events. This allows us to consume events from our
-     * ancestors during the pre-fling and post-fling phases.
-     *
-     * @param availableOnPreFling velocity available before the fling, this can be partially
-     *   consumed by our ancestors.
-     * @param onFling function that returns the velocity consumed during the fling given the
-     *   velocity available after the [NestedScrollConnection.onPreFling].
-     * @return Total velocity consumed.
-     */
-    private suspend inline fun dispatchFlingEvents(
-        availableOnPreFling: Float,
-        onFling: suspend (velocity: Float) -> Float,
-    ): Float {
-        // PreFling phase
-        val consumedByPreFling =
-            dispatcher.dispatchPreFling(available = availableOnPreFling.toVelocity()).toFloat()
-
-        // Fling phase
-        val availableOnFling = availableOnPreFling - consumedByPreFling
-        val consumedBySelfFling = onFling(availableOnFling)
-
-        // PostFling phase
-        val availableOnPostFling = availableOnFling - consumedBySelfFling
-        val consumedByPostFling =
-            dispatcher
-                .dispatchPostFling(
-                    consumed = consumedBySelfFling.toVelocity(),
-                    available = availableOnPostFling.toVelocity(),
-                )
-                .toFloat()
-
-        return consumedByPreFling + consumedBySelfFling + consumedByPostFling
-    }
-
-    /**
-     * Detect drag gestures in the given [orientation].
-     *
-     * This function is a mix of [androidx.compose.foundation.gestures.awaitDownAndSlop] and
-     * [androidx.compose.foundation.gestures.detectVerticalDragGestures] to add support for passing
-     * the number of pointers down to [onDragStart].
-     */
-    private suspend fun AwaitPointerEventScope.detectDragGestures(
-        orientation: Orientation,
-        onDragStart: (pointersDown: PointersInfo.PointersDown, overSlop: Float) -> DragController,
-        onDrag: (controller: DragController, dragAmount: Float) -> Unit,
-        onDragEnd: (controller: DragController) -> Unit,
-        onDragCancel: (controller: DragController) -> Unit,
-        swipeDetector: SwipeDetector,
-    ) {
-        val consumablePointer =
-            awaitConsumableEvent {
-                    // We are searching for an event that can be used as the starting point for the
-                    // drag gesture. Our options are:
-                    // - Initial: These events should never be consumed by the MultiPointerDraggable
-                    //   since our ancestors can consume the gesture, but we would eliminate this
-                    //   possibility for our descendants.
-                    // - Main: These events are consumed during the drag gesture, and they are a
-                    //   good place to start if the previous event has not been consumed.
-                    // - Final: If the previous event has been consumed, we can wait for the Main
-                    //   pass to finish. If none of our ancestors were interested in the event, we
-                    //   can wait for an unconsumed event in the Final pass.
-                    val previousConsumed = currentEvent.changes.fastAny { it.isConsumed }
-                    if (previousConsumed) PointerEventPass.Final else PointerEventPass.Main
-                }
-                .changes
-                .first()
-
-        var overSlop = 0f
-        val onSlopReached = { change: PointerInputChange, over: Float ->
-            if (swipeDetector.detectSwipe(change)) {
-                change.consume()
-                overSlop = over
-            }
-        }
-
-        // TODO(b/291055080): Replace by await[Orientation]PointerSlopOrCancellation once it
-        // is public.
-        val drag =
-            when (orientation) {
-                Orientation.Horizontal ->
-                    awaitHorizontalTouchSlopOrCancellation(consumablePointer.id, onSlopReached)
-                Orientation.Vertical ->
-                    awaitVerticalTouchSlopOrCancellation(consumablePointer.id, onSlopReached)
-            } ?: return
-
-        val lastPointersDown =
-            checkNotNull(pointersInfo()) {
-                "We should have pointers down, last event: $currentEvent"
-            }
-                as PointersInfo.PointersDown
-        // Make sure that overSlop is not 0f. This can happen when the user drags by exactly
-        // the touch slop. However, the overSlop we pass to onDragStarted() is used to
-        // compute the direction we are dragging in, so overSlop should never be 0f.
-        if (overSlop == 0f) {
-            // If the user drags in the opposite direction, the delta becomes zero because
-            // we return to the original point. Therefore, we should use the previous event
-            // to calculate the direction.
-            val delta = (drag.position - drag.previousPosition).toFloat()
-            check(delta != 0f) {
-                buildString {
-                    append("delta is equal to 0 ")
-                    append("touchSlop ${currentValueOf(LocalViewConfiguration).touchSlop} ")
-                    append("consumablePointer.position ${consumablePointer.position} ")
-                    append("drag.position ${drag.position} ")
-                    append("drag.previousPosition ${drag.previousPosition}")
-                }
-            }
-            overSlop = delta.sign
-        }
-
-        val controller = onDragStart(lastPointersDown, overSlop)
-        val successful: Boolean
-        try {
-            onDrag(controller, overSlop)
-
-            successful =
-                drag(
-                    initialPointerId = drag.id,
-                    hasDragged = { it.positionChangeIgnoreConsumed().toFloat() != 0f },
-                    onDrag = {
-                        onDrag(controller, it.positionChange().toFloat())
-                        it.consume()
-                    },
-                    onIgnoredEvent = {
-                        // We are still dragging an object, but this event is not of interest to the
-                        // caller.
-                        // This event will not trigger the onDrag event, but we will consume the
-                        // event to prevent another pointerInput from interrupting the current
-                        // gesture just because the event was ignored.
-                        it.consume()
-                    },
-                )
-        } catch (t: Throwable) {
-            onDragCancel(controller)
-            throw t
-        }
-
-        if (successful) {
-            onDragEnd(controller)
-        } else {
-            onDragCancel(controller)
-        }
-    }
-
-    private suspend fun AwaitPointerEventScope.awaitConsumableEvent(
-        pass: () -> PointerEventPass
-    ): PointerEvent {
-        fun canBeConsumed(changes: List<PointerInputChange>): Boolean {
-            // At least one pointer down AND
-            return changes.fastAny { it.pressed } &&
-                // All pointers must be either:
-                changes.fastAll {
-                    // A) unconsumed AND recently pressed
-                    it.changedToDown() ||
-                        // B) unconsumed AND in a new position (on the current axis)
-                        it.positionChange().toFloat() != 0f
-                }
-        }
-
-        var event: PointerEvent
-        do {
-            event = awaitPointerEvent(pass = pass())
-        } while (!canBeConsumed(event.changes))
-
-        // We found a consumable event in the Main pass
-        return event
-    }
-
-    /**
-     * 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 [initialPointerId]. [hasDragged] passes the
-     * result whether a change was detected from the drag function or not.
-     *
-     * Whenever the pointer moves, if [hasDragged] returns true, [onDrag] is called; otherwise,
-     * [onIgnoredEvent] is called.
-     *
-     * @return true when gesture ended with all pointers up and false when the gesture was canceled.
-     *
-     * Note: Inspired by DragGestureDetector.kt
-     */
-    private suspend inline fun AwaitPointerEventScope.drag(
-        initialPointerId: PointerId,
-        hasDragged: (PointerInputChange) -> Boolean,
-        onDrag: (PointerInputChange) -> Unit,
-        onIgnoredEvent: (PointerInputChange) -> Unit,
-    ): Boolean {
-        val pointer = currentEvent.changes.fastFirstOrNull { it.id == initialPointerId }
-        val isPointerUp = pointer?.pressed != true
-        if (isPointerUp) {
-            return false // The pointer has already been lifted, so the gesture is canceled
-        }
-        var pointerId = initialPointerId
-        while (true) {
-            val change = awaitDragOrUp(pointerId, hasDragged, onIgnoredEvent) ?: return false
-
-            if (change.isConsumed) {
-                return false
-            }
-
-            if (change.changedToUpIgnoreConsumed()) {
-                return true
-            }
-
-            onDrag(change)
-            pointerId = change.id
-        }
-    }
-
-    /**
-     * Waits for a single drag in one axis, final pointer up, or all pointers are up. When
-     * [initialPointerId] 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`. Events that should not be captured are passed to
-     * [onIgnoredEvent].
-     *
-     * `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.
-     *
-     * Note: Inspired by DragGestureDetector.kt
-     */
-    private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
-        initialPointerId: PointerId,
-        hasDragged: (PointerInputChange) -> Boolean,
-        onIgnoredEvent: (PointerInputChange) -> Unit,
-    ): PointerInputChange? {
-        var pointerId = initialPointerId
-        while (true) {
-            val event = awaitPointerEvent()
-            val dragEvent = event.changes.fastFirstOrNull { it.id == pointerId } ?: return null
-            if (dragEvent.changedToUpIgnoreConsumed()) {
-                val otherDown = event.changes.fastFirstOrNull { it.pressed }
-                if (otherDown == null) {
-                    // This is the last "up"
-                    return dragEvent
-                } else {
-                    pointerId = otherDown.id
-                }
-            } else if (hasDragged(dragEvent)) {
-                return dragEvent
-            } else {
-                onIgnoredEvent(dragEvent)
-            }
-        }
-    }
-
-    private fun List<PointerInputChange>.countDown() = fastSumBy { if (it.pressed) 1 else 0 }
-}
-
-internal fun interface PointersInfoOwner {
-    /**
-     * Provides information about the pointers interacting with this composable.
-     *
-     * @return A [PointersInfo] object containing details about the pointers, including the starting
-     *   position and the number of pointers down, or `null` if there are no pointers down.
-     */
-    fun pointersInfo(): PointersInfo?
-}
-
-internal sealed interface PointersInfo {
-    /**
-     * Holds information about pointer interactions within a composable.
-     *
-     * This class stores details such as the starting position of a gesture, the number of pointers
-     * down, and whether the last pointer event was a mouse wheel scroll.
-     *
-     * @param startedPosition The starting position of the gesture. This is the position where the
-     *   first pointer touched the screen, not necessarily the point where dragging begins. This may
-     *   be different from the initial touch position if a child composable intercepts the gesture
-     *   before this one.
-     * @param count The number of pointers currently down.
-     * @param countByType Provide a map of pointer types to the count of pointers of that type
-     *   currently down/pressed.
-     */
-    data class PointersDown(
-        val startedPosition: Offset,
-        val count: Int,
-        val countByType: Map<PointerType, Int>,
-    ) : PointersInfo {
-        init {
-            check(count > 0) { "We should have at least 1 pointer down, $count instead" }
-        }
-    }
-
-    /** Indicates whether the last pointer event was a mouse wheel scroll. */
-    data object MouseWheel : PointersInfo
-}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
index de428a7..4e38947 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
@@ -457,7 +457,7 @@
 private constructor(
     val direction: SwipeDirection,
     val pointerCount: Int = 1,
-    val pointersType: PointerType? = null,
+    val pointerType: PointerType? = null,
     val fromSource: SwipeSource? = null,
 ) : UserAction() {
     companion object {
@@ -470,46 +470,46 @@
 
         fun Left(
             pointerCount: Int = 1,
-            pointersType: PointerType? = null,
+            pointerType: PointerType? = null,
             fromSource: SwipeSource? = null,
-        ) = Swipe(SwipeDirection.Left, pointerCount, pointersType, fromSource)
+        ) = Swipe(SwipeDirection.Left, pointerCount, pointerType, fromSource)
 
         fun Up(
             pointerCount: Int = 1,
-            pointersType: PointerType? = null,
+            pointerType: PointerType? = null,
             fromSource: SwipeSource? = null,
-        ) = Swipe(SwipeDirection.Up, pointerCount, pointersType, fromSource)
+        ) = Swipe(SwipeDirection.Up, pointerCount, pointerType, fromSource)
 
         fun Right(
             pointerCount: Int = 1,
-            pointersType: PointerType? = null,
+            pointerType: PointerType? = null,
             fromSource: SwipeSource? = null,
-        ) = Swipe(SwipeDirection.Right, pointerCount, pointersType, fromSource)
+        ) = Swipe(SwipeDirection.Right, pointerCount, pointerType, fromSource)
 
         fun Down(
             pointerCount: Int = 1,
-            pointersType: PointerType? = null,
+            pointerType: PointerType? = null,
             fromSource: SwipeSource? = null,
-        ) = Swipe(SwipeDirection.Down, pointerCount, pointersType, fromSource)
+        ) = Swipe(SwipeDirection.Down, pointerCount, pointerType, fromSource)
 
         fun Start(
             pointerCount: Int = 1,
-            pointersType: PointerType? = null,
+            pointerType: PointerType? = null,
             fromSource: SwipeSource? = null,
-        ) = Swipe(SwipeDirection.Start, pointerCount, pointersType, fromSource)
+        ) = Swipe(SwipeDirection.Start, pointerCount, pointerType, fromSource)
 
         fun End(
             pointerCount: Int = 1,
-            pointersType: PointerType? = null,
+            pointerType: PointerType? = null,
             fromSource: SwipeSource? = null,
-        ) = Swipe(SwipeDirection.End, pointerCount, pointersType, fromSource)
+        ) = Swipe(SwipeDirection.End, pointerCount, pointerType, fromSource)
     }
 
     override fun resolve(layoutDirection: LayoutDirection): UserAction.Resolved {
         return Resolved(
             direction = direction.resolve(layoutDirection),
             pointerCount = pointerCount,
-            pointersType = pointersType,
+            pointerType = pointerType,
             fromSource = fromSource?.resolve(layoutDirection),
         )
     }
@@ -519,7 +519,7 @@
         val direction: SwipeDirection.Resolved,
         val pointerCount: Int,
         val fromSource: SwipeSource.Resolved?,
-        val pointersType: PointerType?,
+        val pointerType: PointerType?,
     ) : UserAction.Resolved()
 }
 
@@ -724,6 +724,7 @@
                 density = density,
                 layoutDirection = layoutDirection,
                 swipeSourceDetector = swipeSourceDetector,
+                swipeDetector = swipeDetector,
                 transitionInterceptionThreshold = transitionInterceptionThreshold,
                 builder = builder,
                 animationScope = animationScope,
@@ -767,8 +768,9 @@
         layoutImpl.density = density
         layoutImpl.layoutDirection = layoutDirection
         layoutImpl.swipeSourceDetector = swipeSourceDetector
+        layoutImpl.swipeDetector = swipeDetector
         layoutImpl.transitionInterceptionThreshold = transitionInterceptionThreshold
     }
 
-    layoutImpl.Content(modifier, swipeDetector)
+    layoutImpl.Content(modifier)
 }
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
index e5bdc92..b4c449d 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
@@ -31,6 +31,9 @@
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.ExperimentalComposeUiApi
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
+import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.layout.ApproachLayoutModifierNode
 import androidx.compose.ui.layout.ApproachMeasureScope
 import androidx.compose.ui.layout.LookaheadScope
@@ -49,10 +52,8 @@
 import com.android.compose.animation.scene.content.Overlay
 import com.android.compose.animation.scene.content.Scene
 import com.android.compose.animation.scene.content.state.TransitionState
-import com.android.compose.animation.scene.effect.GestureEffect
 import com.android.compose.ui.util.lerp
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
 
 /** The type for the content of movable elements. */
 internal typealias MovableElementContent = @Composable (@Composable () -> Unit) -> Unit
@@ -75,6 +76,7 @@
     internal var density: Density,
     internal var layoutDirection: LayoutDirection,
     internal var swipeSourceDetector: SwipeSourceDetector,
+    internal var swipeDetector: SwipeDetector,
     internal var transitionInterceptionThreshold: Float,
     builder: SceneTransitionLayoutScope.() -> Unit,
 
@@ -149,18 +151,6 @@
                     _movableContents = it
                 }
 
-    internal var horizontalOverscrollableContent =
-        OverscrollableContent(
-            animationScope = animationScope,
-            overscrollEffect = { content(it).scope.horizontalOverscrollGestureEffect },
-        )
-
-    internal var verticalOverscrollableContent =
-        OverscrollableContent(
-            animationScope = animationScope,
-            overscrollEffect = { content(it).scope.verticalOverscrollGestureEffect },
-        )
-
     /**
      * The different values of a shared value keyed by a a [ValueKey] and the different elements and
      * contents it is associated to.
@@ -175,8 +165,8 @@
                 }
 
     // TODO(b/317958526): Lazily allocate scene gesture handlers the first time they are needed.
-    internal val horizontalDraggableHandler: DraggableHandlerImpl
-    internal val verticalDraggableHandler: DraggableHandlerImpl
+    internal val horizontalDraggableHandler: DraggableHandler
+    internal val verticalDraggableHandler: DraggableHandler
 
     internal val elementStateScope = ElementStateScopeImpl(this)
     internal val propertyTransformationScope = PropertyTransformationScopeImpl(this)
@@ -190,16 +180,33 @@
 
     internal var lastSize: IntSize = IntSize.Zero
 
+    /**
+     * An empty [NestedScrollDispatcher] and [NestedScrollConnection]. These are composed above our
+     * [SwipeToSceneElement] modifiers, so that the dispatcher will be used by the nested draggables
+     * to launch fling events, making sure that they are not cancelled unless this whole layout is
+     * removed from composition.
+     */
+    private val nestedScrollDispatcher = NestedScrollDispatcher()
+    private val nestedScrollConnection = object : NestedScrollConnection {}
+
     init {
         updateContents(builder, layoutDirection)
 
         // DraggableHandlerImpl must wait for the scenes to be initialized, in order to access the
         // current scene (required for SwipeTransition).
         horizontalDraggableHandler =
-            DraggableHandlerImpl(layoutImpl = this, orientation = Orientation.Horizontal)
+            DraggableHandler(
+                layoutImpl = this,
+                orientation = Orientation.Horizontal,
+                gestureEffectProvider = { content(it).scope.horizontalOverscrollGestureEffect },
+            )
 
         verticalDraggableHandler =
-            DraggableHandlerImpl(layoutImpl = this, orientation = Orientation.Vertical)
+            DraggableHandler(
+                layoutImpl = this,
+                orientation = Orientation.Vertical,
+                gestureEffectProvider = { content(it).scope.verticalOverscrollGestureEffect },
+            )
 
         // Make sure that the state is created on the same thread (most probably the main thread)
         // than this STLImpl.
@@ -379,14 +386,15 @@
     }
 
     @Composable
-    internal fun Content(modifier: Modifier, swipeDetector: SwipeDetector) {
+    internal fun Content(modifier: Modifier) {
         Box(
             modifier
+                .nestedScroll(nestedScrollConnection, nestedScrollDispatcher)
                 // Handle horizontal and vertical swipes on this layout.
                 // Note: order here is important and will give a slight priority to the vertical
                 // swipes.
-                .swipeToScene(horizontalDraggableHandler, swipeDetector)
-                .swipeToScene(verticalDraggableHandler, swipeDetector)
+                .swipeToScene(horizontalDraggableHandler)
+                .swipeToScene(verticalDraggableHandler)
                 .then(LayoutElement(layoutImpl = this))
         ) {
             LookaheadScope {
@@ -580,23 +588,3 @@
         return layout(width, height) { placeable.place(0, 0) }
     }
 }
-
-internal class OverscrollableContent(
-    private val animationScope: CoroutineScope,
-    private val overscrollEffect: (ContentKey) -> GestureEffect,
-) {
-    private var currentContent: ContentKey? = null
-    var currentOverscrollEffect: GestureEffect? = null
-
-    fun applyOverscrollEffectOn(contentKey: ContentKey): GestureEffect {
-        if (currentContent == contentKey) return currentOverscrollEffect!!
-
-        currentOverscrollEffect?.apply { animationScope.launch { ensureApplyToFlingIsCalled() } }
-
-        // We are wrapping the overscroll effect.
-        val overscrollEffect = overscrollEffect(contentKey)
-        currentContent = contentKey
-        currentOverscrollEffect = overscrollEffect
-        return overscrollEffect
-    }
-}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt
index 2bfa019..b1d6d1e 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeAnimation.kt
@@ -24,6 +24,7 @@
 import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.util.fastCoerceIn
 import com.android.compose.animation.scene.content.state.TransitionState
 import com.android.compose.animation.scene.content.state.TransitionState.Companion.DistanceUnspecified
 import kotlin.math.absoluteValue
@@ -76,7 +77,16 @@
             return DistanceUnspecified
         }
 
-        val distance = if (isUpOrLeft) -absoluteDistance else absoluteDistance
+        // Compute the signed distance and make sure that the offset is always coerced in the right
+        // range.
+        val distance =
+            if (isUpOrLeft) {
+                animation.dragOffset = animation.dragOffset.fastCoerceIn(-absoluteDistance, 0f)
+                -absoluteDistance
+            } else {
+                animation.dragOffset = animation.dragOffset.fastCoerceIn(0f, absoluteDistance)
+                absoluteDistance
+            }
         lastDistance = distance
         return distance
     }
@@ -294,12 +304,10 @@
         initialVelocity: Float,
         targetContent: T,
         spec: AnimationSpec<Float>? = null,
-        overscrollCompletable: CompletableDeferred<Unit>? = null,
+        awaitFling: (suspend () -> Unit)? = null,
     ): Float {
         check(!isAnimatingOffset()) { "SwipeAnimation.animateOffset() can only be called once" }
 
-        val initialProgress = progress
-
         val targetContent =
             if (targetContent != currentContent && !canChangeContent(targetContent)) {
                 currentContent
@@ -307,18 +315,16 @@
                 targetContent
             }
 
-        // Skip the animation if we have already reached the target content and the overscroll does
-        // not animate anything.
-        val hasReachedTargetContent =
-            (targetContent == toContent && initialProgress >= 1f) ||
-                (targetContent == fromContent && initialProgress <= 0f)
-        val skipAnimation =
-            hasReachedTargetContent && !contentTransition.isWithinProgressRange(initialProgress)
-
         val distance = distance()
-        check(distance != DistanceUnspecified) { "distance is equal to $DistanceUnspecified" }
-
-        val targetOffset = if (targetContent == fromContent) 0f else distance
+        val targetOffset =
+            if (targetContent == fromContent) {
+                0f
+            } else {
+                check(distance != DistanceUnspecified) {
+                    "distance is equal to $DistanceUnspecified"
+                }
+                distance
+            }
 
         // If the effective current content changed, it should be reflected right now in the
         // current state, even before the settle animation is ongoing. That way all the
@@ -350,28 +356,12 @@
 
         check(isAnimatingOffset())
 
-        // Note: we still create the animatable and set it on offsetAnimation even when
-        // skipAnimation is true, just so that isUserInputOngoing and isAnimatingOffset() are
-        // unchanged even despite this small skip-optimization (which is just an implementation
-        // detail).
-        if (skipAnimation) {
-            // Unblock the job.
-            offsetAnimationRunnable.complete {
-                // Wait for overscroll to finish so that the transition is removed from the STLState
-                // only after the overscroll is done, to avoid dropping frame right when the user
-                // lifts their finger and overscroll is animated to 0.
-                overscrollCompletable?.await()
-            }
-            return 0f
-        }
-
         val motionSpatialSpec =
             spec
                 ?: contentTransition.transformationSpec.motionSpatialSpec
                 ?: layoutState.transitions.defaultMotionSpatialSpec
 
         val velocityConsumed = CompletableDeferred<Float>()
-
         offsetAnimationRunnable.complete {
             val result =
                 animatable.animateTo(
@@ -385,9 +375,9 @@
             velocityConsumed.complete(initialVelocity - result.endState.velocity)
 
             // Wait for overscroll to finish so that the transition is removed from the STLState
-            // only after the overscroll is done, to avoid dropping frame right when the user
-            // lifts their finger and overscroll is animated to 0.
-            overscrollCompletable?.await()
+            // only after the overscroll is done, to avoid dropping frame right when the user lifts
+            // their finger and overscroll is animated to 0.
+            awaitFling?.invoke()
         }
 
         return velocityConsumed.await()
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
index e221211..19f707d 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
@@ -19,153 +19,35 @@
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.runtime.Stable
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
-import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
-import androidx.compose.ui.input.pointer.PointerEvent
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.node.DelegatingNode
-import androidx.compose.ui.node.ModifierNodeElement
-import androidx.compose.ui.node.PointerInputModifierNode
-import androidx.compose.ui.unit.IntSize
 import com.android.compose.animation.scene.content.Content
+import com.android.compose.gesture.nestedDraggable
 
 /**
  * Configures the swipeable behavior of a [SceneTransitionLayout] depending on the current state.
  */
 @Stable
-internal fun Modifier.swipeToScene(
-    draggableHandler: DraggableHandlerImpl,
-    swipeDetector: SwipeDetector,
-): Modifier {
-    return then(SwipeToSceneElement(draggableHandler, swipeDetector, draggableHandler.enabled()))
+internal fun Modifier.swipeToScene(draggableHandler: DraggableHandler): Modifier {
+    return this.nestedDraggable(
+        draggable = draggableHandler,
+        orientation = draggableHandler.orientation,
+        overscrollEffect = draggableHandler.overscrollEffect,
+        enabled = draggableHandler.enabled(),
+    )
 }
 
-private fun DraggableHandlerImpl.enabled(): Boolean {
+internal fun DraggableHandler.enabled(): Boolean {
     return isDrivingTransition || contentForSwipes().shouldEnableSwipes(orientation)
 }
 
-private fun DraggableHandlerImpl.contentForSwipes(): Content {
+private fun DraggableHandler.contentForSwipes(): Content {
     return layoutImpl.contentForUserActions()
 }
 
 /** Whether swipe should be enabled in the given [orientation]. */
-internal fun Content.shouldEnableSwipes(orientation: Orientation): Boolean {
+private fun Content.shouldEnableSwipes(orientation: Orientation): Boolean {
     if (userActions.isEmpty() || !areSwipesAllowed()) {
         return false
     }
 
     return userActions.keys.any { it is Swipe.Resolved && it.direction.orientation == orientation }
 }
-
-private data class SwipeToSceneElement(
-    val draggableHandler: DraggableHandlerImpl,
-    val swipeDetector: SwipeDetector,
-    val enabled: Boolean,
-) : ModifierNodeElement<SwipeToSceneRootNode>() {
-    override fun create(): SwipeToSceneRootNode =
-        SwipeToSceneRootNode(draggableHandler, swipeDetector, enabled)
-
-    override fun update(node: SwipeToSceneRootNode) {
-        node.update(draggableHandler, swipeDetector, enabled)
-    }
-}
-
-private class SwipeToSceneRootNode(
-    draggableHandler: DraggableHandlerImpl,
-    swipeDetector: SwipeDetector,
-    enabled: Boolean,
-) : DelegatingNode() {
-    private var delegateNode = if (enabled) create(draggableHandler, swipeDetector) else null
-
-    fun update(
-        draggableHandler: DraggableHandlerImpl,
-        swipeDetector: SwipeDetector,
-        enabled: Boolean,
-    ) {
-        // Disabled.
-        if (!enabled) {
-            delegateNode?.let { undelegate(it) }
-            delegateNode = null
-            return
-        }
-
-        // Disabled => Enabled.
-        val nullableDelegate = delegateNode
-        if (nullableDelegate == null) {
-            delegateNode = create(draggableHandler, swipeDetector)
-            return
-        }
-
-        // Enabled => Enabled (update).
-        if (draggableHandler == nullableDelegate.draggableHandler) {
-            // Simple update, just update the swipe detector directly and keep the node.
-            nullableDelegate.swipeDetector = swipeDetector
-        } else {
-            // The draggableHandler changed, force recreate the underlying SwipeToSceneNode.
-            undelegate(nullableDelegate)
-            delegateNode = create(draggableHandler, swipeDetector)
-        }
-    }
-
-    private fun create(
-        draggableHandler: DraggableHandlerImpl,
-        swipeDetector: SwipeDetector,
-    ): SwipeToSceneNode {
-        return delegate(SwipeToSceneNode(draggableHandler, swipeDetector))
-    }
-}
-
-private class SwipeToSceneNode(
-    val draggableHandler: DraggableHandlerImpl,
-    swipeDetector: SwipeDetector,
-) : DelegatingNode(), PointerInputModifierNode {
-    private val dispatcher = NestedScrollDispatcher()
-    private val multiPointerDraggableNode =
-        delegate(
-            MultiPointerDraggableNode(
-                orientation = draggableHandler.orientation,
-                onDragStarted = draggableHandler::onDragStarted,
-                onFirstPointerDown = ::onFirstPointerDown,
-                swipeDetector = swipeDetector,
-                dispatcher = dispatcher,
-            )
-        )
-
-    var swipeDetector: SwipeDetector
-        get() = multiPointerDraggableNode.swipeDetector
-        set(value) {
-            multiPointerDraggableNode.swipeDetector = value
-        }
-
-    private val nestedScrollHandlerImpl =
-        NestedScrollHandlerImpl(
-            draggableHandler = draggableHandler,
-            pointersInfoOwner = { multiPointerDraggableNode.pointersInfo() },
-        )
-
-    init {
-        delegate(nestedScrollModifierNode(nestedScrollHandlerImpl.connection, dispatcher))
-    }
-
-    private fun onFirstPointerDown() {
-        // When we drag our finger across the screen, the NestedScrollConnection keeps track of all
-        // the scroll events until we lift our finger. However, in some cases, the connection might
-        // not receive the "up" event. This can lead to an incorrect initial state for the gesture.
-        // To prevent this issue, we can call the reset() method when the first finger touches the
-        // screen. This ensures that the NestedScrollConnection starts from a correct state.
-        nestedScrollHandlerImpl.connection.reset()
-    }
-
-    override fun onDetach() {
-        // Make sure we reset the scroll connection when this modifier is removed from composition
-        nestedScrollHandlerImpl.connection.reset()
-    }
-
-    override fun onPointerEvent(
-        pointerEvent: PointerEvent,
-        pass: PointerEventPass,
-        bounds: IntSize,
-    ) = multiPointerDraggableNode.onPointerEvent(pointerEvent, pass, bounds)
-
-    override fun onCancelPointerInput() = multiPointerDraggableNode.onCancelPointerInput()
-}
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt
index 0977226..f772f1a 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/state/TransitionState.kt
@@ -373,15 +373,6 @@
             }
         }
 
-        /**
-         * Checks if the given [progress] value is within the valid range for this transition.
-         *
-         * The valid range is between 0f and 1f, inclusive.
-         */
-        internal fun isWithinProgressRange(progress: Float): Boolean {
-            return progress >= 0f && progress <= 1f
-        }
-
         internal open fun interruptionProgress(layoutImpl: SceneTransitionLayoutImpl): Float {
             if (replacedTransition != null) {
                 return replacedTransition.interruptionProgress(layoutImpl)
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
index 5a9edba..4a0c330 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/DraggableHandlerTest.kt
@@ -38,9 +38,11 @@
 import com.android.compose.animation.scene.content.state.TransitionState
 import com.android.compose.animation.scene.content.state.TransitionState.Transition
 import com.android.compose.animation.scene.subjects.assertThat
+import com.android.compose.gesture.NestedDraggable
 import com.android.compose.test.MonotonicClockTestScope
 import com.android.compose.test.runMonotonicClockTest
 import com.google.common.truth.Truth.assertThat
+import kotlin.math.sign
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Deferred
 import kotlinx.coroutines.async
@@ -51,18 +53,6 @@
 private const val SCREEN_SIZE = 100f
 private val LAYOUT_SIZE = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt())
 
-private fun pointersDown(
-    startedPosition: Offset = Offset.Zero,
-    pointersDown: Int = 1,
-    pointersDownByType: Map<PointerType, Int> = mapOf(PointerType.Touch to pointersDown),
-): PointersInfo.PointersDown {
-    return PointersInfo.PointersDown(
-        startedPosition = startedPosition,
-        count = pointersDown,
-        countByType = pointersDownByType,
-    )
-}
-
 @RunWith(AndroidJUnit4::class)
 class DraggableHandlerTest {
     private class TestGestureScope(val testScope: MonotonicClockTestScope) {
@@ -123,6 +113,7 @@
                     density = Density(1f),
                     layoutDirection = LayoutDirection.Ltr,
                     swipeSourceDetector = DefaultEdgeDetector,
+                    swipeDetector = DefaultSwipeDetector,
                     transitionInterceptionThreshold = transitionInterceptionThreshold,
                     builder = scenesBuilder,
 
@@ -134,9 +125,6 @@
 
         val draggableHandler = layoutImpl.verticalDraggableHandler
         val horizontalDraggableHandler = layoutImpl.horizontalDraggableHandler
-
-        var pointerInfoOwner: () -> PointersInfo = { pointersDown() }
-
         val velocityThreshold = draggableHandler.velocityThreshold
 
         fun down(fractionOfScreen: Float) =
@@ -204,41 +192,51 @@
         }
 
         fun onDragStarted(
-            pointersInfo: PointersInfo.PointersDown = pointersDown(),
             overSlop: Float,
+            position: Offset = Offset.Zero,
+            pointersDown: Int = 1,
+            pointerType: PointerType? = PointerType.Touch,
             expectedConsumedOverSlop: Float = overSlop,
-        ): DragController {
-            // overSlop should be 0f only if the drag gesture starts with startDragImmediately
-            if (overSlop == 0f) error("Consider using onDragStartedImmediately()")
+        ): NestedDraggable.Controller {
             return onDragStarted(
                 draggableHandler = draggableHandler,
-                pointersInfo = pointersInfo,
                 overSlop = overSlop,
+                position = position,
+                pointersDown = pointersDown,
+                pointerType = pointerType,
                 expectedConsumedOverSlop = expectedConsumedOverSlop,
             )
         }
 
         fun onDragStarted(
-            draggableHandler: DraggableHandler,
-            pointersInfo: PointersInfo.PointersDown = pointersDown(),
-            overSlop: Float = 0f,
+            draggableHandler: NestedDraggable,
+            overSlop: Float,
+            position: Offset = Offset.Zero,
+            pointersDown: Int = 1,
+            pointerType: PointerType? = PointerType.Touch,
             expectedConsumedOverSlop: Float = overSlop,
-        ): DragController {
-            val dragController =
-                draggableHandler.onDragStarted(pointersDown = pointersInfo, overSlop = overSlop)
+        ): NestedDraggable.Controller {
+            // overSlop should be 0f only if the drag gesture starts with startDragImmediately.
+            if (overSlop == 0f) error("Consider using onDragStartedImmediately()")
 
-            // MultiPointerDraggable will always call onDelta with the initial overSlop right after
+            val dragController =
+                draggableHandler.onDragStarted(position, overSlop.sign, pointersDown, pointerType)
+
+            // MultiPointerDraggable will always call onDelta with the initial overSlop right after.
             dragController.onDragDelta(pixels = overSlop, expectedConsumedOverSlop)
 
             return dragController
         }
 
-        fun DragController.onDragDelta(pixels: Float, expectedConsumed: Float = pixels) {
+        fun NestedDraggable.Controller.onDragDelta(
+            pixels: Float,
+            expectedConsumed: Float = pixels,
+        ) {
             val consumed = onDrag(delta = pixels)
             assertThat(consumed).isEqualTo(expectedConsumed)
         }
 
-        suspend fun DragController.onDragStoppedAnimateNow(
+        suspend fun NestedDraggable.Controller.onDragStoppedAnimateNow(
             velocity: Float,
             onAnimationStart: () -> Unit,
             onAnimationEnd: (Float) -> Unit,
@@ -248,7 +246,7 @@
             onAnimationEnd(velocityConsumed.await())
         }
 
-        suspend fun DragController.onDragStoppedAnimateNow(
+        suspend fun NestedDraggable.Controller.onDragStoppedAnimateNow(
             velocity: Float,
             onAnimationStart: () -> Unit,
         ) =
@@ -258,8 +256,8 @@
                 onAnimationEnd = {},
             )
 
-        fun DragController.onDragStoppedAnimateLater(velocity: Float): Deferred<Float> {
-            val velocityConsumed = testScope.async { onStop(velocity) }
+        fun NestedDraggable.Controller.onDragStoppedAnimateLater(velocity: Float): Deferred<Float> {
+            val velocityConsumed = testScope.async { onDragStopped(velocity, awaitFling = {}) }
             testScope.testScheduler.runCurrent()
             return velocityConsumed
         }
@@ -408,12 +406,13 @@
     }
 
     @Test
-    fun onDragIntoNoAction_stayIdle() = runGestureTest {
+    fun onDragIntoNoAction_overscrolls() = runGestureTest {
         navigateToSceneC()
 
-        // We are on SceneC which has no action in Down direction
+        // We are on SceneC which has no action in Down direction, we still start a transition so
+        // that we can overscroll on that scene.
         onDragStarted(overSlop = 10f, expectedConsumedOverSlop = 0f)
-        assertIdle(currentScene = SceneC)
+        assertTransition(fromScene = SceneC, toScene = SceneB, progress = 0f)
     }
 
     @Test
@@ -422,8 +421,7 @@
         mutableUserActionsA = mapOf(Swipe.Up to UserActionResult(SceneB), Swipe.Down to SceneC)
         val dragController =
             onDragStarted(
-                pointersInfo =
-                    pointersDown(startedPosition = Offset(SCREEN_SIZE * 0.5f, SCREEN_SIZE * 0.5f)),
+                position = Offset(SCREEN_SIZE * 0.5f, SCREEN_SIZE * 0.5f),
                 overSlop = up(fractionOfScreen = 0.2f),
             )
         assertTransition(
@@ -447,7 +445,7 @@
 
         // Start dragging from the bottom
         onDragStarted(
-            pointersInfo = pointersDown(startedPosition = Offset(SCREEN_SIZE * 0.5f, SCREEN_SIZE)),
+            position = Offset(SCREEN_SIZE * 0.5f, SCREEN_SIZE),
             overSlop = up(fractionOfScreen = 0.1f),
         )
         assertTransition(
@@ -548,8 +546,7 @@
         navigateToSceneC()
 
         // Swipe up from the middle to transition to scene B.
-        val middle = pointersDown(startedPosition = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f))
-        onDragStarted(pointersInfo = middle, overSlop = up(0.1f))
+        onDragStarted(position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f), overSlop = up(0.1f))
         assertTransition(fromScene = SceneC, toScene = SceneB, isUserInputOngoing = true)
 
         // Freeze the transition.
@@ -602,8 +599,11 @@
     @Test
     fun transitionIsImmediatelyUpdatedWhenReleasingFinger() = runGestureTest {
         // Swipe up from the middle to transition to scene B.
-        val middle = pointersDown(startedPosition = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f))
-        val dragController = onDragStarted(pointersInfo = middle, overSlop = up(0.1f))
+        val dragController =
+            onDragStarted(
+                position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
+                overSlop = up(0.1f),
+            )
         assertTransition(fromScene = SceneA, toScene = SceneB, isUserInputOngoing = true)
 
         dragController.onDragStoppedAnimateLater(velocity = 0f)
@@ -613,8 +613,11 @@
     @Test
     fun emptyOverscrollAbortsSettleAnimationAndExposeTheConsumedVelocity() = runGestureTest {
         // Swipe up to scene B at progress = 200%.
-        val middle = pointersDown(startedPosition = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f))
-        val dragController = onDragStarted(pointersInfo = middle, overSlop = up(0.99f))
+        val dragController =
+            onDragStarted(
+                position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
+                overSlop = up(0.99f),
+            )
         assertTransition(fromScene = SceneA, toScene = SceneB, progress = 0.99f)
 
         // Release the finger.
@@ -638,9 +641,11 @@
             from(SceneA, to = SceneB) {}
         }
 
-        val middle = pointersDown(startedPosition = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f))
-
-        val dragController = onDragStarted(pointersInfo = middle, overSlop = up(0.5f))
+        val dragController =
+            onDragStarted(
+                position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
+                overSlop = up(0.5f),
+            )
         val transition = assertThat(transitionState).isSceneTransition()
         assertThat(transition).hasFromScene(SceneA)
         assertThat(transition).hasToScene(SceneB)
@@ -667,9 +672,11 @@
             from(SceneA, to = SceneC) {}
         }
 
-        val middle = pointersDown(startedPosition = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f))
-
-        val dragController = onDragStarted(pointersInfo = middle, overSlop = down(0.5f))
+        val dragController =
+            onDragStarted(
+                position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
+                overSlop = down(0.5f),
+            )
         val transition = assertThat(transitionState).isSceneTransition()
         assertThat(transition).hasFromScene(SceneA)
         assertThat(transition).hasToScene(SceneC)
@@ -695,11 +702,9 @@
             from(SceneA, to = SceneB) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
         }
 
-        val middle = pointersDown(startedPosition = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f))
-
         val dragController =
             onDragStarted(
-                pointersInfo = middle,
+                position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
                 overSlop = up(1.5f),
                 expectedConsumedOverSlop = up(1f),
             )
@@ -729,11 +734,9 @@
             from(SceneA, to = SceneC) { spec = spring(dampingRatio = Spring.DampingRatioNoBouncy) }
         }
 
-        val middle = pointersDown(startedPosition = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f))
-
         val dragController =
             onDragStarted(
-                pointersInfo = middle,
+                position = Offset(SCREEN_SIZE / 2f, SCREEN_SIZE / 2f),
                 overSlop = down(1.5f),
                 expectedConsumedOverSlop = down(1f),
             )
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt
deleted file mode 100644
index 5c6f91b..0000000
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/MultiPointerDraggableTest.kt
+++ /dev/null
@@ -1,866 +0,0 @@
-/*
- * Copyright (C) 2024 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 com.android.compose.animation.scene
-
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.rememberScrollableState
-import androidx.compose.foundation.gestures.scrollable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.size
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
-import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
-import androidx.compose.ui.input.nestedscroll.NestedScrollSource
-import androidx.compose.ui.input.nestedscroll.nestedScroll
-import androidx.compose.ui.input.pointer.AwaitPointerEventScope
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.PointerInputChange
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalViewConfiguration
-import androidx.compose.ui.test.TouchInjectionScope
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onRoot
-import androidx.compose.ui.test.performTouchInput
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.Velocity
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.android.compose.modifiers.thenIf
-import com.google.common.truth.Truth.assertThat
-import kotlin.properties.Delegates
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.isActive
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@RunWith(AndroidJUnit4::class)
-class MultiPointerDraggableTest {
-    @get:Rule val rule = createComposeRule()
-
-    private val emptyConnection = object : NestedScrollConnection {}
-    private val defaultDispatcher = NestedScrollDispatcher()
-
-    private fun Modifier.nestedScrollDispatcher() = nestedScroll(emptyConnection, defaultDispatcher)
-
-    private class SimpleDragController(
-        val onDrag: (delta: Float) -> Unit,
-        val onStop: (velocity: Float) -> Unit,
-    ) : DragController {
-        override fun onDrag(delta: Float): Float {
-            onDrag.invoke(delta)
-            return delta
-        }
-
-        override suspend fun onStop(velocity: Float): Float {
-            onStop.invoke(velocity)
-            return velocity
-        }
-
-        override fun onCancel() {
-            error("MultiPointerDraggable never calls onCancel()")
-        }
-    }
-
-    @Test
-    fun cancellingPointerCallsOnDragStopped() {
-        val size = 200f
-        val middle = Offset(size / 2f, size / 2f)
-
-        var enabled by mutableStateOf(false)
-        var started = false
-        var dragged = false
-        var stopped = false
-
-        var touchSlop = 0f
-        rule.setContent {
-            touchSlop = LocalViewConfiguration.current.touchSlop
-            Box(
-                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
-                    .nestedScrollDispatcher()
-                    .thenIf(enabled) {
-                        Modifier.multiPointerDraggable(
-                            orientation = Orientation.Vertical,
-                            onDragStarted = { _, _ ->
-                                started = true
-                                SimpleDragController(
-                                    onDrag = { dragged = true },
-                                    onStop = { stopped = true },
-                                )
-                            },
-                            dispatcher = defaultDispatcher,
-                        )
-                    }
-            )
-        }
-
-        fun startDraggingDown() {
-            rule.onRoot().performTouchInput {
-                down(middle)
-                moveBy(Offset(0f, touchSlop))
-            }
-        }
-
-        fun releaseFinger() {
-            rule.onRoot().performTouchInput { up() }
-        }
-
-        // Swiping down does nothing because enabled is false.
-        startDraggingDown()
-        assertThat(started).isFalse()
-        assertThat(dragged).isFalse()
-        assertThat(stopped).isFalse()
-        releaseFinger()
-
-        // Enable the draggable and swipe down. This should both call onDragStarted() and
-        // onDragDelta().
-        enabled = true
-        rule.waitForIdle()
-        startDraggingDown()
-        assertThat(started).isTrue()
-        assertThat(dragged).isTrue()
-        assertThat(stopped).isFalse()
-
-        // Disable the pointer input. This should call onDragStopped() even if didn't release the
-        // finger yet.
-        enabled = false
-        rule.waitForIdle()
-        assertThat(started).isTrue()
-        assertThat(dragged).isTrue()
-        assertThat(stopped).isTrue()
-    }
-
-    @Test
-    fun shouldNotStartDragEventsWith0PointersDown() {
-        val size = 200f
-        val middle = Offset(size / 2f, size / 2f)
-
-        var started = false
-        var dragged = false
-        var stopped = false
-        var consumedByDescendant = false
-
-        var touchSlop = 0f
-        rule.setContent {
-            touchSlop = LocalViewConfiguration.current.touchSlop
-            Box(
-                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
-                    .nestedScrollDispatcher()
-                    .multiPointerDraggable(
-                        orientation = Orientation.Vertical,
-                        onDragStarted = { _, _ ->
-                            started = true
-                            SimpleDragController(
-                                onDrag = { dragged = true },
-                                onStop = { stopped = true },
-                            )
-                        },
-                        dispatcher = defaultDispatcher,
-                    )
-                    .pointerInput(Unit) {
-                        coroutineScope {
-                            awaitPointerEventScope {
-                                while (isActive) {
-                                    val change = awaitPointerEvent().changes.first()
-                                    if (consumedByDescendant) {
-                                        change.consume()
-                                    }
-                                }
-                            }
-                        }
-                    }
-            )
-        }
-
-        // The first part of the gesture is consumed by our descendant
-        consumedByDescendant = true
-        rule.onRoot().performTouchInput {
-            down(middle)
-            moveBy(Offset(0f, touchSlop))
-        }
-
-        // The events were consumed by our descendant, we should not start a drag gesture.
-        assertThat(started).isFalse()
-        assertThat(dragged).isFalse()
-        assertThat(stopped).isFalse()
-
-        // The next events could be consumed by us
-        consumedByDescendant = false
-        rule.onRoot().performTouchInput {
-            // The pointer is moved to a new position without reporting it
-            updatePointerBy(0, Offset(0f, touchSlop))
-
-            // The pointer report an "up" (0 pointers down) with a new position
-            up()
-        }
-
-        // The "up" event should not be used to start a drag gesture
-        assertThat(started).isFalse()
-        assertThat(dragged).isFalse()
-        assertThat(stopped).isFalse()
-    }
-
-    @Test
-    fun handleDisappearingScrollableDuringAGesture() {
-        val size = 200f
-        val middle = Offset(size / 2f, size / 2f)
-
-        var started = false
-        var dragged = false
-        var stopped = false
-        var consumedByScroll = false
-        var hasScrollable by mutableStateOf(true)
-
-        var touchSlop = 0f
-        rule.setContent {
-            touchSlop = LocalViewConfiguration.current.touchSlop
-            Box(
-                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
-                    .nestedScrollDispatcher()
-                    .multiPointerDraggable(
-                        orientation = Orientation.Vertical,
-                        onDragStarted = { _, _ ->
-                            started = true
-                            SimpleDragController(
-                                onDrag = { dragged = true },
-                                onStop = { stopped = true },
-                            )
-                        },
-                        dispatcher = defaultDispatcher,
-                    )
-            ) {
-                if (hasScrollable) {
-                    Box(
-                        Modifier.scrollable(
-                                // Consume all the vertical scroll gestures
-                                rememberScrollableState(
-                                    consumeScrollDelta = {
-                                        consumedByScroll = true
-                                        it
-                                    }
-                                ),
-                                Orientation.Vertical,
-                            )
-                            .fillMaxSize()
-                    )
-                }
-            }
-        }
-
-        fun startDraggingDown() {
-            rule.onRoot().performTouchInput {
-                down(middle)
-                moveBy(Offset(0f, touchSlop))
-            }
-        }
-
-        fun continueDraggingDown() {
-            rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
-        }
-
-        fun releaseFinger() {
-            rule.onRoot().performTouchInput { up() }
-        }
-
-        // Swipe down. This should intercepted by the scrollable modifier.
-        startDraggingDown()
-        assertThat(consumedByScroll).isTrue()
-        assertThat(started).isFalse()
-        assertThat(dragged).isFalse()
-        assertThat(stopped).isFalse()
-
-        // Reset the scroll state for the test
-        consumedByScroll = false
-
-        // Suddenly remove the scrollable container
-        hasScrollable = false
-        rule.waitForIdle()
-
-        // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop
-        // before consuming it.
-        continueDraggingDown()
-        assertThat(consumedByScroll).isFalse()
-        assertThat(started).isFalse()
-        assertThat(dragged).isFalse()
-        assertThat(stopped).isFalse()
-
-        // Swipe down. This should both call onDragStarted() and onDragDelta().
-        continueDraggingDown()
-        assertThat(consumedByScroll).isFalse()
-        assertThat(started).isTrue()
-        assertThat(dragged).isTrue()
-        assertThat(stopped).isFalse()
-
-        rule.waitForIdle()
-        releaseFinger()
-        assertThat(stopped).isTrue()
-    }
-
-    @Test
-    fun multiPointerWaitAConsumableEventInMainPass() {
-        val size = 200f
-        val middle = Offset(size / 2f, size / 2f)
-
-        var started = false
-        var dragged = false
-        var stopped = false
-
-        var childConsumesOnPass: PointerEventPass? = null
-
-        suspend fun AwaitPointerEventScope.childPointerInputScope() {
-            awaitPointerEvent(PointerEventPass.Initial).also { initial ->
-                // Check unconsumed: it should be always true
-                assertThat(initial.changes.any { it.isConsumed }).isFalse()
-
-                if (childConsumesOnPass == PointerEventPass.Initial) {
-                    initial.changes.first().consume()
-                }
-            }
-
-            awaitPointerEvent(PointerEventPass.Main).also { main ->
-                // Check unconsumed
-                if (childConsumesOnPass != PointerEventPass.Initial) {
-                    assertThat(main.changes.any { it.isConsumed }).isFalse()
-                }
-
-                if (childConsumesOnPass == PointerEventPass.Main) {
-                    main.changes.first().consume()
-                }
-            }
-        }
-
-        var touchSlop = 0f
-        rule.setContent {
-            touchSlop = LocalViewConfiguration.current.touchSlop
-            Box(
-                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
-                    .nestedScrollDispatcher()
-                    .multiPointerDraggable(
-                        orientation = Orientation.Vertical,
-                        onDragStarted = { _, _ ->
-                            started = true
-                            SimpleDragController(
-                                onDrag = { dragged = true },
-                                onStop = { stopped = true },
-                            )
-                        },
-                        dispatcher = defaultDispatcher,
-                    )
-            ) {
-                Box(
-                    Modifier.pointerInput(Unit) {
-                            coroutineScope {
-                                awaitPointerEventScope {
-                                    while (isActive) {
-                                        childPointerInputScope()
-                                    }
-                                }
-                            }
-                        }
-                        .fillMaxSize()
-                )
-            }
-        }
-
-        fun startDraggingDown() {
-            rule.onRoot().performTouchInput {
-                down(middle)
-                moveBy(Offset(0f, touchSlop))
-            }
-        }
-
-        fun continueDraggingDown() {
-            rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
-        }
-
-        childConsumesOnPass = PointerEventPass.Initial
-
-        startDraggingDown()
-        assertThat(started).isFalse()
-        assertThat(dragged).isFalse()
-        assertThat(stopped).isFalse()
-
-        continueDraggingDown()
-        assertThat(started).isFalse()
-        assertThat(dragged).isFalse()
-        assertThat(stopped).isFalse()
-
-        childConsumesOnPass = PointerEventPass.Main
-
-        continueDraggingDown()
-        assertThat(started).isFalse()
-        assertThat(dragged).isFalse()
-        assertThat(stopped).isFalse()
-
-        continueDraggingDown()
-        assertThat(started).isFalse()
-        assertThat(dragged).isFalse()
-        assertThat(stopped).isFalse()
-
-        childConsumesOnPass = null
-
-        // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop
-        // before consuming it.
-        continueDraggingDown()
-        assertThat(started).isFalse()
-        assertThat(dragged).isFalse()
-        assertThat(stopped).isFalse()
-
-        // Swipe down. This should both call onDragStarted() and onDragDelta().
-        continueDraggingDown()
-        assertThat(started).isTrue()
-        assertThat(dragged).isTrue()
-        assertThat(stopped).isFalse()
-
-        childConsumesOnPass = PointerEventPass.Main
-
-        continueDraggingDown()
-        assertThat(stopped).isTrue()
-
-        // Complete the gesture
-        rule.onRoot().performTouchInput { up() }
-    }
-
-    @Test
-    fun multiPointerDuringAnotherGestureWaitAConsumableEventAfterMainPass() {
-        val size = 200f
-        val middle = Offset(size / 2f, size / 2f)
-
-        var verticalStarted = false
-        var verticalDragged = false
-        var verticalStopped = false
-        var horizontalStarted = false
-        var horizontalDragged = false
-        var horizontalStopped = false
-
-        var touchSlop = 0f
-        rule.setContent {
-            touchSlop = LocalViewConfiguration.current.touchSlop
-            Box(
-                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
-                    .nestedScrollDispatcher()
-                    .multiPointerDraggable(
-                        orientation = Orientation.Vertical,
-                        onDragStarted = { _, _ ->
-                            verticalStarted = true
-                            SimpleDragController(
-                                onDrag = { verticalDragged = true },
-                                onStop = { verticalStopped = true },
-                            )
-                        },
-                        dispatcher = defaultDispatcher,
-                    )
-                    .multiPointerDraggable(
-                        orientation = Orientation.Horizontal,
-                        onDragStarted = { _, _ ->
-                            horizontalStarted = true
-                            SimpleDragController(
-                                onDrag = { horizontalDragged = true },
-                                onStop = { horizontalStopped = true },
-                            )
-                        },
-                        dispatcher = defaultDispatcher,
-                    )
-            )
-        }
-
-        fun startDraggingDown() {
-            rule.onRoot().performTouchInput {
-                down(middle)
-                moveBy(Offset(0f, touchSlop))
-            }
-        }
-
-        fun startDraggingRight() {
-            rule.onRoot().performTouchInput {
-                down(middle)
-                moveBy(Offset(touchSlop, 0f))
-            }
-        }
-
-        fun stopDragging() {
-            rule.onRoot().performTouchInput { up() }
-        }
-
-        fun continueDown() {
-            rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
-        }
-
-        fun continueRight() {
-            rule.onRoot().performTouchInput { moveBy(Offset(touchSlop, 0f)) }
-        }
-
-        startDraggingDown()
-        assertThat(verticalStarted).isTrue()
-        assertThat(verticalDragged).isTrue()
-        assertThat(verticalStopped).isFalse()
-
-        // Ignore right swipe, do not interrupt the dragging gesture.
-        continueRight()
-        assertThat(horizontalStarted).isFalse()
-        assertThat(horizontalDragged).isFalse()
-        assertThat(horizontalStopped).isFalse()
-        assertThat(verticalStopped).isFalse()
-
-        stopDragging()
-        assertThat(verticalStopped).isTrue()
-
-        verticalStarted = false
-        verticalDragged = false
-        verticalStopped = false
-
-        startDraggingRight()
-        assertThat(horizontalStarted).isTrue()
-        assertThat(horizontalDragged).isTrue()
-        assertThat(horizontalStopped).isFalse()
-
-        // Ignore down swipe, do not interrupt the dragging gesture.
-        continueDown()
-        assertThat(verticalStarted).isFalse()
-        assertThat(verticalDragged).isFalse()
-        assertThat(verticalStopped).isFalse()
-        assertThat(horizontalStopped).isFalse()
-
-        stopDragging()
-        assertThat(horizontalStopped).isTrue()
-    }
-
-    @Test
-    fun multiPointerSwipeDetectorInteraction() {
-        val size = 200f
-        val middle = Offset(size / 2f, size / 2f)
-
-        var started = false
-
-        var capturedChange: PointerInputChange? = null
-        var swipeConsume = false
-
-        var touchSlop = 0f
-        rule.setContent {
-            touchSlop = LocalViewConfiguration.current.touchSlop
-            Box(
-                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
-                    .nestedScrollDispatcher()
-                    .multiPointerDraggable(
-                        orientation = Orientation.Vertical,
-                        swipeDetector =
-                            object : SwipeDetector {
-                                override fun detectSwipe(change: PointerInputChange): Boolean {
-                                    capturedChange = change
-                                    return swipeConsume
-                                }
-                            },
-                        onDragStarted = { _, _ ->
-                            started = true
-                            SimpleDragController(
-                                onDrag = { /* do nothing */ },
-                                onStop = { /* do nothing */ },
-                            )
-                        },
-                        dispatcher = defaultDispatcher,
-                    )
-            ) {}
-        }
-
-        fun startDraggingDown() {
-            rule.onRoot().performTouchInput {
-                down(middle)
-                moveBy(Offset(0f, touchSlop))
-            }
-        }
-
-        fun dragDown() {
-            rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
-        }
-
-        startDraggingDown()
-        assertThat(capturedChange).isNotNull()
-        capturedChange = null
-        assertThat(started).isFalse()
-
-        swipeConsume = true
-        // Drag in same direction
-        dragDown()
-        assertThat(capturedChange).isNotNull()
-        capturedChange = null
-
-        dragDown()
-        assertThat(capturedChange).isNull()
-
-        assertThat(started).isTrue()
-    }
-
-    @Test
-    fun multiPointerSwipeDetectorInteractionZeroOffsetFromStartPosition() {
-        val size = 200f
-        val middle = Offset(size / 2f, size / 2f)
-
-        var started = false
-
-        var capturedChange: PointerInputChange? = null
-        var swipeConsume = false
-
-        var touchSlop = 0f
-        rule.setContent {
-            touchSlop = LocalViewConfiguration.current.touchSlop
-            Box(
-                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
-                    .nestedScrollDispatcher()
-                    .multiPointerDraggable(
-                        orientation = Orientation.Vertical,
-                        swipeDetector =
-                            object : SwipeDetector {
-                                override fun detectSwipe(change: PointerInputChange): Boolean {
-                                    capturedChange = change
-                                    return swipeConsume
-                                }
-                            },
-                        onDragStarted = { _, _ ->
-                            started = true
-                            SimpleDragController(
-                                onDrag = { /* do nothing */ },
-                                onStop = { /* do nothing */ },
-                            )
-                        },
-                        dispatcher = defaultDispatcher,
-                    )
-            ) {}
-        }
-
-        fun startDraggingDown() {
-            rule.onRoot().performTouchInput {
-                down(middle)
-                moveBy(Offset(0f, touchSlop))
-            }
-        }
-
-        fun dragUp() {
-            rule.onRoot().performTouchInput { moveBy(Offset(0f, -touchSlop)) }
-        }
-
-        startDraggingDown()
-        assertThat(capturedChange).isNotNull()
-        capturedChange = null
-        assertThat(started).isFalse()
-
-        swipeConsume = true
-        // Drag in the opposite direction
-        dragUp()
-        assertThat(capturedChange).isNotNull()
-        capturedChange = null
-
-        dragUp()
-        assertThat(capturedChange).isNull()
-
-        assertThat(started).isTrue()
-    }
-
-    @Test
-    fun multiPointerNestedScrollDispatcher() {
-        val size = 200f
-        val middle = Offset(size / 2f, size / 2f)
-        var touchSlop = 0f
-
-        var consumedOnPreScroll = 0f
-
-        var availableOnPreScroll = Float.MIN_VALUE
-        var availableOnPostScroll = Float.MIN_VALUE
-        var availableOnPreFling = Float.MIN_VALUE
-        var availableOnPostFling = Float.MIN_VALUE
-
-        var consumedOnDrag = 0f
-        var consumedOnDragStop = 0f
-
-        val connection =
-            object : NestedScrollConnection {
-                override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
-                    availableOnPreScroll = available.y
-                    return Offset(0f, consumedOnPreScroll)
-                }
-
-                override fun onPostScroll(
-                    consumed: Offset,
-                    available: Offset,
-                    source: NestedScrollSource,
-                ): Offset {
-                    availableOnPostScroll = available.y
-                    return Offset.Zero
-                }
-
-                override suspend fun onPreFling(available: Velocity): Velocity {
-                    availableOnPreFling = available.y
-                    return Velocity.Zero
-                }
-
-                override suspend fun onPostFling(
-                    consumed: Velocity,
-                    available: Velocity,
-                ): Velocity {
-                    availableOnPostFling = available.y
-                    return Velocity.Zero
-                }
-            }
-
-        rule.setContent {
-            touchSlop = LocalViewConfiguration.current.touchSlop
-            Box(
-                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
-                    .nestedScroll(connection)
-                    .nestedScrollDispatcher()
-                    .multiPointerDraggable(
-                        orientation = Orientation.Vertical,
-                        onDragStarted = { _, _ ->
-                            SimpleDragController(
-                                onDrag = { consumedOnDrag = it },
-                                onStop = { consumedOnDragStop = it },
-                            )
-                        },
-                        dispatcher = defaultDispatcher,
-                    )
-            )
-        }
-
-        fun startDrag() {
-            rule.onRoot().performTouchInput {
-                down(middle)
-                moveBy(Offset(0f, touchSlop))
-            }
-        }
-
-        fun continueDrag() {
-            rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
-        }
-
-        fun stopDrag() {
-            rule.onRoot().performTouchInput { up() }
-        }
-
-        startDrag()
-
-        continueDrag()
-        assertThat(availableOnPreScroll).isEqualTo(touchSlop)
-        assertThat(consumedOnDrag).isEqualTo(touchSlop)
-        assertThat(availableOnPostScroll).isEqualTo(0f)
-
-        // Parent node consumes half of the gesture
-        consumedOnPreScroll = touchSlop / 2f
-        continueDrag()
-        assertThat(availableOnPreScroll).isEqualTo(touchSlop)
-        assertThat(consumedOnDrag).isEqualTo(touchSlop / 2f)
-        assertThat(availableOnPostScroll).isEqualTo(0f)
-
-        // Parent node consumes the gesture
-        consumedOnPreScroll = touchSlop
-        continueDrag()
-        assertThat(availableOnPreScroll).isEqualTo(touchSlop)
-        assertThat(consumedOnDrag).isEqualTo(0f)
-        assertThat(availableOnPostScroll).isEqualTo(0f)
-
-        // Parent node can intercept the velocity on stop
-        stopDrag()
-        assertThat(availableOnPreFling).isEqualTo(consumedOnDragStop)
-        assertThat(availableOnPostFling).isEqualTo(0f)
-    }
-
-    @Test
-    fun multiPointerOnStopVelocity() {
-        val size = 200f
-        val middle = Offset(size / 2f, size / 2f)
-
-        var stopped = false
-        var lastVelocity = -1f
-        var touchSlop = 0f
-        var density: Density by Delegates.notNull()
-        rule.setContent {
-            touchSlop = LocalViewConfiguration.current.touchSlop
-            density = LocalDensity.current
-            Box(
-                Modifier.size(with(density) { Size(size, size).toDpSize() })
-                    .nestedScrollDispatcher()
-                    .multiPointerDraggable(
-                        orientation = Orientation.Vertical,
-                        onDragStarted = { _, _ ->
-                            SimpleDragController(
-                                onDrag = { /* do nothing */ },
-                                onStop = {
-                                    stopped = true
-                                    lastVelocity = it
-                                },
-                            )
-                        },
-                        dispatcher = defaultDispatcher,
-                    )
-            )
-        }
-
-        var eventMillis: Long by Delegates.notNull()
-        rule.onRoot().performTouchInput { eventMillis = eventPeriodMillis }
-
-        fun swipeGesture(block: TouchInjectionScope.() -> Unit) {
-            stopped = false
-            rule.onRoot().performTouchInput {
-                down(middle)
-                block()
-                up()
-            }
-            assertThat(stopped).isEqualTo(true)
-        }
-
-        val shortDistance = touchSlop / 2f
-        swipeGesture {
-            moveBy(delta = Offset(0f, shortDistance), delayMillis = eventMillis)
-            moveBy(delta = Offset(0f, shortDistance), delayMillis = eventMillis)
-        }
-        assertThat(lastVelocity).isGreaterThan(0f)
-        assertThat(lastVelocity).isWithin(1f).of((shortDistance / eventMillis) * 1000f)
-
-        val longDistance = touchSlop * 4f
-        swipeGesture {
-            moveBy(delta = Offset(0f, longDistance), delayMillis = eventMillis)
-            moveBy(delta = Offset(0f, longDistance), delayMillis = eventMillis)
-        }
-        assertThat(lastVelocity).isGreaterThan(0f)
-        assertThat(lastVelocity).isWithin(1f).of((longDistance / eventMillis) * 1000f)
-
-        rule.onRoot().performTouchInput {
-            down(pointerId = 0, position = middle)
-            down(pointerId = 1, position = middle)
-            moveBy(pointerId = 0, delta = Offset(0f, longDistance), delayMillis = eventMillis)
-            moveBy(pointerId = 0, delta = Offset(0f, longDistance), delayMillis = eventMillis)
-            // The velocity should be:
-            // (longDistance / eventMillis) pixels/ms
-
-            // 1 pointer left, the second one
-            up(pointerId = 0)
-
-            // After a few events the velocity should be:
-            // (shortDistance / eventMillis) pixels/ms
-            repeat(10) {
-                moveBy(pointerId = 1, delta = Offset(0f, shortDistance), delayMillis = eventMillis)
-            }
-            up(pointerId = 1)
-        }
-        assertThat(lastVelocity).isGreaterThan(0f)
-        assertThat(lastVelocity).isWithin(1f).of((shortDistance / eventMillis) * 1000f)
-    }
-}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt
index 0355a30..e036084 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SwipeToSceneTest.kt
@@ -128,7 +128,7 @@
                         mapOf(
                             Swipe.Down to SceneA,
                             Swipe.Down(pointerCount = 2) to SceneB,
-                            Swipe.Down(pointersType = PointerType.Mouse) to SceneD,
+                            Swipe.Down(pointerType = PointerType.Mouse) to SceneD,
                             Swipe.Down(fromSource = Edge.Top) to SceneB,
                             Swipe.Right(fromSource = Edge.Left) to SceneB,
                         )