1 /*
<lambda>null2 * 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.Spring
21 import androidx.compose.animation.core.spring
22 import androidx.compose.foundation.gestures.Orientation
23 import androidx.compose.foundation.gestures.draggable
24 import androidx.compose.foundation.gestures.rememberDraggableState
25 import androidx.compose.runtime.Composable
26 import androidx.compose.runtime.getValue
27 import androidx.compose.runtime.mutableFloatStateOf
28 import androidx.compose.runtime.mutableStateOf
29 import androidx.compose.runtime.remember
30 import androidx.compose.runtime.setValue
31 import androidx.compose.ui.Modifier
32 import androidx.compose.ui.platform.LocalDensity
33 import androidx.compose.ui.unit.dp
34 import kotlin.math.absoluteValue
35 import kotlinx.coroutines.CoroutineScope
36 import kotlinx.coroutines.Job
37 import kotlinx.coroutines.launch
38
39 /**
40 * Configures the swipeable behavior of a [SceneTransitionLayout] depending on the current state.
41 */
42 @Composable
43 internal fun Modifier.swipeToScene(
44 layoutImpl: SceneTransitionLayoutImpl,
45 orientation: Orientation,
46 ): Modifier {
47 val state = layoutImpl.state.transitionState
48 val currentScene = layoutImpl.scene(state.currentScene)
49 val transition = remember {
50 // Note that the currentScene here does not matter, it's only used for initializing the
51 // transition and will be replaced when a drag event starts.
52 SwipeTransition(initialScene = currentScene)
53 }
54
55 val enabled = state == transition || currentScene.shouldEnableSwipes(orientation)
56
57 // Immediately start the drag if this our [transition] is currently animating to a scene (i.e.
58 // the user released their input pointer after swiping in this orientation) and the user can't
59 // swipe in the other direction.
60 val startDragImmediately =
61 state == transition &&
62 transition.isAnimatingOffset &&
63 !currentScene.shouldEnableSwipes(orientation.opposite())
64
65 // The velocity threshold at which the intent of the user is to swipe up or down. It is the same
66 // as SwipeableV2Defaults.VelocityThreshold.
67 val velocityThreshold = with(LocalDensity.current) { 125.dp.toPx() }
68
69 // The positional threshold at which the intent of the user is to swipe to the next scene. It is
70 // the same as SwipeableV2Defaults.PositionalThreshold.
71 val positionalThreshold = with(LocalDensity.current) { 56.dp.toPx() }
72
73 return draggable(
74 orientation = orientation,
75 enabled = enabled,
76 startDragImmediately = startDragImmediately,
77 onDragStarted = { onDragStarted(layoutImpl, transition, orientation) },
78 state =
79 rememberDraggableState { delta -> onDrag(layoutImpl, transition, orientation, delta) },
80 onDragStopped = { velocity ->
81 onDragStopped(
82 layoutImpl,
83 transition,
84 velocity,
85 velocityThreshold,
86 positionalThreshold,
87 )
88 },
89 )
90 }
91
92 private class SwipeTransition(initialScene: Scene) : TransitionState.Transition {
93 var _currentScene by mutableStateOf(initialScene)
94 override val currentScene: SceneKey
95 get() = _currentScene.key
96
97 var _fromScene by mutableStateOf(initialScene)
98 override val fromScene: SceneKey
99 get() = _fromScene.key
100
101 var _toScene by mutableStateOf(initialScene)
102 override val toScene: SceneKey
103 get() = _toScene.key
104
105 override val progress: Float
106 get() {
107 val offset = if (isAnimatingOffset) offsetAnimatable.value else dragOffset
108 if (distance == 0f) {
109 // This can happen only if fromScene == toScene.
110 error(
111 "Transition.progress should be called only when Transition.fromScene != " +
112 "Transition.toScene"
113 )
114 }
115 return offset / distance
116 }
117
118 /** The current offset caused by the drag gesture. */
119 var dragOffset by mutableFloatStateOf(0f)
120
121 /**
122 * Whether the offset is animated (the user lifted their finger) or if it is driven by gesture.
123 */
124 var isAnimatingOffset by mutableStateOf(false)
125
126 /** The animatable used to animate the offset once the user lifted its finger. */
127 val offsetAnimatable = Animatable(0f, visibilityThreshold = OffsetVisibilityThreshold)
128
129 /**
130 * The job currently animating [offsetAnimatable], if it is animating. Note that setting this to
131 * a new job will automatically cancel the previous one.
132 */
133 var offsetAnimationJob: Job? = null
134 set(value) {
135 field?.cancel()
136 field = value
137 }
138
139 /** The absolute distance between [fromScene] and [toScene]. */
140 var absoluteDistance = 0f
141
142 /**
143 * The signed distance between [fromScene] and [toScene]. It is negative if [fromScene] is above
144 * or to the left of [toScene].
145 */
146 var _distance by mutableFloatStateOf(0f)
147 val distance: Float
148 get() = _distance
149 }
150
151 /** The destination scene when swiping up or left from [this@upOrLeft]. */
upOrLeftnull152 private fun Scene.upOrLeft(orientation: Orientation): SceneKey? {
153 return when (orientation) {
154 Orientation.Vertical -> userActions[Swipe.Up]
155 Orientation.Horizontal -> userActions[Swipe.Left]
156 }
157 }
158
159 /** The destination scene when swiping down or right from [this@downOrRight]. */
Scenenull160 private fun Scene.downOrRight(orientation: Orientation): SceneKey? {
161 return when (orientation) {
162 Orientation.Vertical -> userActions[Swipe.Down]
163 Orientation.Horizontal -> userActions[Swipe.Right]
164 }
165 }
166
167 /** Whether swipe should be enabled in the given [orientation]. */
Scenenull168 private fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean {
169 return upOrLeft(orientation) != null || downOrRight(orientation) != null
170 }
171
oppositenull172 private fun Orientation.opposite(): Orientation {
173 return when (this) {
174 Orientation.Vertical -> Orientation.Horizontal
175 Orientation.Horizontal -> Orientation.Vertical
176 }
177 }
178
onDragStartednull179 private fun onDragStarted(
180 layoutImpl: SceneTransitionLayoutImpl,
181 transition: SwipeTransition,
182 orientation: Orientation,
183 ) {
184 if (layoutImpl.state.transitionState == transition) {
185 // This [transition] was already driving the animation: simply take over it.
186 if (transition.isAnimatingOffset) {
187 // Stop animating and start from where the current offset. Setting the animation job to
188 // `null` will effectively cancel the animation.
189 transition.isAnimatingOffset = false
190 transition.offsetAnimationJob = null
191 transition.dragOffset = transition.offsetAnimatable.value
192 }
193
194 return
195 }
196
197 // TODO(b/290184746): Better handle interruptions here if state != idle.
198
199 val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
200
201 transition._currentScene = fromScene
202 transition._fromScene = fromScene
203
204 // We don't know where we are transitioning to yet given that the drag just started, so set it
205 // to fromScene, which will effectively be treated the same as Idle(fromScene).
206 transition._toScene = fromScene
207
208 transition.dragOffset = 0f
209 transition.isAnimatingOffset = false
210 transition.offsetAnimationJob = null
211
212 // Use the layout size in the swipe orientation for swipe distance.
213 // TODO(b/290184746): Also handle custom distances for transitions. With smaller distances, we
214 // will also have to make sure that we correctly handle overscroll.
215 transition.absoluteDistance =
216 when (orientation) {
217 Orientation.Horizontal -> layoutImpl.size.width
218 Orientation.Vertical -> layoutImpl.size.height
219 }.toFloat()
220
221 if (transition.absoluteDistance > 0f) {
222 layoutImpl.state.transitionState = transition
223 }
224 }
225
onDragnull226 private fun onDrag(
227 layoutImpl: SceneTransitionLayoutImpl,
228 transition: SwipeTransition,
229 orientation: Orientation,
230 delta: Float,
231 ) {
232 transition.dragOffset += delta
233
234 // First check transition.fromScene should be changed for the case where the user quickly swiped
235 // twice in a row to accelerate the transition and go from A => B then B => C really fast.
236 maybeHandleAcceleratedSwipe(transition, orientation)
237
238 val fromScene = transition._fromScene
239 val upOrLeft = fromScene.upOrLeft(orientation)
240 val downOrRight = fromScene.downOrRight(orientation)
241 val offset = transition.dragOffset
242
243 // Compute the target scene depending on the current offset.
244 val targetSceneKey: SceneKey
245 val signedDistance: Float
246 when {
247 offset < 0f && upOrLeft != null -> {
248 targetSceneKey = upOrLeft
249 signedDistance = -transition.absoluteDistance
250 }
251 offset > 0f && downOrRight != null -> {
252 targetSceneKey = downOrRight
253 signedDistance = transition.absoluteDistance
254 }
255 else -> {
256 targetSceneKey = fromScene.key
257 signedDistance = 0f
258 }
259 }
260
261 if (transition._toScene.key != targetSceneKey) {
262 transition._toScene = layoutImpl.scenes.getValue(targetSceneKey)
263 }
264
265 if (transition._distance != signedDistance) {
266 transition._distance = signedDistance
267 }
268 }
269
270 /**
271 * Change fromScene in the case where the user quickly swiped multiple times in the same direction
272 * to accelerate the transition from A => B then B => C.
273 */
maybeHandleAcceleratedSwipenull274 private fun maybeHandleAcceleratedSwipe(
275 transition: SwipeTransition,
276 orientation: Orientation,
277 ) {
278 val toScene = transition._toScene
279 val fromScene = transition._fromScene
280
281 // If the swipe was not committed, don't do anything.
282 if (fromScene == toScene || transition._currentScene != toScene) {
283 return
284 }
285
286 // If the offset is past the distance then let's change fromScene so that the user can swipe to
287 // the next screen or go back to the previous one.
288 val offset = transition.dragOffset
289 val absoluteDistance = transition.absoluteDistance
290 if (offset <= -absoluteDistance && fromScene.upOrLeft(orientation) == toScene.key) {
291 transition.dragOffset += absoluteDistance
292 transition._fromScene = toScene
293 } else if (offset >= absoluteDistance && fromScene.downOrRight(orientation) == toScene.key) {
294 transition.dragOffset -= absoluteDistance
295 transition._fromScene = toScene
296 }
297
298 // Important note: toScene and distance will be updated right after this function is called,
299 // using fromScene and dragOffset.
300 }
301
onDragStoppednull302 private fun CoroutineScope.onDragStopped(
303 layoutImpl: SceneTransitionLayoutImpl,
304 transition: SwipeTransition,
305 velocity: Float,
306 velocityThreshold: Float,
307 positionalThreshold: Float,
308 ) {
309 // The state was changed since the drag started; don't do anything.
310 if (layoutImpl.state.transitionState != transition) {
311 return
312 }
313
314 // We were not animating.
315 if (transition._fromScene == transition._toScene) {
316 layoutImpl.state.transitionState = TransitionState.Idle(transition._fromScene.key)
317 return
318 }
319
320 // Compute the destination scene (and therefore offset) to settle in.
321 val targetScene: Scene
322 val targetOffset: Float
323 val offset = transition.dragOffset
324 val distance = transition.distance
325 if (
326 shouldCommitSwipe(
327 offset,
328 distance,
329 velocity,
330 velocityThreshold,
331 positionalThreshold,
332 wasCommitted = transition._currentScene == transition._toScene,
333 )
334 ) {
335 targetOffset = distance
336 targetScene = transition._toScene
337 } else {
338 targetOffset = 0f
339 targetScene = transition._fromScene
340 }
341
342 // If the effective current scene changed, it should be reflected right now in the current scene
343 // state, even before the settle animation is ongoing. That way all the swipeables and back
344 // handlers will be refreshed and the user can for instance quickly swipe vertically from A => B
345 // then horizontally from B => C, or swipe from A => B then immediately go back B => A.
346 if (targetScene != transition._currentScene) {
347 transition._currentScene = targetScene
348 layoutImpl.onChangeScene(targetScene.key)
349 }
350
351 // Animate the offset.
352 transition.offsetAnimationJob = launch {
353 transition.offsetAnimatable.snapTo(offset)
354 transition.isAnimatingOffset = true
355
356 transition.offsetAnimatable.animateTo(
357 targetOffset,
358 // TODO(b/290184746): Make this spring spec configurable.
359 spring(
360 stiffness = Spring.StiffnessMediumLow,
361 visibilityThreshold = OffsetVisibilityThreshold
362 ),
363 initialVelocity = velocity,
364 )
365
366 // Now that the animation is done, the state should be idle. Note that if the state was
367 // changed since this animation started, some external code changed it and we shouldn't do
368 // anything here. Note also that this job will be cancelled in the case where the user
369 // intercepts this swipe.
370 if (layoutImpl.state.transitionState == transition) {
371 layoutImpl.state.transitionState = TransitionState.Idle(targetScene.key)
372 }
373
374 transition.offsetAnimationJob = null
375 }
376 }
377
378 /**
379 * Whether the swipe to the target scene should be committed or not. This is inspired by
380 * SwipeableV2.computeTarget().
381 */
shouldCommitSwipenull382 private fun shouldCommitSwipe(
383 offset: Float,
384 distance: Float,
385 velocity: Float,
386 velocityThreshold: Float,
387 positionalThreshold: Float,
388 wasCommitted: Boolean,
389 ): Boolean {
390 fun isCloserToTarget(): Boolean {
391 return (offset - distance).absoluteValue < offset.absoluteValue
392 }
393
394 // Swiping up or left.
395 if (distance < 0f) {
396 return if (offset > 0f || velocity >= velocityThreshold) {
397 false
398 } else {
399 velocity <= -velocityThreshold ||
400 (offset <= -positionalThreshold && !wasCommitted) ||
401 isCloserToTarget()
402 }
403 }
404
405 // Swiping down or right.
406 return if (offset < 0f || velocity <= -velocityThreshold) {
407 false
408 } else {
409 velocity >= velocityThreshold ||
410 (offset >= positionalThreshold && !wasCommitted) ||
411 isCloserToTarget()
412 }
413 }
414
415 /**
416 * The number of pixels below which there won't be a visible difference in the transition and from
417 * which the animation can stop.
418 */
419 private const val OffsetVisibilityThreshold = 0.5f
420