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.launch
25
26 /**
27 * Transition to [target] using a canned animation. This function will try to be smart and take over
28 * the currently running transition, if there is one.
29 */
animateToScenenull30 internal fun CoroutineScope.animateToScene(
31 layoutImpl: SceneTransitionLayoutImpl,
32 target: SceneKey,
33 ) {
34 val state = layoutImpl.state.transitionState
35 if (state.currentScene == target) {
36 // This can happen in 3 different situations, for which there isn't anything else to do:
37 // 1. There is no ongoing transition and [target] is already the current scene.
38 // 2. The user is swiping to [target] from another scene and released their pointer such
39 // that the gesture was committed and the transition is animating to [scene] already.
40 // 3. The user is swiping from [target] to another scene and either:
41 // a. didn't release their pointer yet.
42 // b. released their pointer such that the swipe gesture was cancelled and the
43 // transition is currently animating back to [target].
44 return
45 }
46
47 when (state) {
48 is TransitionState.Idle -> animate(layoutImpl, target)
49 is TransitionState.Transition -> {
50 if (state.toScene == state.fromScene) {
51 // Same as idle.
52 animate(layoutImpl, target)
53 return
54 }
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 (state.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(state.fromScene == state.currentScene)
65 val progress = state.progress
66 if ((1f - progress).absoluteValue < ProgressVisibilityThreshold) {
67 // The transition is already finished (progress ~= 1): no need to animate.
68 layoutImpl.state.transitionState = TransitionState.Idle(state.currentScene)
69 } else {
70 // The transition is in progress: start the canned animation at the same
71 // progress as it was in.
72 // TODO(b/290184746): Also take the current velocity into account.
73 animate(layoutImpl, target, startProgress = progress)
74 }
75
76 return
77 }
78
79 if (state.fromScene == target) {
80 // There is a transition from [target] to another scene: simply animate the same
81 // transition progress to `0`.
82
83 check(state.toScene == state.currentScene)
84 val progress = state.progress
85 if (progress.absoluteValue < ProgressVisibilityThreshold) {
86 // The transition is at progress ~= 0: no need to animate.
87 layoutImpl.state.transitionState = TransitionState.Idle(state.currentScene)
88 } else {
89 // TODO(b/290184746): Also take the current velocity into account.
90 animate(layoutImpl, target, startProgress = progress, reversed = true)
91 }
92
93 return
94 }
95
96 // Generic interruption; the current transition is neither from or to [target].
97 // TODO(b/290930950): Better handle interruptions here.
98 animate(layoutImpl, target)
99 }
100 }
101 }
102
CoroutineScopenull103 private fun CoroutineScope.animate(
104 layoutImpl: SceneTransitionLayoutImpl,
105 target: SceneKey,
106 startProgress: Float = 0f,
107 reversed: Boolean = false,
108 ) {
109 val fromScene = layoutImpl.state.transitionState.currentScene
110
111 val animationSpec = layoutImpl.transitions.transitionSpec(fromScene, target).spec
112 val visibilityThreshold =
113 (animationSpec as? SpringSpec)?.visibilityThreshold ?: ProgressVisibilityThreshold
114 val animatable = Animatable(startProgress, visibilityThreshold = visibilityThreshold)
115
116 val targetProgress = if (reversed) 0f else 1f
117 val transition =
118 if (reversed) {
119 OneOffTransition(target, fromScene, currentScene = target, animatable)
120 } else {
121 OneOffTransition(fromScene, target, currentScene = target, animatable)
122 }
123
124 // Change the current layout state to use this new transition.
125 layoutImpl.state.transitionState = transition
126
127 // Animate the progress to its target value.
128 launch {
129 animatable.animateTo(targetProgress, animationSpec)
130
131 // Unless some other external state change happened, the state should now be idle.
132 if (layoutImpl.state.transitionState == transition) {
133 layoutImpl.state.transitionState = TransitionState.Idle(target)
134 }
135 }
136 }
137
138 private class OneOffTransition(
139 override val fromScene: SceneKey,
140 override val toScene: SceneKey,
141 override val currentScene: SceneKey,
142 private val animatable: Animatable<Float, AnimationVector1D>,
143 ) : TransitionState.Transition {
144 override val progress: Float
145 get() = animatable.value
146 }
147
148 // TODO(b/290184746): Compute a good default visibility threshold that depends on the layout size
149 // and screen density.
150 private const val ProgressVisibilityThreshold = 1e-3f
151