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