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 @file:Suppress("NOTHING_TO_INLINE")
18 
19 package com.android.compose.animation.scene
20 
21 import androidx.compose.animation.core.Animatable
22 import androidx.compose.animation.core.AnimationVector1D
23 import androidx.compose.foundation.gestures.Orientation
24 import androidx.compose.runtime.getValue
25 import androidx.compose.runtime.mutableFloatStateOf
26 import androidx.compose.runtime.mutableStateOf
27 import androidx.compose.runtime.setValue
28 import androidx.compose.ui.geometry.Offset
29 import androidx.compose.ui.unit.IntSize
30 import androidx.compose.ui.unit.dp
31 import androidx.compose.ui.unit.round
32 import com.android.compose.animation.scene.TransitionState.HasOverscrollProperties.Companion.DistanceUnspecified
33 import com.android.compose.nestedscroll.PriorityNestedScrollConnection
34 import kotlin.math.absoluteValue
35 import kotlinx.coroutines.CoroutineScope
36 import kotlinx.coroutines.CoroutineStart
37 import kotlinx.coroutines.Job
38 import kotlinx.coroutines.launch
39 
40 interface DraggableHandler {
41     /**
42      * Start a drag in the given [startedPosition], with the given [overSlop] and number of
43      * [pointersDown].
44      *
45      * The returned [DragController] should be used to continue or stop the drag.
46      */
47     fun onDragStarted(startedPosition: Offset?, overSlop: Float, pointersDown: Int): DragController
48 }
49 
50 /**
51  * The [DragController] provides control over the transition between two scenes through the [onDrag]
52  * and [onStop] methods.
53  */
54 interface DragController {
55     /** Drag the current scene by [delta] pixels. */
onDragnull56     fun onDrag(delta: Float)
57 
58     /** Starts a transition to a target scene. */
59     fun onStop(velocity: Float, canChangeScene: Boolean)
60 }
61 
62 internal class DraggableHandlerImpl(
63     internal val layoutImpl: SceneTransitionLayoutImpl,
64     internal val orientation: Orientation,
65     internal val coroutineScope: CoroutineScope,
66 ) : DraggableHandler {
67     /** The [DraggableHandler] can only have one active [DragController] at a time. */
68     private var dragController: DragControllerImpl? = null
69 
70     internal val isDrivingTransition: Boolean
71         get() = dragController?.isDrivingTransition == true
72 
73     /**
74      * The velocity threshold at which the intent of the user is to swipe up or down. It is the same
75      * as SwipeableV2Defaults.VelocityThreshold.
76      */
77     internal val velocityThreshold: Float
78         get() = with(layoutImpl.density) { 125.dp.toPx() }
79 
80     /**
81      * The positional threshold at which the intent of the user is to swipe to the next scene. It is
82      * the same as SwipeableV2Defaults.PositionalThreshold.
83      */
84     internal val positionalThreshold
85         get() = with(layoutImpl.density) { 56.dp.toPx() }
86 
87     /**
88      * Whether we should immediately intercept a gesture.
89      *
90      * Note: if this returns true, then [onDragStarted] will be called with overSlop equal to 0f,
91      * indicating that the transition should be intercepted.
92      */
93     internal fun shouldImmediatelyIntercept(startedPosition: Offset?): Boolean {
94         // We don't intercept the touch if we are not currently driving the transition.
95         val dragController = dragController
96         if (dragController?.isDrivingTransition != true) {
97             return false
98         }
99 
100         val swipeTransition = dragController.swipeTransition
101 
102         // Don't intercept a transition that is finishing.
103         if (swipeTransition.isFinishing) {
104             return false
105         }
106 
107         // Only intercept the current transition if one of the 2 swipes results is also a transition
108         // between the same pair of scenes.
109         val fromScene = swipeTransition._currentScene
110         val swipes = computeSwipes(fromScene, startedPosition, pointersDown = 1)
111         val (upOrLeft, downOrRight) = swipes.computeSwipesResults(fromScene)
112         return (upOrLeft != null &&
113             swipeTransition.isTransitioningBetween(fromScene.key, upOrLeft.toScene)) ||
114             (downOrRight != null &&
115                 swipeTransition.isTransitioningBetween(fromScene.key, downOrRight.toScene))
116     }
117 
118     override fun onDragStarted(
119         startedPosition: Offset?,
120         overSlop: Float,
121         pointersDown: Int,
122     ): DragController {
123         if (overSlop == 0f) {
124             val oldDragController = dragController
125             check(oldDragController != null && oldDragController.isDrivingTransition) {
126                 val isActive = oldDragController?.isDrivingTransition
127                 "onDragStarted(overSlop=0f) requires an active dragController, but was $isActive"
128             }
129 
130             // This [transition] was already driving the animation: simply take over it.
131             // Stop animating and start from where the current offset.
132             oldDragController.swipeTransition.cancelOffsetAnimation()
133 
134             // We need to recompute the swipe results since this is a new gesture, and the
135             // fromScene.userActions may have changed.
136             val swipes = oldDragController.swipes
137             swipes.updateSwipesResults(oldDragController.swipeTransition._fromScene)
138 
139             // A new gesture should always create a new SwipeTransition. This way there cannot be
140             // different gestures controlling the same transition.
141             val swipeTransition = SwipeTransition(oldDragController.swipeTransition)
142             swipes.updateSwipesResults(fromScene = swipeTransition._fromScene)
143             return updateDragController(swipes, swipeTransition)
144         }
145 
146         val transitionState = layoutImpl.state.transitionState
147         val fromScene = layoutImpl.scene(transitionState.currentScene)
148         val swipes = computeSwipes(fromScene, startedPosition, pointersDown)
149         val result =
150             swipes.findUserActionResult(fromScene, overSlop, true)
151                 // As we were unable to locate a valid target scene, the initial SwipeTransition
152                 // cannot be defined. Consequently, a simple NoOp Controller will be returned.
153                 ?: return NoOpDragController
154 
155         return updateDragController(
156             swipes = swipes,
157             swipeTransition =
158                 SwipeTransition(
159                     layoutImpl.state,
160                     coroutineScope,
161                     fromScene,
162                     result,
163                     swipes,
164                     layoutImpl,
165                     orientation,
166                 )
167         )
168     }
169 
170     private fun updateDragController(
171         swipes: Swipes,
172         swipeTransition: SwipeTransition
173     ): DragController {
174         val newDragController = DragControllerImpl(this, swipes, swipeTransition)
175         newDragController.updateTransition(swipeTransition, force = true)
176         dragController = newDragController
177         return newDragController
178     }
179 
180     private fun computeSwipes(
181         fromScene: Scene,
182         startedPosition: Offset?,
183         pointersDown: Int
184     ): Swipes {
185         val fromSource =
186             startedPosition?.let { position ->
187                 layoutImpl.swipeSourceDetector.source(
188                     fromScene.targetSize,
189                     position.round(),
190                     layoutImpl.density,
191                     orientation,
192                 )
193             }
194 
195         val upOrLeft =
196             Swipe(
197                 direction =
198                     when (orientation) {
199                         Orientation.Horizontal -> SwipeDirection.Left
200                         Orientation.Vertical -> SwipeDirection.Up
201                     },
202                 pointerCount = pointersDown,
203                 fromSource = fromSource,
204             )
205 
206         val downOrRight =
207             Swipe(
208                 direction =
209                     when (orientation) {
210                         Orientation.Horizontal -> SwipeDirection.Right
211                         Orientation.Vertical -> SwipeDirection.Down
212                     },
213                 pointerCount = pointersDown,
214                 fromSource = fromSource,
215             )
216 
217         return if (fromSource == null) {
218             Swipes(
219                 upOrLeft = null,
220                 downOrRight = null,
221                 upOrLeftNoSource = upOrLeft,
222                 downOrRightNoSource = downOrRight,
223             )
224         } else {
225             Swipes(
226                 upOrLeft = upOrLeft,
227                 downOrRight = downOrRight,
228                 upOrLeftNoSource = upOrLeft.copy(fromSource = null),
229                 downOrRightNoSource = downOrRight.copy(fromSource = null),
230             )
231         }
232     }
233 
234     companion object {
235         private const val TAG = "DraggableHandlerImpl"
236     }
237 }
238 
239 /** @param swipes The [Swipes] associated to the current gesture. */
240 private class DragControllerImpl(
241     private val draggableHandler: DraggableHandlerImpl,
242     val swipes: Swipes,
243     var swipeTransition: SwipeTransition,
244 ) : DragController {
245     val layoutState = draggableHandler.layoutImpl.state
246 
247     /**
248      * Whether this handle is active. If this returns false, calling [onDrag] and [onStop] will do
249      * nothing. We should have only one active controller at a time
250      */
251     val isDrivingTransition: Boolean
252         get() = layoutState.transitionState == swipeTransition
253 
254     init {
<lambda>null255         check(!isDrivingTransition) { "Multiple controllers with the same SwipeTransition" }
256     }
257 
updateTransitionnull258     fun updateTransition(newTransition: SwipeTransition, force: Boolean = false) {
259         if (isDrivingTransition || force) {
260             layoutState.startTransition(newTransition)
261         }
262 
263         swipeTransition = newTransition
264     }
265 
266     /**
267      * We receive a [delta] that can be consumed to change the offset of the current
268      * [SwipeTransition].
269      *
270      * @return the consumed delta
271      */
onDragnull272     override fun onDrag(delta: Float) {
273         if (delta == 0f || !isDrivingTransition || swipeTransition.isFinishing) return
274         swipeTransition.dragOffset += delta
275 
276         val (fromScene, acceleratedOffset) =
277             computeFromSceneConsideringAcceleratedSwipe(swipeTransition)
278 
279         val isNewFromScene = fromScene.key != swipeTransition.fromScene
280         val result =
281             swipes.findUserActionResult(
282                 fromScene = fromScene,
283                 directionOffset = swipeTransition.dragOffset,
284                 updateSwipesResults = isNewFromScene,
285             )
286 
287         if (result == null) {
288             onStop(velocity = delta, canChangeScene = true)
289             return
290         }
291 
292         if (
293             isNewFromScene ||
294                 result.toScene != swipeTransition.toScene ||
295                 result.transitionKey != swipeTransition.key
296         ) {
297             // Make sure the current transition will finish to the right current scene.
298             swipeTransition._currentScene = fromScene
299 
300             val swipeTransition =
301                 SwipeTransition(
302                         layoutState = layoutState,
303                         coroutineScope = draggableHandler.coroutineScope,
304                         fromScene = fromScene,
305                         result = result,
306                         swipes = swipes,
307                         layoutImpl = draggableHandler.layoutImpl,
308                         orientation = draggableHandler.orientation,
309                     )
310                     .apply { dragOffset = swipeTransition.dragOffset + acceleratedOffset }
311 
312             updateTransition(swipeTransition)
313         }
314     }
315 
316     /**
317      * Change fromScene in the case where the user quickly swiped multiple times in the same
318      * direction to accelerate the transition from A => B then B => C.
319      *
320      * @return the new fromScene and a dragOffset to be added in case the scene has changed
321      *
322      * TODO(b/290184746): the second drag needs to pass B to work. Add support for flinging twice
323      *   before B has been reached
324      */
computeFromSceneConsideringAcceleratedSwipenull325     private inline fun computeFromSceneConsideringAcceleratedSwipe(
326         swipeTransition: SwipeTransition,
327     ): Pair<Scene, Float> {
328         val toScene = swipeTransition._toScene
329         val fromScene = swipeTransition._fromScene
330         val distance = swipeTransition.distance()
331 
332         // If the swipe was not committed or if the swipe distance is not computed yet, don't do
333         // anything.
334         if (swipeTransition._currentScene != toScene || distance == DistanceUnspecified) {
335             return fromScene to 0f
336         }
337 
338         // If the offset is past the distance then let's change fromScene so that the user can swipe
339         // to the next screen or go back to the previous one.
340         val offset = swipeTransition.dragOffset
341         val absoluteDistance = distance.absoluteValue
342         return if (offset <= -absoluteDistance && swipes.upOrLeftResult?.toScene == toScene.key) {
343             toScene to absoluteDistance
344         } else if (offset >= absoluteDistance && swipes.downOrRightResult?.toScene == toScene.key) {
345             toScene to -absoluteDistance
346         } else {
347             fromScene to 0f
348         }
349     }
350 
onStopnull351     override fun onStop(velocity: Float, canChangeScene: Boolean) {
352         // The state was changed since the drag started; don't do anything.
353         if (!isDrivingTransition || swipeTransition.isFinishing) {
354             return
355         }
356 
357         // Important: Make sure that all the code here references the current transition when
358         // [onDragStopped] is called, otherwise the callbacks (like onAnimationCompleted()) might
359         // incorrectly finish a new transition that replaced this one.
360         val swipeTransition = this.swipeTransition
361 
362         fun animateTo(targetScene: Scene, targetOffset: Float) {
363             // If the effective current scene changed, it should be reflected right now in the
364             // current scene state, even before the settle animation is ongoing. That way all the
365             // swipeables and back handlers will be refreshed and the user can for instance quickly
366             // swipe vertically from A => B then horizontally from B => C, or swipe from A => B then
367             // immediately go back B => A.
368             if (targetScene != swipeTransition._currentScene) {
369                 swipeTransition._currentScene = targetScene
370                 with(draggableHandler.layoutImpl.state) {
371                     draggableHandler.coroutineScope.onChangeScene(targetScene.key)
372                 }
373             }
374 
375             swipeTransition.animateOffset(
376                 coroutineScope = draggableHandler.coroutineScope,
377                 initialVelocity = velocity,
378                 targetOffset = targetOffset,
379                 targetScene = targetScene.key,
380             )
381         }
382 
383         val fromScene = swipeTransition._fromScene
384         if (canChangeScene) {
385             // If we are halfway between two scenes, we check what the target will be based on the
386             // velocity and offset of the transition, then we launch the animation.
387 
388             val toScene = swipeTransition._toScene
389 
390             // Compute the destination scene (and therefore offset) to settle in.
391             val offset = swipeTransition.dragOffset
392             val distance = swipeTransition.distance()
393             var targetScene: Scene
394             var targetOffset: Float
395             if (
396                 distance != DistanceUnspecified &&
397                     shouldCommitSwipe(
398                         offset,
399                         distance,
400                         velocity,
401                         wasCommitted = swipeTransition._currentScene == toScene,
402                     )
403             ) {
404                 targetScene = toScene
405                 targetOffset = distance
406             } else {
407                 targetScene = fromScene
408                 targetOffset = 0f
409             }
410 
411             if (
412                 targetScene != swipeTransition._currentScene &&
413                     !layoutState.canChangeScene(targetScene.key)
414             ) {
415                 // We wanted to change to a new scene but we are not allowed to, so we animate back
416                 // to the current scene.
417                 targetScene = swipeTransition._currentScene
418                 targetOffset =
419                     if (targetScene == fromScene) {
420                         0f
421                     } else {
422                         check(distance != DistanceUnspecified) {
423                             "distance is equal to $DistanceUnspecified"
424                         }
425                         distance
426                     }
427             }
428 
429             animateTo(targetScene = targetScene, targetOffset = targetOffset)
430         } else {
431             // We are doing an overscroll animation between scenes. In this case, we can also start
432             // from the idle position.
433 
434             val startFromIdlePosition = swipeTransition.dragOffset == 0f
435 
436             if (startFromIdlePosition) {
437                 // If there is a target scene, we start the overscroll animation.
438                 val result = swipes.findUserActionResultStrict(velocity)
439                 if (result == null) {
440                     // We will not animate
441                     swipeTransition.snapToScene(fromScene.key)
442                     return
443                 }
444 
445                 val newSwipeTransition =
446                     SwipeTransition(
447                             layoutState = layoutState,
448                             coroutineScope = draggableHandler.coroutineScope,
449                             fromScene = fromScene,
450                             result = result,
451                             swipes = swipes,
452                             layoutImpl = draggableHandler.layoutImpl,
453                             orientation = draggableHandler.orientation,
454                         )
455                         .apply { _currentScene = swipeTransition._currentScene }
456 
457                 updateTransition(newSwipeTransition)
458                 animateTo(targetScene = fromScene, targetOffset = 0f)
459             } else {
460                 // We were between two scenes: animate to the initial scene.
461                 animateTo(targetScene = fromScene, targetOffset = 0f)
462             }
463         }
464     }
465 
466     /**
467      * Whether the swipe to the target scene should be committed or not. This is inspired by
468      * SwipeableV2.computeTarget().
469      */
shouldCommitSwipenull470     private fun shouldCommitSwipe(
471         offset: Float,
472         distance: Float,
473         velocity: Float,
474         wasCommitted: Boolean,
475     ): Boolean {
476         fun isCloserToTarget(): Boolean {
477             return (offset - distance).absoluteValue < offset.absoluteValue
478         }
479 
480         val velocityThreshold = draggableHandler.velocityThreshold
481         val positionalThreshold = draggableHandler.positionalThreshold
482 
483         // Swiping up or left.
484         if (distance < 0f) {
485             return if (offset > 0f || velocity >= velocityThreshold) {
486                 false
487             } else {
488                 velocity <= -velocityThreshold ||
489                     (offset <= -positionalThreshold && !wasCommitted) ||
490                     isCloserToTarget()
491             }
492         }
493 
494         // Swiping down or right.
495         return if (offset < 0f || velocity <= -velocityThreshold) {
496             false
497         } else {
498             velocity >= velocityThreshold ||
499                 (offset >= positionalThreshold && !wasCommitted) ||
500                 isCloserToTarget()
501         }
502     }
503 }
504 
SwipeTransitionnull505 private fun SwipeTransition(
506     layoutState: BaseSceneTransitionLayoutState,
507     coroutineScope: CoroutineScope,
508     fromScene: Scene,
509     result: UserActionResult,
510     swipes: Swipes,
511     layoutImpl: SceneTransitionLayoutImpl,
512     orientation: Orientation,
513 ): SwipeTransition {
514     val upOrLeftResult = swipes.upOrLeftResult
515     val downOrRightResult = swipes.downOrRightResult
516     val isUpOrLeft =
517         when (result) {
518             upOrLeftResult -> true
519             downOrRightResult -> false
520             else -> error("Unknown result $result ($upOrLeftResult $downOrRightResult)")
521         }
522 
523     return SwipeTransition(
524         layoutImpl = layoutImpl,
525         layoutState = layoutState,
526         coroutineScope = coroutineScope,
527         key = result.transitionKey,
528         _fromScene = fromScene,
529         _toScene = layoutImpl.scene(result.toScene),
530         userActionDistanceScope = layoutImpl.userActionDistanceScope,
531         orientation = orientation,
532         isUpOrLeft = isUpOrLeft,
533     )
534 }
535 
SwipeTransitionnull536 private fun SwipeTransition(old: SwipeTransition): SwipeTransition {
537     return SwipeTransition(
538             layoutImpl = old.layoutImpl,
539             layoutState = old.layoutState,
540             coroutineScope = old.coroutineScope,
541             key = old.key,
542             _fromScene = old._fromScene,
543             _toScene = old._toScene,
544             userActionDistanceScope = old.userActionDistanceScope,
545             orientation = old.orientation,
546             isUpOrLeft = old.isUpOrLeft
547         )
548         .apply {
549             _currentScene = old._currentScene
550             dragOffset = old.dragOffset
551         }
552 }
553 
554 private class SwipeTransition(
555     val layoutImpl: SceneTransitionLayoutImpl,
556     val layoutState: BaseSceneTransitionLayoutState,
557     val coroutineScope: CoroutineScope,
558     override val key: TransitionKey?,
559     val _fromScene: Scene,
560     val _toScene: Scene,
561     val userActionDistanceScope: UserActionDistanceScope,
562     override val orientation: Orientation,
563     override val isUpOrLeft: Boolean,
564 ) :
565     TransitionState.Transition(_fromScene.key, _toScene.key),
566     TransitionState.HasOverscrollProperties {
567     var _currentScene by mutableStateOf(_fromScene)
568     override val currentScene: SceneKey
569         get() = _currentScene.key
570 
571     override val progress: Float
572         get() {
573             // Important: If we are going to return early because distance is equal to 0, we should
574             // still make sure we read the offset before returning so that the calling code still
575             // subscribes to the offset value.
576             val offset = offsetAnimation?.animatable?.value ?: dragOffset
577 
578             val distance = distance()
579             if (distance == DistanceUnspecified) {
580                 return 0f
581             }
582 
583             return offset / distance
584         }
585 
586     override val progressVelocity: Float
587         get() {
588             val animatable = offsetAnimation?.animatable ?: return 0f
589             val distance = distance()
590             if (distance == DistanceUnspecified) {
591                 return 0f
592             }
593 
594             val velocityInDistanceUnit = animatable.velocity
595             return velocityInDistanceUnit / distance.absoluteValue
596         }
597 
598     override val isInitiatedByUserInput = true
599 
600     override var bouncingScene: SceneKey? = null
601 
602     /** The current offset caused by the drag gesture. */
603     var dragOffset by mutableFloatStateOf(0f)
604 
605     /** The offset animation that animates the offset once the user lifts their finger. */
606     private var offsetAnimation: OffsetAnimation? by mutableStateOf(null)
607 
608     override val isUserInputOngoing: Boolean
609         get() = offsetAnimation == null
610 
611     override val overscrollScope: OverscrollScope =
612         object : OverscrollScope {
613             override val density: Float
614                 get() = layoutImpl.density.density
615 
616             override val fontScale: Float
617                 get() = layoutImpl.density.fontScale
618 
619             override val absoluteDistance: Float
620                 get() = distance().absoluteValue
621         }
622 
623     private var lastDistance = DistanceUnspecified
624 
625     /** Whether [TransitionState.Transition.finish] was called on this transition. */
626     var isFinishing = false
627         private set
628 
629     /**
630      * The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above
631      * or to the left of [toScene].
632      *
633      * Note that this distance can be equal to [DistanceUnspecified] during the first frame of a
634      * transition when the distance depends on the size or position of an element that is composed
635      * in the scene we are going to.
636      */
distancenull637     fun distance(): Float {
638         if (lastDistance != DistanceUnspecified) {
639             return lastDistance
640         }
641 
642         val absoluteDistance =
643             with(transformationSpec.distance ?: DefaultSwipeDistance) {
644                 userActionDistanceScope.absoluteDistance(
645                     _fromScene.targetSize,
646                     orientation,
647                 )
648             }
649 
650         if (absoluteDistance <= 0f) {
651             return DistanceUnspecified
652         }
653 
654         val distance = if (isUpOrLeft) -absoluteDistance else absoluteDistance
655         lastDistance = distance
656         return distance
657     }
658 
659     /** Ends any previous [offsetAnimation] and runs the new [animation]. */
startOffsetAnimationnull660     private fun startOffsetAnimation(animation: () -> OffsetAnimation): OffsetAnimation {
661         cancelOffsetAnimation()
662         return animation().also { offsetAnimation = it }
663     }
664 
665     /** Cancel any ongoing offset animation. */
666     // TODO(b/317063114) This should be a suspended function to avoid multiple jobs running at
667     // the same time.
cancelOffsetAnimationnull668     fun cancelOffsetAnimation() {
669         val animation = offsetAnimation ?: return
670         offsetAnimation = null
671 
672         dragOffset = animation.animatable.value
673         animation.job.cancel()
674     }
675 
animateOffsetnull676     fun animateOffset(
677         // TODO(b/317063114) The CoroutineScope should be removed.
678         coroutineScope: CoroutineScope,
679         initialVelocity: Float,
680         targetOffset: Float,
681         targetScene: SceneKey,
682     ): OffsetAnimation {
683         // Skip the animation if we have already reached the target scene and the overscroll does
684         // not animate anything.
685         val hasReachedTargetScene =
686             (targetScene == toScene && progress >= 1f) ||
687                 (targetScene == fromScene && progress <= 0f)
688         val skipAnimation =
689             hasReachedTargetScene &&
690                 currentOverscrollSpec?.transformationSpec?.transformations?.isEmpty() == true
691 
692         return startOffsetAnimation {
693             val animatable = Animatable(dragOffset, OffsetVisibilityThreshold)
694             val isTargetGreater = targetOffset > animatable.value
695             val startedWhenOvercrollingTargetScene =
696                 if (targetScene == fromScene) progress < 0f else progress > 1f
697             val job =
698                 coroutineScope
699                     // Important: We start atomically to make sure that we start the coroutine even
700                     // if it is cancelled right after it is launched, so that snapToScene() is
701                     // correctly called. Otherwise, this transition will never be stopped and we
702                     // will never settle to Idle.
703                     .launch(start = CoroutineStart.ATOMIC) {
704                         // TODO(b/327249191): Refactor the code so that we don't even launch a
705                         // coroutine if we don't need to animate.
706                         if (skipAnimation) {
707                             snapToScene(targetScene)
708                             cancelOffsetAnimation()
709                             dragOffset = targetOffset
710                             return@launch
711                         }
712 
713                         try {
714                             val swipeSpec =
715                                 transformationSpec.swipeSpec
716                                     ?: layoutState.transitions.defaultSwipeSpec
717                             animatable.animateTo(
718                                 targetValue = targetOffset,
719                                 animationSpec = swipeSpec,
720                                 initialVelocity = initialVelocity,
721                             ) {
722                                 if (bouncingScene == null) {
723                                     val isBouncing =
724                                         if (isTargetGreater) {
725                                             if (startedWhenOvercrollingTargetScene) {
726                                                 value >= targetOffset
727                                             } else {
728                                                 value > targetOffset
729                                             }
730                                         } else {
731                                             if (startedWhenOvercrollingTargetScene) {
732                                                 value <= targetOffset
733                                             } else {
734                                                 value < targetOffset
735                                             }
736                                         }
737 
738                                     if (isBouncing) {
739                                         bouncingScene = targetScene
740 
741                                         // Immediately stop this transition if we are bouncing on a
742                                         // scene that does not bounce.
743                                         val overscrollSpec = currentOverscrollSpec
744                                         if (
745                                             overscrollSpec != null &&
746                                                 overscrollSpec.transformationSpec.transformations
747                                                     .isEmpty()
748                                         ) {
749                                             snapToScene(targetScene)
750                                         }
751                                     }
752                                 }
753                             }
754                         } finally {
755                             snapToScene(targetScene)
756                         }
757                     }
758 
759             OffsetAnimation(animatable, job)
760         }
761     }
762 
snapToScenenull763     fun snapToScene(scene: SceneKey) {
764         cancelOffsetAnimation()
765         layoutState.finishTransition(this, idleScene = scene)
766     }
767 
finishnull768     override fun finish(): Job {
769         if (isFinishing) return requireNotNull(offsetAnimation).job
770         isFinishing = true
771 
772         // If we were already animating the offset, simply return the job.
773         offsetAnimation?.let {
774             return it.job
775         }
776 
777         // Animate to the current scene.
778         val targetScene = currentScene
779         val targetOffset =
780             if (targetScene == fromScene) {
781                 0f
782             } else {
783                 val distance = distance()
784                 check(distance != DistanceUnspecified) {
785                     "targetScene != fromScene but distance is unspecified"
786                 }
787                 distance
788             }
789 
790         val animation =
791             animateOffset(
792                 coroutineScope = coroutineScope,
793                 initialVelocity = 0f,
794                 targetOffset = targetOffset,
795                 targetScene = currentScene,
796             )
797         check(offsetAnimation == animation)
798         return animation.job
799     }
800 
801     internal class OffsetAnimation(
802         /** The animatable used to animate the offset. */
803         val animatable: Animatable<Float, AnimationVector1D>,
804 
805         /** The job in which [animatable] is animated. */
806         val job: Job,
807     )
808 }
809 
810 private object DefaultSwipeDistance : UserActionDistance {
absoluteDistancenull811     override fun UserActionDistanceScope.absoluteDistance(
812         fromSceneSize: IntSize,
813         orientation: Orientation,
814     ): Float {
815         return when (orientation) {
816             Orientation.Horizontal -> fromSceneSize.width
817             Orientation.Vertical -> fromSceneSize.height
818         }.toFloat()
819     }
820 }
821 
822 /** The [Swipe] associated to a given fromScene, startedPosition and pointersDown. */
823 private class Swipes(
824     val upOrLeft: Swipe?,
825     val downOrRight: Swipe?,
826     val upOrLeftNoSource: Swipe?,
827     val downOrRightNoSource: Swipe?,
828 ) {
829     /** The [UserActionResult] associated to up and down swipes. */
830     var upOrLeftResult: UserActionResult? = null
831     var downOrRightResult: UserActionResult? = null
832 
computeSwipesResultsnull833     fun computeSwipesResults(fromScene: Scene): Pair<UserActionResult?, UserActionResult?> {
834         val userActions = fromScene.userActions
835         fun result(swipe: Swipe?): UserActionResult? {
836             return userActions[swipe ?: return null]
837         }
838 
839         val upOrLeftResult = result(upOrLeft) ?: result(upOrLeftNoSource)
840         val downOrRightResult = result(downOrRight) ?: result(downOrRightNoSource)
841         return upOrLeftResult to downOrRightResult
842     }
843 
updateSwipesResultsnull844     fun updateSwipesResults(fromScene: Scene) {
845         val (upOrLeftResult, downOrRightResult) = computeSwipesResults(fromScene)
846 
847         this.upOrLeftResult = upOrLeftResult
848         this.downOrRightResult = downOrRightResult
849     }
850 
851     /**
852      * Returns the [UserActionResult] from [fromScene] in the direction of [directionOffset].
853      *
854      * @param fromScene the scene from which we look for the target
855      * @param directionOffset signed float that indicates the direction. Positive is down or right
856      *   negative is up or left.
857      * @param updateSwipesResults whether the target scenes should be updated to the current values
858      *   held in the Scenes map. Usually we don't want to update them while doing a drag, because
859      *   this could change the target scene (jump cutting) to a different scene, when some system
860      *   state changed the targets the background. However, an update is needed any time we
861      *   calculate the targets for a new fromScene.
862      * @return null when there are no targets in either direction. If one direction is null and you
863      *   drag into the null direction this function will return the opposite direction, assuming
864      *   that the users intention is to start the drag into the other direction eventually. If
865      *   [directionOffset] is 0f and both direction are available, it will default to
866      *   [upOrLeftResult].
867      */
findUserActionResultnull868     fun findUserActionResult(
869         fromScene: Scene,
870         directionOffset: Float,
871         updateSwipesResults: Boolean,
872     ): UserActionResult? {
873         if (updateSwipesResults) {
874             updateSwipesResults(fromScene)
875         }
876 
877         return when {
878             upOrLeftResult == null && downOrRightResult == null -> null
879             (directionOffset < 0f && upOrLeftResult != null) || downOrRightResult == null ->
880                 upOrLeftResult
881             else -> downOrRightResult
882         }
883     }
884 
885     /**
886      * A strict version of [findUserActionResult] that will return null when there is no Scene in
887      * [directionOffset] direction
888      */
findUserActionResultStrictnull889     fun findUserActionResultStrict(directionOffset: Float): UserActionResult? {
890         return when {
891             directionOffset > 0f -> upOrLeftResult
892             directionOffset < 0f -> downOrRightResult
893             else -> null
894         }
895     }
896 }
897 
898 internal class NestedScrollHandlerImpl(
899     private val layoutImpl: SceneTransitionLayoutImpl,
900     private val orientation: Orientation,
901     private val topOrLeftBehavior: NestedScrollBehavior,
902     private val bottomOrRightBehavior: NestedScrollBehavior,
903     private val isExternalOverscrollGesture: () -> Boolean,
904 ) {
905     private val layoutState = layoutImpl.state
906     private val draggableHandler = layoutImpl.draggableHandler(orientation)
907 
908     val connection: PriorityNestedScrollConnection = nestedScrollConnection()
909 
nestedScrollConnectionnull910     private fun nestedScrollConnection(): PriorityNestedScrollConnection {
911         // If we performed a long gesture before entering priority mode, we would have to avoid
912         // moving on to the next scene.
913         var canChangeScene = false
914 
915         val actionUpOrLeft =
916             Swipe(
917                 direction =
918                     when (orientation) {
919                         Orientation.Horizontal -> SwipeDirection.Left
920                         Orientation.Vertical -> SwipeDirection.Up
921                     },
922                 pointerCount = 1,
923             )
924 
925         val actionDownOrRight =
926             Swipe(
927                 direction =
928                     when (orientation) {
929                         Orientation.Horizontal -> SwipeDirection.Right
930                         Orientation.Vertical -> SwipeDirection.Down
931                     },
932                 pointerCount = 1,
933             )
934 
935         fun hasNextScene(amount: Float): Boolean {
936             val transitionState = layoutState.transitionState
937             val scene = transitionState.currentScene
938             val fromScene = layoutImpl.scene(scene)
939             val nextScene =
940                 when {
941                     amount < 0f -> fromScene.userActions[actionUpOrLeft]
942                     amount > 0f -> fromScene.userActions[actionDownOrRight]
943                     else -> null
944                 }
945             if (nextScene != null) return true
946 
947             if (transitionState !is TransitionState.Idle) return false
948 
949             val overscrollSpec = layoutImpl.state.transitions.overscrollSpec(scene, orientation)
950             return overscrollSpec != null
951         }
952 
953         var dragController: DragController? = null
954         var isIntercepting = false
955 
956         return PriorityNestedScrollConnection(
957             orientation = orientation,
958             canStartPreScroll = { offsetAvailable, offsetBeforeStart ->
959                 canChangeScene =
960                     if (isExternalOverscrollGesture()) false else offsetBeforeStart == 0f
961 
962                 val canInterceptSwipeTransition =
963                     canChangeScene &&
964                         offsetAvailable != 0f &&
965                         draggableHandler.shouldImmediatelyIntercept(startedPosition = null)
966                 if (!canInterceptSwipeTransition) return@PriorityNestedScrollConnection false
967 
968                 val threshold = layoutImpl.transitionInterceptionThreshold
969                 val hasSnappedToIdle = layoutState.snapToIdleIfClose(threshold)
970                 if (hasSnappedToIdle) {
971                     // If the current swipe transition is closed to 0f or 1f, then we want to
972                     // interrupt the transition (snapping it to Idle) and scroll the list.
973                     return@PriorityNestedScrollConnection false
974                 }
975 
976                 // If the current swipe transition is *not* closed to 0f or 1f, then we want the
977                 // scroll events to intercept the current transition to continue the scene
978                 // transition.
979                 isIntercepting = true
980                 true
981             },
982             canStartPostScroll = { offsetAvailable, offsetBeforeStart ->
983                 val behavior: NestedScrollBehavior =
984                     when {
985                         offsetAvailable > 0f -> topOrLeftBehavior
986                         offsetAvailable < 0f -> bottomOrRightBehavior
987                         else -> return@PriorityNestedScrollConnection false
988                     }
989 
990                 val isZeroOffset =
991                     if (isExternalOverscrollGesture()) false else offsetBeforeStart == 0f
992 
993                 val canStart =
994                     when (behavior) {
995                         NestedScrollBehavior.DuringTransitionBetweenScenes -> {
996                             canChangeScene = false // unused: added for consistency
997                             false
998                         }
999                         NestedScrollBehavior.EdgeNoPreview -> {
1000                             canChangeScene = isZeroOffset
1001                             isZeroOffset && hasNextScene(offsetAvailable)
1002                         }
1003                         NestedScrollBehavior.EdgeWithPreview -> {
1004                             canChangeScene = isZeroOffset
1005                             hasNextScene(offsetAvailable)
1006                         }
1007                         NestedScrollBehavior.EdgeAlways -> {
1008                             canChangeScene = true
1009                             hasNextScene(offsetAvailable)
1010                         }
1011                     }
1012 
1013                 if (canStart) {
1014                     isIntercepting = false
1015                 }
1016 
1017                 canStart
1018             },
1019             canStartPostFling = { velocityAvailable ->
1020                 val behavior: NestedScrollBehavior =
1021                     when {
1022                         velocityAvailable > 0f -> topOrLeftBehavior
1023                         velocityAvailable < 0f -> bottomOrRightBehavior
1024                         else -> return@PriorityNestedScrollConnection false
1025                     }
1026 
1027                 // We could start an overscroll animation
1028                 canChangeScene = false
1029 
1030                 val canStart = behavior.canStartOnPostFling && hasNextScene(velocityAvailable)
1031                 if (canStart) {
1032                     isIntercepting = false
1033                 }
1034 
1035                 canStart
1036             },
1037             canContinueScroll = { true },
1038             canScrollOnFling = false,
1039             onStart = { offsetAvailable ->
1040                 dragController =
1041                     draggableHandler.onDragStarted(
1042                         pointersDown = 1,
1043                         startedPosition = null,
1044                         overSlop = if (isIntercepting) 0f else offsetAvailable,
1045                     )
1046             },
1047             onScroll = { offsetAvailable ->
1048                 val controller = dragController ?: error("Should be called after onStart")
1049 
1050                 // TODO(b/297842071) We should handle the overscroll or slow drag if the gesture is
1051                 // initiated in a nested child.
1052                 controller.onDrag(delta = offsetAvailable)
1053 
1054                 offsetAvailable
1055             },
1056             onStop = { velocityAvailable ->
1057                 val controller = dragController ?: error("Should be called after onStart")
1058 
1059                 controller.onStop(velocity = velocityAvailable, canChangeScene = canChangeScene)
1060 
1061                 dragController = null
1062                 // The onDragStopped animation consumes any remaining velocity.
1063                 velocityAvailable
1064             },
1065         )
1066     }
1067 }
1068 
1069 /**
1070  * The number of pixels below which there won't be a visible difference in the transition and from
1071  * which the animation can stop.
1072  */
1073 // TODO(b/290184746): Have a better default visibility threshold which takes the swipe distance into
1074 // account instead.
1075 internal const val OffsetVisibilityThreshold = 0.5f
1076 
1077 private object NoOpDragController : DragController {
onDragnull1078     override fun onDrag(delta: Float) {}
1079 
onStopnull1080     override fun onStop(velocity: Float, canChangeScene: Boolean) {}
1081 }
1082