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.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]. */
152 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]. */
160 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]. */
168 private fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean {
169     return upOrLeft(orientation) != null || downOrRight(orientation) != null
170 }
171 
172 private fun Orientation.opposite(): Orientation {
173     return when (this) {
174         Orientation.Vertical -> Orientation.Horizontal
175         Orientation.Horizontal -> Orientation.Vertical
176     }
177 }
178 
179 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 
226 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  */
274 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 
302 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  */
382 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