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,
)