1 /*
<lambda>null2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.compose.animation.scene
18 
19 import androidx.compose.foundation.gestures.Orientation
20 import androidx.compose.ui.geometry.Offset
21 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
22 import androidx.compose.ui.unit.dp
23 import androidx.compose.ui.unit.round
24 import androidx.compose.ui.util.fastCoerceIn
25 import com.android.compose.animation.scene.content.Content
26 import com.android.compose.animation.scene.content.state.TransitionState.HasOverscrollProperties.Companion.DistanceUnspecified
27 import com.android.compose.nestedscroll.OnStopScope
28 import com.android.compose.nestedscroll.PriorityNestedScrollConnection
29 import com.android.compose.nestedscroll.ScrollController
30 import com.android.compose.ui.util.SpaceVectorConverter
31 import kotlin.math.absoluteValue
32 import kotlinx.coroutines.NonCancellable
33 import kotlinx.coroutines.launch
34 import kotlinx.coroutines.withContext
35 
36 internal interface DraggableHandler {
37     /**
38      * Start a drag with the given [pointersDown] and [overSlop].
39      *
40      * The returned [DragController] should be used to continue or stop the drag.
41      */
42     fun onDragStarted(pointersDown: PointersInfo.PointersDown?, overSlop: Float): DragController
43 }
44 
45 /**
46  * The [DragController] provides control over the transition between two scenes through the [onDrag]
47  * and [onStop] methods.
48  */
49 internal interface DragController {
50     /**
51      * Drag the current scene by [delta] pixels.
52      *
53      * @param delta The distance to drag the scene in pixels.
54      * @return the consumed [delta]
55      */
onDragnull56     fun onDrag(delta: Float): Float
57 
58     /**
59      * Stop the current drag with the given [velocity].
60      *
61      * @param velocity The velocity of the drag when it stopped.
62      * @param canChangeContent Whether the content can be changed as a result of this drag.
63      * @return the consumed [velocity] when the animation complete
64      */
65     suspend fun onStop(velocity: Float, canChangeContent: Boolean): Float
66 
67     /**
68      * Cancels the current drag.
69      *
70      * @param canChangeContent Whether the content can be changed as a result of this drag.
71      */
72     fun onCancel(canChangeContent: Boolean)
73 }
74 
75 internal class DraggableHandlerImpl(
76     internal val layoutImpl: SceneTransitionLayoutImpl,
77     internal val orientation: Orientation,
78 ) : DraggableHandler {
79     internal val nestedScrollKey = Any()
80 
81     /** The [DraggableHandler] can only have one active [DragController] at a time. */
82     private var dragController: DragControllerImpl? = null
83 
84     internal val isDrivingTransition: Boolean
85         get() = dragController?.isDrivingTransition == true
86 
87     /**
88      * The velocity threshold at which the intent of the user is to swipe up or down. It is the same
89      * as SwipeableV2Defaults.VelocityThreshold.
90      */
91     internal val velocityThreshold: Float
92         get() = with(layoutImpl.density) { 125.dp.toPx() }
93 
94     /**
95      * The positional threshold at which the intent of the user is to swipe to the next scene. It is
96      * the same as SwipeableV2Defaults.PositionalThreshold.
97      */
98     internal val positionalThreshold
99         get() = with(layoutImpl.density) { 56.dp.toPx() }
100 
101     override fun onDragStarted(
102         pointersDown: PointersInfo.PointersDown?,
103         overSlop: Float,
104     ): DragController {
105         check(overSlop != 0f)
106         val swipes = computeSwipes(pointersDown)
107         val fromContent = layoutImpl.contentForUserActions()
108 
109         swipes.updateSwipesResults(fromContent)
110         val result =
111             swipes.findUserActionResult(overSlop)
112                 // As we were unable to locate a valid target scene, the initial SwipeAnimation
113                 // cannot be defined. Consequently, a simple NoOp Controller will be returned.
114                 ?: return NoOpDragController
115 
116         val swipeAnimation = createSwipeAnimation(swipes, result)
117         return updateDragController(swipes, swipeAnimation)
118     }
119 
120     private fun updateDragController(
121         swipes: Swipes,
122         swipeAnimation: SwipeAnimation<*>,
123     ): DragControllerImpl {
124         val newDragController = DragControllerImpl(this, swipes, swipeAnimation)
125         newDragController.updateTransition(swipeAnimation, force = true)
126         dragController = newDragController
127         return newDragController
128     }
129 
130     private fun createSwipeAnimation(swipes: Swipes, result: UserActionResult): SwipeAnimation<*> {
131         val upOrLeftResult = swipes.upOrLeftResult
132         val downOrRightResult = swipes.downOrRightResult
133         val isUpOrLeft =
134             when (result) {
135                 upOrLeftResult -> true
136                 downOrRightResult -> false
137                 else -> error("Unknown result $result ($upOrLeftResult $downOrRightResult)")
138             }
139 
140         return createSwipeAnimation(layoutImpl, result, isUpOrLeft, orientation)
141     }
142 
143     private fun resolveSwipeSource(startedPosition: Offset): SwipeSource.Resolved? {
144         return layoutImpl.swipeSourceDetector.source(
145             layoutSize = layoutImpl.lastSize,
146             position = startedPosition.round(),
147             density = layoutImpl.density,
148             orientation = orientation,
149         )
150     }
151 
152     private fun computeSwipes(pointersDown: PointersInfo.PointersDown?): Swipes {
153         val fromSource = pointersDown?.let { resolveSwipeSource(it.startedPosition) }
154         return Swipes(
155             upOrLeft = resolveSwipe(orientation, isUpOrLeft = true, pointersDown, fromSource),
156             downOrRight = resolveSwipe(orientation, isUpOrLeft = false, pointersDown, fromSource),
157         )
158     }
159 }
160 
resolveSwipenull161 private fun resolveSwipe(
162     orientation: Orientation,
163     isUpOrLeft: Boolean,
164     pointersDown: PointersInfo.PointersDown?,
165     fromSource: SwipeSource.Resolved?,
166 ): Swipe.Resolved {
167     return Swipe.Resolved(
168         direction =
169             when (orientation) {
170                 Orientation.Horizontal ->
171                     if (isUpOrLeft) {
172                         SwipeDirection.Resolved.Left
173                     } else {
174                         SwipeDirection.Resolved.Right
175                     }
176 
177                 Orientation.Vertical ->
178                     if (isUpOrLeft) {
179                         SwipeDirection.Resolved.Up
180                     } else {
181                         SwipeDirection.Resolved.Down
182                     }
183             },
184         // If the number of pointers is not specified, 1 is assumed.
185         pointerCount = pointersDown?.count ?: 1,
186         // Resolves the pointer type only if all pointers are of the same type.
187         pointersType = pointersDown?.countByType?.keys?.singleOrNull(),
188         fromSource = fromSource,
189     )
190 }
191 
192 /** @param swipes The [Swipes] associated to the current gesture. */
193 private class DragControllerImpl(
194     private val draggableHandler: DraggableHandlerImpl,
195     val swipes: Swipes,
196     var swipeAnimation: SwipeAnimation<*>,
<lambda>null197 ) : DragController, SpaceVectorConverter by SpaceVectorConverter(draggableHandler.orientation) {
198     val layoutState = draggableHandler.layoutImpl.state
199 
200     val overscrollableContent: OverscrollableContent =
201         when (draggableHandler.orientation) {
202             Orientation.Vertical -> draggableHandler.layoutImpl.verticalOverscrollableContent
203             Orientation.Horizontal -> draggableHandler.layoutImpl.horizontalOverscrollableContent
204         }
205 
206     /**
207      * Whether this handle is active. If this returns false, calling [onDrag] and [onStop] will do
208      * nothing.
209      */
210     val isDrivingTransition: Boolean
211         get() = layoutState.transitionState == swipeAnimation.contentTransition
212 
213     init {
214         check(!isDrivingTransition) { "Multiple controllers with the same SwipeTransition" }
215     }
216 
217     fun updateTransition(newTransition: SwipeAnimation<*>, force: Boolean = false) {
218         if (force || isDrivingTransition) {
219             layoutState.startTransitionImmediately(
220                 animationScope = draggableHandler.layoutImpl.animationScope,
221                 newTransition.contentTransition,
222                 true,
223             )
224         }
225 
226         swipeAnimation = newTransition
227     }
228 
229     /**
230      * We receive a [delta] that can be consumed to change the offset of the current
231      * [SwipeAnimation].
232      *
233      * @return the consumed delta
234      */
235     override fun onDrag(delta: Float): Float {
236         val initialAnimation = swipeAnimation
237         if (delta == 0f || !isDrivingTransition || initialAnimation.isAnimatingOffset()) {
238             return 0f
239         }
240         // swipeAnimation can change during the gesture, we want to always use the initial reference
241         // during the whole drag gesture.
242         return dragWithOverscroll(delta, animation = initialAnimation)
243     }
244 
245     private fun <T : ContentKey> dragWithOverscroll(
246         delta: Float,
247         animation: SwipeAnimation<T>,
248     ): Float {
249         require(delta != 0f) { "delta should not be 0" }
250         var overscrollEffect = overscrollableContent.currentOverscrollEffect
251 
252         // If we're already overscrolling, continue with the current effect for a smooth finish.
253         if (overscrollEffect == null || !overscrollEffect.isInProgress) {
254             // Otherwise, determine the target content (toContent or fromContent) for the new
255             // overscroll effect based on the gesture's direction.
256             val content = animation.contentByDirection(delta)
257             overscrollEffect = overscrollableContent.applyOverscrollEffectOn(content)
258         }
259 
260         // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
261         if (!overscrollEffect.node.node.isAttached) {
262             return drag(delta, animation)
263         }
264 
265         return overscrollEffect
266             .applyToScroll(
267                 delta = delta.toOffset(),
268                 source = NestedScrollSource.UserInput,
269                 performScroll = {
270                     val preScrollAvailable = it.toFloat()
271                     drag(preScrollAvailable, animation).toOffset()
272                 },
273             )
274             .toFloat()
275     }
276 
277     private fun <T : ContentKey> drag(delta: Float, animation: SwipeAnimation<T>): Float {
278         if (delta == 0f) return 0f
279 
280         val distance = animation.distance()
281         val previousOffset = animation.dragOffset
282         val desiredOffset = previousOffset + delta
283         val desiredProgress = animation.computeProgress(desiredOffset)
284 
285         // Note: the distance could be negative if fromContent is above or to the left of toContent.
286         val newOffset =
287             when {
288                 distance == DistanceUnspecified ||
289                     animation.contentTransition.isWithinProgressRange(desiredProgress) ->
290                     desiredOffset
291                 distance > 0f -> desiredOffset.fastCoerceIn(0f, distance)
292                 else -> desiredOffset.fastCoerceIn(distance, 0f)
293             }
294 
295         animation.dragOffset = newOffset
296         return newOffset - previousOffset
297     }
298 
299     override suspend fun onStop(velocity: Float, canChangeContent: Boolean): Float {
300         // To ensure that any ongoing animation completes gracefully and avoids an undefined state,
301         // we execute the actual `onStop` logic in a non-cancellable context. This prevents the
302         // coroutine from being cancelled prematurely, which could interrupt the animation.
303         // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
304         return withContext(NonCancellable) { onStop(velocity, canChangeContent, swipeAnimation) }
305     }
306 
307     private suspend fun <T : ContentKey> onStop(
308         velocity: Float,
309         canChangeContent: Boolean,
310 
311         // Important: Make sure that this has the same name as [this.swipeAnimation] so that all the
312         // code here references the current animation when [onDragStopped] is called, otherwise the
313         // callbacks (like onAnimationCompleted()) might incorrectly finish a new transition that
314         // replaced this one.
315         swipeAnimation: SwipeAnimation<T>,
316     ): Float {
317         // The state was changed since the drag started; don't do anything.
318         if (!isDrivingTransition || swipeAnimation.isAnimatingOffset()) {
319             return 0f
320         }
321 
322         val fromContent = swipeAnimation.fromContent
323         val targetContent =
324             if (canChangeContent) {
325                 // If we are halfway between two contents, we check what the target will be based on
326                 // the velocity and offset of the transition, then we launch the animation.
327 
328                 val toContent = swipeAnimation.toContent
329 
330                 // Compute the destination content (and therefore offset) to settle in.
331                 val offset = swipeAnimation.dragOffset
332                 val distance = swipeAnimation.distance()
333                 if (
334                     distance != DistanceUnspecified &&
335                         shouldCommitSwipe(
336                             offset = offset,
337                             distance = distance,
338                             velocity = velocity,
339                             wasCommitted = swipeAnimation.currentContent == toContent,
340                             requiresFullDistanceSwipe = swipeAnimation.requiresFullDistanceSwipe,
341                         )
342                 ) {
343                     toContent
344                 } else {
345                     fromContent
346                 }
347             } else {
348                 // We are doing an overscroll preview animation between scenes.
349                 check(fromContent == swipeAnimation.currentContent) {
350                     "canChangeContent is false but currentContent != fromContent"
351                 }
352                 fromContent
353             }
354 
355         val overscrollEffect = overscrollableContent.applyOverscrollEffectOn(targetContent)
356 
357         // TODO(b/378470603) Remove this check once NestedDraggable is used to handle drags.
358         if (!overscrollEffect.node.node.isAttached) {
359             return swipeAnimation.animateOffset(velocity, targetContent)
360         }
361 
362         overscrollEffect.applyToFling(
363             velocity = velocity.toVelocity(),
364             performFling = {
365                 val velocityLeft = it.toFloat()
366                 swipeAnimation.animateOffset(velocityLeft, targetContent).toVelocity()
367             },
368         )
369 
370         return velocity
371     }
372 
373     /**
374      * Whether the swipe to the target scene should be committed or not. This is inspired by
375      * SwipeableV2.computeTarget().
376      */
377     private fun shouldCommitSwipe(
378         offset: Float,
379         distance: Float,
380         velocity: Float,
381         wasCommitted: Boolean,
382         requiresFullDistanceSwipe: Boolean,
383     ): Boolean {
384         if (requiresFullDistanceSwipe && !wasCommitted) {
385             return offset / distance >= 1f
386         }
387 
388         fun isCloserToTarget(): Boolean {
389             return (offset - distance).absoluteValue < offset.absoluteValue
390         }
391 
392         val velocityThreshold = draggableHandler.velocityThreshold
393         val positionalThreshold = draggableHandler.positionalThreshold
394 
395         // Swiping up or left.
396         if (distance < 0f) {
397             return if (offset > 0f || velocity >= velocityThreshold) {
398                 false
399             } else {
400                 velocity <= -velocityThreshold ||
401                     (offset <= -positionalThreshold && !wasCommitted) ||
402                     isCloserToTarget()
403             }
404         }
405 
406         // Swiping down or right.
407         return if (offset < 0f || velocity <= -velocityThreshold) {
408             false
409         } else {
410             velocity >= velocityThreshold ||
411                 (offset >= positionalThreshold && !wasCommitted) ||
412                 isCloserToTarget()
413         }
414     }
415 
416     override fun onCancel(canChangeContent: Boolean) {
417         swipeAnimation.contentTransition.coroutineScope.launch {
418             onStop(velocity = 0f, canChangeContent = canChangeContent)
419         }
420     }
421 }
422 
423 /** The [Swipe] associated to a given fromScene, startedPosition and pointersDown. */
424 internal class Swipes(val upOrLeft: Swipe.Resolved, val downOrRight: Swipe.Resolved) {
425     /** The [UserActionResult] associated to up and down swipes. */
426     var upOrLeftResult: UserActionResult? = null
427     var downOrRightResult: UserActionResult? = null
428 
computeSwipesResultsnull429     private fun computeSwipesResults(
430         fromContent: Content
431     ): Pair<UserActionResult?, UserActionResult?> {
432         val upOrLeftResult = fromContent.findActionResultBestMatch(swipe = upOrLeft)
433         val downOrRightResult = fromContent.findActionResultBestMatch(swipe = downOrRight)
434         return upOrLeftResult to downOrRightResult
435     }
436 
437     /**
438      * Update the swipes results.
439      *
440      * Usually we don't want to update them while doing a drag, because this could change the target
441      * content (jump cutting) to a different content, when some system state changed the targets the
442      * background. However, an update is needed any time we calculate the targets for a new
443      * fromContent.
444      */
updateSwipesResultsnull445     fun updateSwipesResults(fromContent: Content) {
446         val (upOrLeftResult, downOrRightResult) = computeSwipesResults(fromContent)
447 
448         this.upOrLeftResult = upOrLeftResult
449         this.downOrRightResult = downOrRightResult
450     }
451 
452     /**
453      * Returns the [UserActionResult] in the direction of [directionOffset].
454      *
455      * @param directionOffset signed float that indicates the direction. Positive is down or right
456      *   negative is up or left.
457      * @return null when there are no targets in either direction. If one direction is null and you
458      *   drag into the null direction this function will return the opposite direction, assuming
459      *   that the users intention is to start the drag into the other direction eventually. If
460      *   [directionOffset] is 0f and both direction are available, it will default to
461      *   [upOrLeftResult].
462      */
findUserActionResultnull463     fun findUserActionResult(directionOffset: Float): UserActionResult? {
464         return when {
465             upOrLeftResult == null && downOrRightResult == null -> null
466             (directionOffset < 0f && upOrLeftResult != null) || downOrRightResult == null ->
467                 upOrLeftResult
468 
469             else -> downOrRightResult
470         }
471     }
472 }
473 
474 internal class NestedScrollHandlerImpl(
475     private val draggableHandler: DraggableHandlerImpl,
476     internal var topOrLeftBehavior: NestedScrollBehavior,
477     internal var bottomOrRightBehavior: NestedScrollBehavior,
478     internal var isExternalOverscrollGesture: () -> Boolean,
479     private val pointersInfoOwner: PointersInfoOwner,
480 ) {
481     val connection: PriorityNestedScrollConnection = nestedScrollConnection()
482 
nestedScrollConnectionnull483     private fun nestedScrollConnection(): PriorityNestedScrollConnection {
484         // If we performed a long gesture before entering priority mode, we would have to avoid
485         // moving on to the next scene.
486         var canChangeScene = false
487 
488         var lastPointersDown: PointersInfo.PointersDown? = null
489 
490         fun shouldEnableSwipes(): Boolean {
491             return draggableHandler.layoutImpl
492                 .contentForUserActions()
493                 .shouldEnableSwipes(draggableHandler.orientation)
494         }
495 
496         return PriorityNestedScrollConnection(
497             orientation = draggableHandler.orientation,
498             canStartPreScroll = { _, _, _ -> false },
499             canStartPostScroll = { offsetAvailable, offsetBeforeStart, _ ->
500                 val behavior: NestedScrollBehavior =
501                     when {
502                         offsetAvailable > 0f -> topOrLeftBehavior
503                         offsetAvailable < 0f -> bottomOrRightBehavior
504                         else -> return@PriorityNestedScrollConnection false
505                     }
506 
507                 val isZeroOffset =
508                     if (isExternalOverscrollGesture()) false else offsetBeforeStart == 0f
509 
510                 val pointersDown: PointersInfo.PointersDown? =
511                     when (val info = pointersInfoOwner.pointersInfo()) {
512                         PointersInfo.MouseWheel -> {
513                             // Do not support mouse wheel interactions
514                             return@PriorityNestedScrollConnection false
515                         }
516 
517                         is PointersInfo.PointersDown -> info
518                         null -> null
519                     }
520                 lastPointersDown = pointersDown
521 
522                 when (behavior) {
523                     NestedScrollBehavior.EdgeNoPreview -> {
524                         canChangeScene = isZeroOffset
525                         isZeroOffset && shouldEnableSwipes()
526                     }
527 
528                     NestedScrollBehavior.EdgeWithPreview -> {
529                         canChangeScene = isZeroOffset
530                         shouldEnableSwipes()
531                     }
532 
533                     NestedScrollBehavior.EdgeAlways -> {
534                         canChangeScene = true
535                         shouldEnableSwipes()
536                     }
537                 }
538             },
539             canStartPostFling = { velocityAvailable ->
540                 val behavior: NestedScrollBehavior =
541                     when {
542                         velocityAvailable > 0f -> topOrLeftBehavior
543                         velocityAvailable < 0f -> bottomOrRightBehavior
544                         else -> return@PriorityNestedScrollConnection false
545                     }
546 
547                 // We could start an overscroll animation
548                 canChangeScene = false
549 
550                 val pointersDown: PointersInfo.PointersDown? =
551                     when (val info = pointersInfoOwner.pointersInfo()) {
552                         PointersInfo.MouseWheel -> {
553                             // Do not support mouse wheel interactions
554                             return@PriorityNestedScrollConnection false
555                         }
556 
557                         is PointersInfo.PointersDown -> info
558                         null -> null
559                     }
560                 lastPointersDown = pointersDown
561 
562                 behavior.canStartOnPostFling && shouldEnableSwipes()
563             },
564             onStart = { firstScroll ->
565                 scrollController(
566                     dragController =
567                         draggableHandler.onDragStarted(
568                             pointersDown = lastPointersDown,
569                             overSlop = firstScroll,
570                         ),
571                     canChangeScene = canChangeScene,
572                     pointersInfoOwner = pointersInfoOwner,
573                 )
574             },
575         )
576     }
577 }
578 
scrollControllernull579 private fun scrollController(
580     dragController: DragController,
581     canChangeScene: Boolean,
582     pointersInfoOwner: PointersInfoOwner,
583 ): ScrollController {
584     return object : ScrollController {
585         override fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float {
586             if (pointersInfoOwner.pointersInfo() == PointersInfo.MouseWheel) {
587                 // Do not support mouse wheel interactions
588                 return 0f
589             }
590 
591             return dragController.onDrag(delta = deltaScroll)
592         }
593 
594         override suspend fun OnStopScope.onStop(initialVelocity: Float): Float {
595             return dragController.onStop(
596                 velocity = initialVelocity,
597                 canChangeContent = canChangeScene,
598             )
599         }
600 
601         override fun onCancel() {
602             dragController.onCancel(canChangeScene)
603         }
604 
605         /**
606          * We need to maintain scroll priority even if the scene transition can no longer consume
607          * the scroll gesture to allow us to return to the previous scene.
608          */
609         override fun canCancelScroll(available: Float, consumed: Float) = false
610 
611         override fun canStopOnPreFling() = true
612     }
613 }
614 
615 /**
616  * The number of pixels below which there won't be a visible difference in the transition and from
617  * which the animation can stop.
618  */
619 // TODO(b/290184746): Have a better default visibility threshold which takes the swipe distance into
620 // account instead.
621 internal const val OffsetVisibilityThreshold = 0.5f
622 
623 private object NoOpDragController : DragController {
onDragnull624     override fun onDrag(delta: Float) = 0f
625 
626     override suspend fun onStop(velocity: Float, canChangeContent: Boolean) = 0f
627 
628     override fun onCancel(canChangeContent: Boolean) {
629         /* do nothing */
630     }
631 }
632