1 /*
2  * Copyright (C) 2024 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.animation.core.Animatable
20 import androidx.compose.animation.core.AnimationSpec
21 import androidx.compose.animation.core.AnimationVector1D
22 import androidx.compose.foundation.gestures.Orientation
23 import androidx.compose.runtime.getValue
24 import androidx.compose.runtime.mutableFloatStateOf
25 import androidx.compose.runtime.mutableStateOf
26 import androidx.compose.runtime.setValue
27 import com.android.compose.animation.scene.content.state.TransitionState
28 import com.android.compose.animation.scene.content.state.TransitionState.HasOverscrollProperties.Companion.DistanceUnspecified
29 import kotlin.math.absoluteValue
30 import kotlinx.coroutines.CompletableDeferred
31 import kotlinx.coroutines.launch
32 
createSwipeAnimationnull33 internal fun createSwipeAnimation(
34     layoutState: MutableSceneTransitionLayoutStateImpl,
35     result: UserActionResult,
36     isUpOrLeft: Boolean,
37     orientation: Orientation,
38     distance: Float,
39 ): SwipeAnimation<*> {
40     return createSwipeAnimation(
41         layoutState,
42         result,
43         isUpOrLeft,
44         orientation,
45         distance = { distance },
46         contentForUserActions = {
47             error("Computing contentForUserActions requires a SceneTransitionLayoutImpl")
48         },
49     )
50 }
51 
createSwipeAnimationnull52 internal fun createSwipeAnimation(
53     layoutImpl: SceneTransitionLayoutImpl,
54     result: UserActionResult,
55     isUpOrLeft: Boolean,
56     orientation: Orientation,
57     distance: Float = DistanceUnspecified,
58 ): SwipeAnimation<*> {
59     var lastDistance = distance
60 
61     fun distance(animation: SwipeAnimation<*>): Float {
62         if (lastDistance != DistanceUnspecified) {
63             return lastDistance
64         }
65 
66         val absoluteDistance =
67             with(animation.contentTransition.transformationSpec.distance ?: DefaultSwipeDistance) {
68                 layoutImpl.userActionDistanceScope.absoluteDistance(
69                     fromContent = animation.fromContent,
70                     toContent = animation.toContent,
71                     orientation = orientation,
72                 )
73             }
74 
75         if (absoluteDistance <= 0f) {
76             return DistanceUnspecified
77         }
78 
79         val distance = if (isUpOrLeft) -absoluteDistance else absoluteDistance
80         lastDistance = distance
81         return distance
82     }
83 
84     return createSwipeAnimation(
85         layoutImpl.state,
86         result,
87         isUpOrLeft,
88         orientation,
89         distance = ::distance,
90         contentForUserActions = { layoutImpl.contentForUserActions().key },
91     )
92 }
93 
createSwipeAnimationnull94 private fun createSwipeAnimation(
95     layoutState: MutableSceneTransitionLayoutStateImpl,
96     result: UserActionResult,
97     isUpOrLeft: Boolean,
98     orientation: Orientation,
99     distance: (SwipeAnimation<*>) -> Float,
100     contentForUserActions: () -> ContentKey,
101 ): SwipeAnimation<*> {
102     fun <T : ContentKey> swipeAnimation(fromContent: T, toContent: T): SwipeAnimation<T> {
103         return SwipeAnimation(
104             layoutState = layoutState,
105             fromContent = fromContent,
106             toContent = toContent,
107             orientation = orientation,
108             isUpOrLeft = isUpOrLeft,
109             requiresFullDistanceSwipe = result.requiresFullDistanceSwipe,
110             distance = distance,
111         )
112     }
113 
114     return when (result) {
115         is UserActionResult.ChangeScene -> {
116             val fromScene = layoutState.currentScene
117             val toScene = result.toScene
118             ChangeSceneSwipeTransition(
119                     layoutState = layoutState,
120                     swipeAnimation = swipeAnimation(fromContent = fromScene, toContent = toScene),
121                     key = result.transitionKey,
122                     replacedTransition = null,
123                 )
124                 .swipeAnimation
125         }
126         is UserActionResult.ShowOverlay -> {
127             val fromScene = layoutState.currentScene
128             val overlay = result.overlay
129             ShowOrHideOverlaySwipeTransition(
130                     layoutState = layoutState,
131                     fromOrToScene = fromScene,
132                     overlay = overlay,
133                     swipeAnimation = swipeAnimation(fromContent = fromScene, toContent = overlay),
134                     key = result.transitionKey,
135                     replacedTransition = null,
136                 )
137                 .swipeAnimation
138         }
139         is UserActionResult.HideOverlay -> {
140             val toScene = layoutState.currentScene
141             val overlay = result.overlay
142             ShowOrHideOverlaySwipeTransition(
143                     layoutState = layoutState,
144                     fromOrToScene = toScene,
145                     overlay = overlay,
146                     swipeAnimation = swipeAnimation(fromContent = overlay, toContent = toScene),
147                     key = result.transitionKey,
148                     replacedTransition = null,
149                 )
150                 .swipeAnimation
151         }
152         is UserActionResult.ReplaceByOverlay -> {
153             val fromOverlay =
154                 when (val contentForUserActions = contentForUserActions()) {
155                     is SceneKey ->
156                         error("ReplaceByOverlay can only be called when an overlay is shown")
157                     is OverlayKey -> contentForUserActions
158                 }
159 
160             val toOverlay = result.overlay
161             ReplaceOverlaySwipeTransition(
162                     layoutState = layoutState,
163                     swipeAnimation =
164                         swipeAnimation(fromContent = fromOverlay, toContent = toOverlay),
165                     key = result.transitionKey,
166                     replacedTransition = null,
167                 )
168                 .swipeAnimation
169         }
170     }
171 }
172 
createSwipeAnimationnull173 internal fun createSwipeAnimation(old: SwipeAnimation<*>): SwipeAnimation<*> {
174     return when (val transition = old.contentTransition) {
175         is TransitionState.Transition.ChangeScene -> {
176             ChangeSceneSwipeTransition(transition as ChangeSceneSwipeTransition).swipeAnimation
177         }
178         is TransitionState.Transition.ShowOrHideOverlay -> {
179             ShowOrHideOverlaySwipeTransition(transition as ShowOrHideOverlaySwipeTransition)
180                 .swipeAnimation
181         }
182         is TransitionState.Transition.ReplaceOverlay -> {
183             ReplaceOverlaySwipeTransition(transition as ReplaceOverlaySwipeTransition)
184                 .swipeAnimation
185         }
186     }
187 }
188 
189 /** A helper class that contains the main logic for swipe transitions. */
190 internal class SwipeAnimation<T : ContentKey>(
191     val layoutState: MutableSceneTransitionLayoutStateImpl,
192     val fromContent: T,
193     val toContent: T,
194     override val orientation: Orientation,
195     override val isUpOrLeft: Boolean,
196     val requiresFullDistanceSwipe: Boolean,
197     private val distance: (SwipeAnimation<T>) -> Float,
198     currentContent: T = fromContent,
199     dragOffset: Float = 0f,
200 ) : TransitionState.HasOverscrollProperties {
201     /** The [TransitionState.Transition] whose implementation delegates to this [SwipeAnimation]. */
202     lateinit var contentTransition: TransitionState.Transition
203 
204     private var _currentContent by mutableStateOf(currentContent)
205     var currentContent: T
206         get() = _currentContent
207         set(value) {
<lambda>null208             check(!isAnimatingOffset()) {
209                 "currentContent can not be changed once we are animating the offset"
210             }
211             _currentContent = value
212         }
213 
214     val progress: Float
215         get() {
216             // Important: If we are going to return early because distance is equal to 0, we should
217             // still make sure we read the offset before returning so that the calling code still
218             // subscribes to the offset value.
219             val animatable = offsetAnimation
220             val offset =
221                 when {
222                     isInPreviewStage -> 0f
223                     animatable != null -> animatable.value
224                     else -> dragOffset
225                 }
226 
227             return computeProgress(offset)
228         }
229 
computeProgressnull230     fun computeProgress(offset: Float): Float {
231         val distance = distance()
232         if (distance == DistanceUnspecified) {
233             return 0f
234         }
235         return offset / distance
236     }
237 
238     val progressVelocity: Float
239         get() {
240             val animatable = offsetAnimation ?: return 0f
241             val distance = distance()
242             if (distance == DistanceUnspecified) {
243                 return 0f
244             }
245 
246             val velocityInDistanceUnit = animatable.velocity
247             return velocityInDistanceUnit / distance.absoluteValue
248         }
249 
250     val previewProgress: Float
251         get() {
252             val offset =
253                 if (isInPreviewStage) {
254                     offsetAnimation?.value ?: dragOffset
255                 } else {
256                     dragOffset
257                 }
258             return computeProgress(offset)
259         }
260 
261     val previewProgressVelocity: Float
262         get() = 0f
263 
264     val isInPreviewStage: Boolean
265         get() = contentTransition.previewTransformationSpec != null && currentContent == fromContent
266 
267     override var bouncingContent: ContentKey? = null
268 
269     /** The current offset caused by the drag gesture. */
270     var dragOffset by mutableFloatStateOf(dragOffset)
271 
272     /** The offset animation that animates the offset once the user lifts their finger. */
273     private var offsetAnimation: Animatable<Float, AnimationVector1D>? by mutableStateOf(null)
274     private val offsetAnimationRunnable = CompletableDeferred<(suspend () -> Unit)?>()
275 
276     val isUserInputOngoing: Boolean
277         get() = offsetAnimation == null
278 
279     override val absoluteDistance: Float
280         get() = distance().absoluteValue
281 
282     constructor(
283         other: SwipeAnimation<T>
284     ) : this(
285         layoutState = other.layoutState,
286         fromContent = other.fromContent,
287         toContent = other.toContent,
288         orientation = other.orientation,
289         isUpOrLeft = other.isUpOrLeft,
290         requiresFullDistanceSwipe = other.requiresFullDistanceSwipe,
291         distance = other.distance,
292         currentContent = other.currentContent,
293         dragOffset = other.offsetAnimation?.value ?: other.dragOffset,
294     )
295 
runnull296     suspend fun run() {
297         // This animation will first be driven by finger, then when the user lift their finger we
298         // start an animation to the target offset (progress = 1f or progress = 0f). We await() for
299         // offsetAnimationRunnable to be completed and then run it.
300         val runAnimation = offsetAnimationRunnable.await() ?: return
301         runAnimation()
302     }
303 
304     /**
305      * The signed distance between [fromContent] and [toContent]. It is negative if [fromContent] is
306      * above or to the left of [toContent].
307      *
308      * Note that this distance can be equal to [DistanceUnspecified] during the first frame of a
309      * transition when the distance depends on the size or position of an element that is composed
310      * in the content we are going to.
311      */
distancenull312     fun distance(): Float = distance(this)
313 
314     fun isAnimatingOffset(): Boolean = offsetAnimation != null
315 
316     /** Get the [ContentKey] ([fromContent] or [toContent]) associated to the current [direction] */
317     fun contentByDirection(direction: Float): T {
318         require(direction != 0f) { "Cannot find a content in this direction: $direction" }
319         val isDirectionToContent = (isUpOrLeft && direction < 0) || (!isUpOrLeft && direction > 0)
320         return if (isDirectionToContent) {
321             toContent
322         } else {
323             fromContent
324         }
325     }
326 
327     /**
328      * Animate the offset to a [targetContent], using the [initialVelocity] and an optional [spec]
329      *
330      * @return the velocity consumed
331      */
animateOffsetnull332     suspend fun animateOffset(
333         initialVelocity: Float,
334         targetContent: T,
335         spec: AnimationSpec<Float>? = null,
336     ): Float {
337         check(!isAnimatingOffset()) { "SwipeAnimation.animateOffset() can only be called once" }
338 
339         val initialProgress = progress
340         // Skip the animation if we have already reached the target content and the overscroll does
341         // not animate anything.
342         val hasReachedTargetContent =
343             (targetContent == toContent && initialProgress >= 1f) ||
344                 (targetContent == fromContent && initialProgress <= 0f)
345         val skipAnimation =
346             hasReachedTargetContent && !contentTransition.isWithinProgressRange(initialProgress)
347 
348         val targetContent =
349             if (targetContent != currentContent && !canChangeContent(targetContent)) {
350                 currentContent
351             } else {
352                 targetContent
353             }
354 
355         val targetOffset =
356             if (targetContent == fromContent) {
357                 0f
358             } else {
359                 val distance = distance()
360                 check(distance != DistanceUnspecified) {
361                     "distance is equal to $DistanceUnspecified"
362                 }
363                 distance
364             }
365 
366         // If the effective current content changed, it should be reflected right now in the
367         // current state, even before the settle animation is ongoing. That way all the
368         // swipeables and back handlers will be refreshed and the user can for instance quickly
369         // swipe vertically from A => B then horizontally from B => C, or swipe from A => B then
370         // immediately go back B => A.
371         if (targetContent != currentContent) {
372             currentContent = targetContent
373         }
374 
375         val initialOffset =
376             if (contentTransition.previewTransformationSpec != null && targetContent == toContent) {
377                 0f
378             } else {
379                 dragOffset
380             }
381 
382         val animatable =
383             Animatable(initialOffset, OffsetVisibilityThreshold).also { offsetAnimation = it }
384 
385         check(isAnimatingOffset())
386 
387         // Note: we still create the animatable and set it on offsetAnimation even when
388         // skipAnimation is true, just so that isUserInputOngoing and isAnimatingOffset() are
389         // unchanged even despite this small skip-optimization (which is just an implementation
390         // detail).
391         if (skipAnimation) {
392             // Unblock the job.
393             offsetAnimationRunnable.complete(null)
394             return 0f
395         }
396 
397         val isTargetGreater = targetOffset > animatable.value
398         val startedWhenOvercrollingTargetContent =
399             if (targetContent == fromContent) initialProgress < 0f else initialProgress > 1f
400 
401         val swipeSpec =
402             spec
403                 ?: contentTransition.transformationSpec.swipeSpec
404                 ?: layoutState.transitions.defaultSwipeSpec
405 
406         val velocityConsumed = CompletableDeferred<Float>()
407 
408         offsetAnimationRunnable.complete {
409             try {
410                 animatable.animateTo(
411                     targetValue = targetOffset,
412                     animationSpec = swipeSpec,
413                     initialVelocity = initialVelocity,
414                 ) {
415                     if (bouncingContent == null) {
416                         val isBouncing =
417                             if (isTargetGreater) {
418                                 if (startedWhenOvercrollingTargetContent) {
419                                     value >= targetOffset
420                                 } else {
421                                     value > targetOffset
422                                 }
423                             } else {
424                                 if (startedWhenOvercrollingTargetContent) {
425                                     value <= targetOffset
426                                 } else {
427                                     value < targetOffset
428                                 }
429                             }
430 
431                         if (isBouncing) {
432                             bouncingContent = targetContent
433 
434                             // Immediately stop this transition if we are bouncing on a content that
435                             // does not bounce.
436                             if (!contentTransition.isWithinProgressRange(progress)) {
437                                 // We are no longer able to consume the velocity, the rest can be
438                                 // consumed by another component in the hierarchy.
439                                 velocityConsumed.complete(initialVelocity - velocity)
440                                 throw SnapException()
441                             }
442                         }
443                     }
444                 }
445             } catch (_: SnapException) {
446                 /* Ignore. */
447             } finally {
448                 if (!velocityConsumed.isCompleted) {
449                     // The animation consumed the whole available velocity
450                     velocityConsumed.complete(initialVelocity)
451                 }
452             }
453         }
454 
455         return velocityConsumed.await()
456     }
457 
458     /** An exception thrown during the animation to stop it immediately. */
459     private class SnapException : Exception()
460 
canChangeContentnull461     private fun canChangeContent(targetContent: ContentKey): Boolean {
462         return when (val transition = contentTransition) {
463             is TransitionState.Transition.ChangeScene ->
464                 layoutState.canChangeScene(targetContent as SceneKey)
465             is TransitionState.Transition.ShowOrHideOverlay -> {
466                 if (targetContent == transition.overlay) {
467                     layoutState.canShowOverlay(transition.overlay)
468                 } else {
469                     layoutState.canHideOverlay(transition.overlay)
470                 }
471             }
472             is TransitionState.Transition.ReplaceOverlay -> {
473                 val to = targetContent as OverlayKey
474                 val from =
475                     if (to == transition.toOverlay) transition.fromOverlay else transition.toOverlay
476                 layoutState.canReplaceOverlay(from, to)
477             }
478         }
479     }
480 
freezeAndAnimateToCurrentStatenull481     fun freezeAndAnimateToCurrentState() {
482         if (isAnimatingOffset()) return
483 
484         contentTransition.coroutineScope.launch {
485             animateOffset(initialVelocity = 0f, targetContent = currentContent)
486         }
487     }
488 }
489 
490 private object DefaultSwipeDistance : UserActionDistance {
absoluteDistancenull491     override fun UserActionDistanceScope.absoluteDistance(
492         fromContent: ContentKey,
493         toContent: ContentKey,
494         orientation: Orientation,
495     ): Float {
496         val fromContentSize = checkNotNull(fromContent.targetSize())
497         return when (orientation) {
498             Orientation.Horizontal -> fromContentSize.width
499             Orientation.Vertical -> fromContentSize.height
500         }.toFloat()
501     }
502 }
503 
504 private class ChangeSceneSwipeTransition(
505     val layoutState: MutableSceneTransitionLayoutStateImpl,
506     val swipeAnimation: SwipeAnimation<SceneKey>,
507     override val key: TransitionKey?,
508     replacedTransition: ChangeSceneSwipeTransition?,
509 ) :
510     TransitionState.Transition.ChangeScene(
511         swipeAnimation.fromContent,
512         swipeAnimation.toContent,
513         replacedTransition,
514     ),
515     TransitionState.HasOverscrollProperties by swipeAnimation {
516 
517     constructor(
518         other: ChangeSceneSwipeTransition
519     ) : this(
520         layoutState = other.layoutState,
521         swipeAnimation = SwipeAnimation(other.swipeAnimation),
522         key = other.key,
523         replacedTransition = other,
524     )
525 
526     init {
527         swipeAnimation.contentTransition = this
528     }
529 
530     override val currentScene: SceneKey
531         get() = swipeAnimation.currentContent
532 
533     override val progress: Float
534         get() = swipeAnimation.progress
535 
536     override val progressVelocity: Float
537         get() = swipeAnimation.progressVelocity
538 
539     override val previewProgress: Float
540         get() = swipeAnimation.previewProgress
541 
542     override val previewProgressVelocity: Float
543         get() = swipeAnimation.previewProgressVelocity
544 
545     override val isInPreviewStage: Boolean
546         get() = swipeAnimation.isInPreviewStage
547 
548     override val isInitiatedByUserInput: Boolean = true
549 
550     override val isUserInputOngoing: Boolean
551         get() = swipeAnimation.isUserInputOngoing
552 
runnull553     override suspend fun run() {
554         swipeAnimation.run()
555     }
556 
freezeAndAnimateToCurrentStatenull557     override fun freezeAndAnimateToCurrentState() {
558         swipeAnimation.freezeAndAnimateToCurrentState()
559     }
560 }
561 
562 private class ShowOrHideOverlaySwipeTransition(
563     val layoutState: MutableSceneTransitionLayoutStateImpl,
564     val swipeAnimation: SwipeAnimation<ContentKey>,
565     overlay: OverlayKey,
566     fromOrToScene: SceneKey,
567     override val key: TransitionKey?,
568     replacedTransition: ShowOrHideOverlaySwipeTransition?,
569 ) :
570     TransitionState.Transition.ShowOrHideOverlay(
571         overlay,
572         fromOrToScene,
573         swipeAnimation.fromContent,
574         swipeAnimation.toContent,
575         replacedTransition,
576     ),
577     TransitionState.HasOverscrollProperties by swipeAnimation {
578     constructor(
579         other: ShowOrHideOverlaySwipeTransition
580     ) : this(
581         layoutState = other.layoutState,
582         swipeAnimation = SwipeAnimation(other.swipeAnimation),
583         overlay = other.overlay,
584         fromOrToScene = other.fromOrToScene,
585         key = other.key,
586         replacedTransition = other,
587     )
588 
589     init {
590         swipeAnimation.contentTransition = this
591     }
592 
593     override val isEffectivelyShown: Boolean
594         get() = swipeAnimation.currentContent == overlay
595 
596     override val progress: Float
597         get() = swipeAnimation.progress
598 
599     override val progressVelocity: Float
600         get() = swipeAnimation.progressVelocity
601 
602     override val previewProgress: Float
603         get() = swipeAnimation.previewProgress
604 
605     override val previewProgressVelocity: Float
606         get() = swipeAnimation.previewProgressVelocity
607 
608     override val isInPreviewStage: Boolean
609         get() = swipeAnimation.isInPreviewStage
610 
611     override val isInitiatedByUserInput: Boolean = true
612 
613     override val isUserInputOngoing: Boolean
614         get() = swipeAnimation.isUserInputOngoing
615 
runnull616     override suspend fun run() {
617         swipeAnimation.run()
618     }
619 
freezeAndAnimateToCurrentStatenull620     override fun freezeAndAnimateToCurrentState() {
621         swipeAnimation.freezeAndAnimateToCurrentState()
622     }
623 }
624 
625 private class ReplaceOverlaySwipeTransition(
626     val layoutState: MutableSceneTransitionLayoutStateImpl,
627     val swipeAnimation: SwipeAnimation<OverlayKey>,
628     override val key: TransitionKey?,
629     replacedTransition: ReplaceOverlaySwipeTransition?,
630 ) :
631     TransitionState.Transition.ReplaceOverlay(
632         swipeAnimation.fromContent,
633         swipeAnimation.toContent,
634         replacedTransition,
635     ),
636     TransitionState.HasOverscrollProperties by swipeAnimation {
637     constructor(
638         other: ReplaceOverlaySwipeTransition
639     ) : this(
640         layoutState = other.layoutState,
641         swipeAnimation = SwipeAnimation(other.swipeAnimation),
642         key = other.key,
643         replacedTransition = other,
644     )
645 
646     init {
647         swipeAnimation.contentTransition = this
648     }
649 
650     override val effectivelyShownOverlay: OverlayKey
651         get() = swipeAnimation.currentContent
652 
653     override val progress: Float
654         get() = swipeAnimation.progress
655 
656     override val progressVelocity: Float
657         get() = swipeAnimation.progressVelocity
658 
659     override val previewProgress: Float
660         get() = swipeAnimation.previewProgress
661 
662     override val previewProgressVelocity: Float
663         get() = swipeAnimation.previewProgressVelocity
664 
665     override val isInPreviewStage: Boolean
666         get() = swipeAnimation.isInPreviewStage
667 
668     override val isInitiatedByUserInput: Boolean = true
669 
670     override val isUserInputOngoing: Boolean
671         get() = swipeAnimation.isUserInputOngoing
672 
runnull673     override suspend fun run() {
674         swipeAnimation.run()
675     }
676 
freezeAndAnimateToCurrentStatenull677     override fun freezeAndAnimateToCurrentState() {
678         swipeAnimation.freezeAndAnimateToCurrentState()
679     }
680 }
681