1 /*
<lambda>null2 * 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.transition
18
19 import androidx.annotation.FloatRange
20 import androidx.compose.animation.core.AnimationSpec
21 import androidx.compose.foundation.gestures.Orientation
22 import androidx.compose.ui.util.fastCoerceIn
23 import com.android.compose.animation.scene.ContentKey
24 import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
25 import com.android.compose.animation.scene.MutableSceneTransitionLayoutStateImpl
26 import com.android.compose.animation.scene.OverlayKey
27 import com.android.compose.animation.scene.SceneKey
28 import com.android.compose.animation.scene.SwipeAnimation
29 import com.android.compose.animation.scene.TransitionKey
30 import com.android.compose.animation.scene.UserActionResult
31 import com.android.compose.animation.scene.createSwipeAnimation
32 import kotlin.coroutines.cancellation.CancellationException
33 import kotlinx.coroutines.CoroutineScope
34 import kotlinx.coroutines.Job
35 import kotlinx.coroutines.coroutineScope
36 import kotlinx.coroutines.flow.Flow
37 import kotlinx.coroutines.flow.collectLatest
38 import kotlinx.coroutines.launch
39
40 /**
41 * Seek to the given [scene] using [progress].
42 *
43 * This will start a transition from the
44 * [current scene][MutableSceneTransitionLayoutState.currentScene] to [scene], driven by the
45 * progress in [progress]. Once [progress] stops emitting, we will animate progress to 1f (using
46 * [animationSpec]) if it stopped normally or to 0f if it stopped with a
47 * [kotlin.coroutines.cancellation.CancellationException].
48 */
49 suspend fun MutableSceneTransitionLayoutState.seekToScene(
50 scene: SceneKey,
51 @FloatRange(0.0, 1.0) progress: Flow<Float>,
52 transitionKey: TransitionKey? = null,
53 animationSpec: AnimationSpec<Float>? = null,
54 ) {
55 require(scene != currentScene) {
56 "seekToScene($scene) has to be called with a different scene than the current scene"
57 }
58
59 seek(UserActionResult.ChangeScene(scene, transitionKey), progress, animationSpec)
60 }
61
62 /**
63 * Seek to show the given [overlay] using [progress].
64 *
65 * This will start a transition to show [overlay] from the
66 * [current scene][MutableSceneTransitionLayoutState.currentScene], driven by the progress in
67 * [progress]. Once [progress] stops emitting, we will animate progress to 1f (using
68 * [animationSpec]) if it stopped normally or to 0f if it stopped with a
69 * [kotlin.coroutines.cancellation.CancellationException].
70 */
seekToShowOverlaynull71 suspend fun MutableSceneTransitionLayoutState.seekToShowOverlay(
72 overlay: OverlayKey,
73 @FloatRange(0.0, 1.0) progress: Flow<Float>,
74 transitionKey: TransitionKey? = null,
75 animationSpec: AnimationSpec<Float>? = null,
76 ) {
77 require(overlay in currentOverlays) {
78 "seekToShowOverlay($overlay) can be called only when the overlay is in currentOverlays"
79 }
80
81 seek(UserActionResult.ShowOverlay(overlay, transitionKey), progress, animationSpec)
82 }
83
84 /**
85 * Seek to hide the given [overlay] using [progress].
86 *
87 * This will start a transition to hide [overlay] to the
88 * [current scene][MutableSceneTransitionLayoutState.currentScene], driven by the progress in
89 * [progress]. Once [progress] stops emitting, we will animate progress to 1f (using
90 * [animationSpec]) if it stopped normally or to 0f if it stopped with a
91 * [kotlin.coroutines.cancellation.CancellationException].
92 */
seekToHideOverlaynull93 suspend fun MutableSceneTransitionLayoutState.seekToHideOverlay(
94 overlay: OverlayKey,
95 @FloatRange(0.0, 1.0) progress: Flow<Float>,
96 transitionKey: TransitionKey? = null,
97 animationSpec: AnimationSpec<Float>? = null,
98 ) {
99 require(overlay !in currentOverlays) {
100 "seekToHideOverlay($overlay) can be called only when the overlay is *not* in " +
101 "currentOverlays"
102 }
103
104 seek(UserActionResult.HideOverlay(overlay, transitionKey), progress, animationSpec)
105 }
106
seeknull107 private suspend fun MutableSceneTransitionLayoutState.seek(
108 result: UserActionResult,
109 progress: Flow<Float>,
110 animationSpec: AnimationSpec<Float>?,
111 ) {
112 val layoutState =
113 when (this) {
114 is MutableSceneTransitionLayoutStateImpl -> this
115 }
116
117 val swipeAnimation =
118 createSwipeAnimation(
119 layoutState = layoutState,
120 result = result,
121
122 // We are animating progress, so distance is always 1f.
123 distance = 1f,
124
125 // The orientation and isUpOrLeft don't matter here given that they are only used during
126 // overscroll, which is disabled for progress-based transitions.
127 orientation = Orientation.Horizontal,
128 isUpOrLeft = false,
129 )
130
131 animateProgress(
132 state = layoutState,
133 animation = swipeAnimation,
134 progress = progress,
135 commitSpec = animationSpec,
136 cancelSpec = animationSpec,
137 )
138 }
139
animateProgressnull140 internal suspend fun <T : ContentKey> animateProgress(
141 state: MutableSceneTransitionLayoutStateImpl,
142 animation: SwipeAnimation<T>,
143 progress: Flow<Float>,
144 commitSpec: AnimationSpec<Float>?,
145 cancelSpec: AnimationSpec<Float>?,
146 animationScope: CoroutineScope? = null,
147 ) {
148 suspend fun animateOffset(targetContent: T, spec: AnimationSpec<Float>?) {
149 if (state.transitionState != animation.contentTransition || animation.isAnimatingOffset()) {
150 return
151 }
152
153 animation.animateOffset(
154 initialVelocity = 0f,
155 targetContent = targetContent,
156
157 // Important: we have to specify a spec that correctly animates *progress* (low
158 // visibility threshold) and not *offset* (higher visibility threshold).
159 spec = spec ?: animation.contentTransition.transformationSpec.progressSpec,
160 )
161 }
162
163 coroutineScope {
164 val collectionJob = launch {
165 try {
166 progress.collectLatest { progress ->
167 // Progress based animation should never overscroll given that the
168 // absoluteDistance exposed to overscroll builders is always 1f and will not
169 // lead to any noticeable transformation.
170 animation.dragOffset = progress.fastCoerceIn(0f, 1f)
171 }
172
173 // Transition committed.
174 animateOffset(animation.toContent, commitSpec)
175 } catch (e: CancellationException) {
176 // Transition cancelled.
177 animateOffset(animation.fromContent, cancelSpec)
178 }
179 }
180
181 // Start the transition.
182 animationScope?.launch { startTransition(state, animation, collectionJob) }
183 ?: startTransition(state, animation, collectionJob)
184 }
185 }
186
startTransitionnull187 private suspend fun <T : ContentKey> startTransition(
188 state: MutableSceneTransitionLayoutStateImpl,
189 animation: SwipeAnimation<T>,
190 progressCollectionJob: Job,
191 ) {
192 state.startTransition(animation.contentTransition)
193 // The transition is done. Cancel the collection in case the transition was finished
194 // because it was interrupted by another transition.
195 if (progressCollectionJob.isActive) {
196 progressCollectionJob.cancel()
197 }
198 }
199