1 /*
2  * Copyright 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.compose.animation.scene
18 
19 import androidx.compose.animation.core.Animatable
20 import androidx.compose.animation.core.AnimationVector1D
21 import androidx.compose.animation.core.SpringSpec
22 import kotlin.math.absoluteValue
23 import kotlinx.coroutines.CoroutineScope
24 import kotlinx.coroutines.CoroutineStart
25 import kotlinx.coroutines.Job
26 import kotlinx.coroutines.launch
27 
28 /**
29  * Transition to [target] using a canned animation. This function will try to be smart and take over
30  * the currently running transition, if there is one.
31  */
animateToScenenull32 internal fun CoroutineScope.animateToScene(
33     layoutState: BaseSceneTransitionLayoutState,
34     target: SceneKey,
35     transitionKey: TransitionKey?,
36 ): TransitionState.Transition? {
37     val transitionState = layoutState.transitionState
38     if (transitionState.currentScene == target) {
39         // This can happen in 3 different situations, for which there isn't anything else to do:
40         //  1. There is no ongoing transition and [target] is already the current scene.
41         //  2. The user is swiping to [target] from another scene and released their pointer such
42         //     that the gesture was committed and the transition is animating to [scene] already.
43         //  3. The user is swiping from [target] to another scene and either:
44         //     a. didn't release their pointer yet.
45         //     b. released their pointer such that the swipe gesture was cancelled and the
46         //        transition is currently animating back to [target].
47         return null
48     }
49 
50     return when (transitionState) {
51         is TransitionState.Idle ->
52             animate(layoutState, target, transitionKey, isInitiatedByUserInput = false)
53         is TransitionState.Transition -> {
54             val isInitiatedByUserInput = transitionState.isInitiatedByUserInput
55 
56             // A transition is currently running: first check whether `transition.toScene` or
57             // `transition.fromScene` is the same as our target scene, in which case the transition
58             // can be accelerated or reversed to end up in the target state.
59 
60             if (transitionState.toScene == target) {
61                 // The user is currently swiping to [target] but didn't release their pointer yet:
62                 // animate the progress to `1`.
63 
64                 check(transitionState.fromScene == transitionState.currentScene)
65                 val progress = transitionState.progress
66                 if ((1f - progress).absoluteValue < ProgressVisibilityThreshold) {
67                     // The transition is already finished (progress ~= 1): no need to animate. We
68                     // finish the current transition early to make sure that the current state
69                     // change is committed.
70                     layoutState.finishTransition(transitionState, target)
71                     null
72                 } else {
73                     // The transition is in progress: start the canned animation at the same
74                     // progress as it was in.
75                     animate(
76                         layoutState,
77                         target,
78                         transitionKey,
79                         isInitiatedByUserInput,
80                         initialProgress = progress,
81                         initialVelocity = transitionState.progressVelocity,
82                     )
83                 }
84             } else if (transitionState.fromScene == target) {
85                 // There is a transition from [target] to another scene: simply animate the same
86                 // transition progress to `0`.
87                 check(transitionState.toScene == transitionState.currentScene)
88 
89                 val progress = transitionState.progress
90                 if (progress.absoluteValue < ProgressVisibilityThreshold) {
91                     // The transition is at progress ~= 0: no need to animate.We finish the current
92                     // transition early to make sure that the current state change is committed.
93                     layoutState.finishTransition(transitionState, target)
94                     null
95                 } else {
96                     animate(
97                         layoutState,
98                         target,
99                         transitionKey,
100                         isInitiatedByUserInput,
101                         initialProgress = progress,
102                         initialVelocity = transitionState.progressVelocity,
103                         reversed = true,
104                     )
105                 }
106             } else {
107                 // Generic interruption; the current transition is neither from or to [target].
108                 val interruptionResult =
109                     layoutState.transitions.interruptionHandler.onInterruption(
110                         transitionState,
111                         target,
112                     ) ?: DefaultInterruptionHandler.onInterruption(transitionState, target)
113 
114                 val animateFrom = interruptionResult.animateFrom
115                 if (
116                     animateFrom != transitionState.toScene &&
117                         animateFrom != transitionState.fromScene
118                 ) {
119                     error(
120                         "InterruptionResult.animateFrom must be either the fromScene " +
121                             "(${transitionState.fromScene.debugName}) or the toScene " +
122                             "(${transitionState.toScene.debugName}) of the interrupted transition."
123                     )
124                 }
125 
126                 // If we were A => B and that we are now animating A => C, add a transition B => A
127                 // to the list of transitions so that B "disappears back to A".
128                 val chain = interruptionResult.chain
129                 if (chain && animateFrom != transitionState.currentScene) {
130                     animateToScene(layoutState, animateFrom, transitionKey = null)
131                 }
132 
133                 animate(
134                     layoutState,
135                     target,
136                     transitionKey,
137                     isInitiatedByUserInput,
138                     fromScene = animateFrom,
139                     chain = chain,
140                 )
141             }
142         }
143     }
144 }
145 
CoroutineScopenull146 private fun CoroutineScope.animate(
147     layoutState: BaseSceneTransitionLayoutState,
148     targetScene: SceneKey,
149     transitionKey: TransitionKey?,
150     isInitiatedByUserInput: Boolean,
151     initialProgress: Float = 0f,
152     initialVelocity: Float = 0f,
153     reversed: Boolean = false,
154     fromScene: SceneKey = layoutState.transitionState.currentScene,
155     chain: Boolean = true,
156 ): TransitionState.Transition {
157     val targetProgress = if (reversed) 0f else 1f
158     val transition =
159         if (reversed) {
160             OneOffTransition(
161                 key = transitionKey,
162                 fromScene = targetScene,
163                 toScene = fromScene,
164                 currentScene = targetScene,
165                 isInitiatedByUserInput = isInitiatedByUserInput,
166                 isUserInputOngoing = false,
167             )
168         } else {
169             OneOffTransition(
170                 key = transitionKey,
171                 fromScene = fromScene,
172                 toScene = targetScene,
173                 currentScene = targetScene,
174                 isInitiatedByUserInput = isInitiatedByUserInput,
175                 isUserInputOngoing = false,
176             )
177         }
178 
179     // Change the current layout state to start this new transition. This will compute the
180     // TransformationSpec associated to this transition, which we need to initialize the Animatable
181     // that will actually animate it.
182     layoutState.startTransition(transition, chain)
183 
184     // The transition now contains the transformation spec that we should use to instantiate the
185     // Animatable.
186     val animationSpec = transition.transformationSpec.progressSpec
187     val visibilityThreshold =
188         (animationSpec as? SpringSpec)?.visibilityThreshold ?: ProgressVisibilityThreshold
189     val animatable =
190         Animatable(initialProgress, visibilityThreshold = visibilityThreshold).also {
191             transition.animatable = it
192         }
193 
194     // Animate the progress to its target value.
195     // Important: We start atomically to make sure that we start the coroutine even if it is
196     // cancelled right after it is launched, so that finishTransition() is correctly called.
197     // Otherwise, this transition will never be stopped and we will never settle to Idle.
198     transition.job =
199         launch(start = CoroutineStart.ATOMIC) {
200             try {
201                 animatable.animateTo(targetProgress, animationSpec, initialVelocity)
202             } finally {
203                 layoutState.finishTransition(transition, targetScene)
204             }
205         }
206 
207     return transition
208 }
209 
210 private class OneOffTransition(
211     override val key: TransitionKey?,
212     fromScene: SceneKey,
213     toScene: SceneKey,
214     override val currentScene: SceneKey,
215     override val isInitiatedByUserInput: Boolean,
216     override val isUserInputOngoing: Boolean,
217 ) : TransitionState.Transition(fromScene, toScene) {
218     /**
219      * The animatable used to animate this transition.
220      *
221      * Note: This is lateinit because we need to first create this Transition object so that
222      * [SceneTransitionLayoutState] can compute the transformations and animation spec associated to
223      * it, which is need to initialize this Animatable.
224      */
225     lateinit var animatable: Animatable<Float, AnimationVector1D>
226 
227     /** The job that is animating [animatable]. */
228     lateinit var job: Job
229 
230     override val progress: Float
231         get() = animatable.value
232 
233     override val progressVelocity: Float
234         get() = animatable.velocity
235 
finishnull236     override fun finish(): Job = job
237 }
238 
239 // TODO(b/290184746): Compute a good default visibility threshold that depends on the layout size
240 // and screen density.
241 internal const val ProgressVisibilityThreshold = 1e-3f
242