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