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.annotation.FloatRange
20 import androidx.compose.foundation.gestures.Orientation
21 import androidx.compose.runtime.Composable
22 import androidx.compose.runtime.SideEffect
23 import androidx.compose.runtime.Stable
24 import androidx.compose.runtime.remember
25 import androidx.compose.runtime.rememberCoroutineScope
26 import androidx.compose.ui.Alignment
27 import androidx.compose.ui.Modifier
28 import androidx.compose.ui.geometry.Offset
29 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
30 import androidx.compose.ui.platform.LocalDensity
31 import androidx.compose.ui.unit.Density
32 import androidx.compose.ui.unit.Dp
33 import androidx.compose.ui.unit.IntOffset
34 import androidx.compose.ui.unit.IntSize
35 
36 /**
37  * [SceneTransitionLayout] is a container that automatically animates its content whenever its state
38  * changes.
39  *
40  * Note: You should use [androidx.compose.animation.AnimatedContent] instead of
41  * [SceneTransitionLayout] if it fits your need. Use [SceneTransitionLayout] over AnimatedContent if
42  * you need support for swipe gestures, shared elements or transitions defined declaratively outside
43  * UI code.
44  *
45  * @param state the state of this layout.
46  * @param swipeSourceDetector the edge detector used to detect which edge a swipe is started from,
47  *   if any.
48  * @param transitionInterceptionThreshold used during a scene transition. For the scene to be
49  *   intercepted, the progress value must be above the threshold, and below (1 - threshold).
50  * @param scenes the configuration of the different scenes of this layout.
51  * @see updateSceneTransitionLayoutState
52  */
53 @Composable
SceneTransitionLayoutnull54 fun SceneTransitionLayout(
55     state: SceneTransitionLayoutState,
56     modifier: Modifier = Modifier,
57     swipeSourceDetector: SwipeSourceDetector = DefaultEdgeDetector,
58     swipeDetector: SwipeDetector = DefaultSwipeDetector,
59     @FloatRange(from = 0.0, to = 0.5) transitionInterceptionThreshold: Float = 0.05f,
60     scenes: SceneTransitionLayoutScope.() -> Unit,
61 ) {
62     SceneTransitionLayoutForTesting(
63         state,
64         modifier,
65         swipeSourceDetector,
66         swipeDetector,
67         transitionInterceptionThreshold,
68         onLayoutImpl = null,
69         scenes,
70     )
71 }
72 
73 /**
74  * [SceneTransitionLayout] is a container that automatically animates its content whenever
75  * [currentScene] changes, using the transitions defined in [transitions].
76  *
77  * Note: You should use [androidx.compose.animation.AnimatedContent] instead of
78  * [SceneTransitionLayout] if it fits your need. Use [SceneTransitionLayout] over AnimatedContent if
79  * you need support for swipe gestures, shared elements or transitions defined declaratively outside
80  * UI code.
81  *
82  * @param currentScene the current scene
83  * @param onChangeScene a mutator that should set [currentScene] to the given scene when called.
84  *   This is called when the user commits a transition to a new scene because of a [UserAction], for
85  *   instance by triggering back navigation or by swiping to a new scene.
86  * @param transitions the definition of the transitions used to animate a change of scene.
87  * @param swipeSourceDetector the source detector used to detect which source a swipe is started
88  *   from, if any.
89  * @param transitionInterceptionThreshold used during a scene transition. For the scene to be
90  *   intercepted, the progress value must be above the threshold, and below (1 - threshold).
91  * @param scenes the configuration of the different scenes of this layout.
92  */
93 @Composable
SceneTransitionLayoutnull94 fun SceneTransitionLayout(
95     currentScene: SceneKey,
96     onChangeScene: (SceneKey) -> Unit,
97     transitions: SceneTransitions,
98     modifier: Modifier = Modifier,
99     swipeSourceDetector: SwipeSourceDetector = DefaultEdgeDetector,
100     swipeDetector: SwipeDetector = DefaultSwipeDetector,
101     @FloatRange(from = 0.0, to = 0.5) transitionInterceptionThreshold: Float = 0f,
102     enableInterruptions: Boolean = DEFAULT_INTERRUPTIONS_ENABLED,
103     scenes: SceneTransitionLayoutScope.() -> Unit,
104 ) {
105     val state =
106         updateSceneTransitionLayoutState(
107             currentScene,
108             onChangeScene,
109             transitions,
110             enableInterruptions = enableInterruptions,
111         )
112 
113     SceneTransitionLayout(
114         state,
115         modifier,
116         swipeSourceDetector,
117         swipeDetector,
118         transitionInterceptionThreshold,
119         scenes,
120     )
121 }
122 
123 interface SceneTransitionLayoutScope {
124     /**
125      * Add a scene to this layout, identified by [key].
126      *
127      * You can configure [userActions] so that swiping on this layout or navigating back will
128      * transition to a different scene.
129      *
130      * Important: scene order along the z-axis follows call order. Calling scene(A) followed by
131      * scene(B) will mean that scene B renders after/above scene A.
132      */
scenenull133     fun scene(
134         key: SceneKey,
135         userActions: Map<UserAction, UserActionResult> = emptyMap(),
136         content: @Composable SceneScope.() -> Unit,
137     )
138 }
139 
140 /**
141  * A DSL marker to prevent people from nesting calls to Modifier.element() inside a MovableElement,
142  * which is not supported.
143  */
144 @DslMarker annotation class ElementDsl
145 
146 /** A scope that can be used to query the target state of an element or scene. */
147 interface ElementStateScope {
148     /**
149      * Return the *target* size of [this] element in the given [scene], i.e. the size of the element
150      * when idle, or `null` if the element is not composed and measured in that scene (yet).
151      */
152     fun ElementKey.targetSize(scene: SceneKey): IntSize?
153 
154     /**
155      * Return the *target* offset of [this] element in the given [scene], i.e. the size of the
156      * element when idle, or `null` if the element is not composed and placed in that scene (yet).
157      */
158     fun ElementKey.targetOffset(scene: SceneKey): Offset?
159 
160     /**
161      * Return the *target* size of [this] scene, i.e. the size of the scene when idle, or `null` if
162      * the scene was never composed.
163      */
164     fun SceneKey.targetSize(): IntSize?
165 }
166 
167 @Stable
168 @ElementDsl
169 interface BaseSceneScope : ElementStateScope {
170     /** The key of this scene. */
171     val sceneKey: SceneKey
172 
173     /** The state of the [SceneTransitionLayout] in which this scene is contained. */
174     val layoutState: SceneTransitionLayoutState
175 
176     /**
177      * Tag an element identified by [key].
178      *
179      * Tagging an element will allow you to reference that element when defining transitions, so
180      * that the element can be transformed and animated when the scene transitions in or out.
181      *
182      * Additionally, this [key] will be used to detect elements that are shared between scenes to
183      * automatically interpolate their size and offset. If you need to animate shared element values
184      * (i.e. values associated to this element that change depending on which scene it is composed
185      * in), use [Element] instead.
186      *
187      * Note that shared elements tagged using this function will be duplicated in each scene they
188      * are part of, so any **internal** state (e.g. state created using `remember {
189      * mutableStateOf(...) }`) will be lost. If you need to preserve internal state, you should use
190      * [MovableElement] instead.
191      *
192      * @see Element
193      * @see MovableElement
194      */
Modifiernull195     fun Modifier.element(key: ElementKey): Modifier
196 
197     /**
198      * Create an element identified by [key].
199      *
200      * Similar to [element], this creates an element that will be automatically shared when present
201      * in multiple scenes and that can be transformed during transitions, the same way that
202      * [element] does.
203      *
204      * The only difference with [element] is that the provided [ElementScope] allows you to
205      * [animate element values][ElementScope.animateElementValueAsState] or specify its
206      * [movable content][Element.movableContent] that will be "moved" and composed only once during
207      * transitions (as opposed to [element] that duplicates shared elements) so that any internal
208      * state is preserved during and after the transition.
209      *
210      * @see element
211      * @see MovableElement
212      */
213     @Composable
214     fun Element(
215         key: ElementKey,
216         modifier: Modifier,
217 
218         // TODO(b/317026105): As discussed in http://shortn/_gJVdltF8Si, remove the @Composable
219         // scope here to make sure that callers specify the content in ElementScope.content {} or
220         // ElementScope.movableContent {}.
221         content: @Composable ElementScope<ElementContentScope>.() -> Unit,
222     )
223 
224     /**
225      * Create a *movable* element identified by [key].
226      *
227      * Similar to [Element], this creates an element that will be automatically shared when present
228      * in multiple scenes and that can be transformed during transitions, and you can also use the
229      * provided [ElementScope] to [animate element values][ElementScope.animateElementValueAsState].
230      *
231      * The important difference with [element] and [Element] is that this element
232      * [content][ElementScope.content] will be "moved" and composed only once during transitions, as
233      * opposed to [element] and [Element] that duplicates shared elements, so that any internal
234      * state is preserved during and after the transition.
235      *
236      * @see element
237      * @see Element
238      */
239     @Composable
240     fun MovableElement(
241         key: ElementKey,
242         modifier: Modifier,
243 
244         // TODO(b/317026105): As discussed in http://shortn/_gJVdltF8Si, remove the @Composable
245         // scope here to make sure that callers specify the content in ElementScope.content {} or
246         // ElementScope.movableContent {}.
247         content: @Composable ElementScope<MovableElementContentScope>.() -> Unit,
248     )
249 
250     /**
251      * Adds a [NestedScrollConnection] to intercept scroll events not handled by the scrollable
252      * component.
253      *
254      * @param leftBehavior when we should perform the overscroll animation at the left.
255      * @param rightBehavior when we should perform the overscroll animation at the right.
256      */
257     fun Modifier.horizontalNestedScrollToScene(
258         leftBehavior: NestedScrollBehavior = NestedScrollBehavior.EdgeNoPreview,
259         rightBehavior: NestedScrollBehavior = NestedScrollBehavior.EdgeNoPreview,
260         isExternalOverscrollGesture: () -> Boolean = { false },
261     ): Modifier
262 
263     /**
264      * Adds a [NestedScrollConnection] to intercept scroll events not handled by the scrollable
265      * component.
266      *
267      * @param topBehavior when we should perform the overscroll animation at the top.
268      * @param bottomBehavior when we should perform the overscroll animation at the bottom.
269      */
verticalNestedScrollToScenenull270     fun Modifier.verticalNestedScrollToScene(
271         topBehavior: NestedScrollBehavior = NestedScrollBehavior.EdgeNoPreview,
272         bottomBehavior: NestedScrollBehavior = NestedScrollBehavior.EdgeNoPreview,
273         isExternalOverscrollGesture: () -> Boolean = { false },
274     ): Modifier
275 
276     /**
277      * Don't resize during transitions. This can for instance be used to make sure that scrollable
278      * lists keep a constant size during transitions even if its elements are growing/shrinking.
279      */
noResizeDuringTransitionsnull280     fun Modifier.noResizeDuringTransitions(): Modifier
281 }
282 
283 @Stable
284 @ElementDsl
285 interface SceneScope : BaseSceneScope {
286     /**
287      * Animate some value at the scene level.
288      *
289      * @param value the value of this shared value in the current scene.
290      * @param key the key of this shared value.
291      * @param type the [SharedValueType] of this animated value.
292      * @param canOverflow whether this value can overflow past the values it is interpolated
293      *   between, for instance because the transition is animated using a bouncy spring.
294      * @see animateSceneIntAsState
295      * @see animateSceneFloatAsState
296      * @see animateSceneDpAsState
297      * @see animateSceneColorAsState
298      */
299     @Composable
300     fun <T> animateSceneValueAsState(
301         value: T,
302         key: ValueKey,
303         type: SharedValueType<T, *>,
304         canOverflow: Boolean,
305     ): AnimatedState<T>
306 }
307 
308 /**
309  * The type of a shared value animated using [ElementScope.animateElementValueAsState] or
310  * [SceneScope.animateSceneValueAsState].
311  */
312 @Stable
313 interface SharedValueType<T, Delta> {
314     /** The unspecified value for this type. */
315     val unspecifiedValue: T
316 
317     /**
318      * The zero value of this type. It should be equal to what [diff(x, x)] returns for any value of
319      * x.
320      */
321     val zeroDeltaValue: Delta
322 
323     /**
324      * Return the linear interpolation of [a] and [b] at the given [progress], i.e. `a + (b - a) *
325      * progress`.
326      */
lerpnull327     fun lerp(a: T, b: T, progress: Float): T
328 
329     /** Return `a - b`. */
330     fun diff(a: T, b: T): Delta
331 
332     /** Return `a + b * bWeight`. */
333     fun addWeighted(a: T, b: Delta, bWeight: Float): T
334 }
335 
336 @Stable
337 @ElementDsl
338 interface ElementScope<ContentScope> {
339     /**
340      * Animate some value associated to this element.
341      *
342      * @param value the value of this shared value in the current scene.
343      * @param key the key of this shared value.
344      * @param type the [SharedValueType] of this animated value.
345      * @param canOverflow whether this value can overflow past the values it is interpolated
346      *   between, for instance because the transition is animated using a bouncy spring.
347      * @see animateElementIntAsState
348      * @see animateElementFloatAsState
349      * @see animateElementDpAsState
350      * @see animateElementColorAsState
351      */
352     @Composable
353     fun <T> animateElementValueAsState(
354         value: T,
355         key: ValueKey,
356         type: SharedValueType<T, *>,
357         canOverflow: Boolean,
358     ): AnimatedState<T>
359 
360     /**
361      * The content of this element.
362      *
363      * Important: This must be called exactly once, after all calls to [animateElementValueAsState].
364      */
365     @Composable fun content(content: @Composable ContentScope.() -> Unit)
366 }
367 
368 /**
369  * The exact same scope as [androidx.compose.foundation.layout.BoxScope].
370  *
371  * We can't reuse BoxScope directly because of the @LayoutScopeMarker annotation on it, which would
372  * prevent us from calling Modifier.element() and other methods of [SceneScope] inside any Box {} in
373  * the [content][ElementScope.content] of a [SceneScope.Element] or a [SceneScope.MovableElement].
374  */
375 @Stable
376 @ElementDsl
377 interface ElementBoxScope {
378     /** @see [androidx.compose.foundation.layout.BoxScope.align]. */
alignnull379     @Stable fun Modifier.align(alignment: Alignment): Modifier
380 
381     /** @see [androidx.compose.foundation.layout.BoxScope.matchParentSize]. */
382     @Stable fun Modifier.matchParentSize(): Modifier
383 }
384 
385 /** The scope for "normal" (not movable) elements. */
386 @Stable @ElementDsl interface ElementContentScope : SceneScope, ElementBoxScope
387 
388 /**
389  * The scope for the content of movable elements.
390  *
391  * Note that it extends [BaseSceneScope] and not [SceneScope] because movable elements should not
392  * call [SceneScope.animateSceneValueAsState], given that their content is not composed in all
393  * scenes.
394  */
395 @Stable @ElementDsl interface MovableElementContentScope : BaseSceneScope, ElementBoxScope
396 
397 /** An action performed by the user. */
398 sealed interface UserAction {
399     infix fun to(scene: SceneKey): Pair<UserAction, UserActionResult> {
400         return this to UserActionResult(toScene = scene)
401     }
402 }
403 
404 /** The user navigated back, either using a gesture or by triggering a KEYCODE_BACK event. */
405 data object Back : UserAction
406 
407 /** The user swiped on the container. */
408 data class Swipe(
409     val direction: SwipeDirection,
410     val pointerCount: Int = 1,
411     val fromSource: SwipeSource? = null,
412 ) : UserAction {
413     companion object {
414         val Left = Swipe(SwipeDirection.Left)
415         val Up = Swipe(SwipeDirection.Up)
416         val Right = Swipe(SwipeDirection.Right)
417         val Down = Swipe(SwipeDirection.Down)
418     }
419 }
420 
421 enum class SwipeDirection(val orientation: Orientation) {
422     Up(Orientation.Vertical),
423     Down(Orientation.Vertical),
424     Left(Orientation.Horizontal),
425     Right(Orientation.Horizontal),
426 }
427 
428 /**
429  * The source of a Swipe.
430  *
431  * Important: This can be anything that can be returned by any [SwipeSourceDetector], but this must
432  * implement [equals] and [hashCode]. Note that those can be trivially implemented using data
433  * classes.
434  */
435 interface SwipeSource {
436     // Require equals() and hashCode() to be implemented.
equalsnull437     override fun equals(other: Any?): Boolean
438 
439     override fun hashCode(): Int
440 }
441 
442 interface SwipeSourceDetector {
443     /**
444      * Return the [SwipeSource] associated to [position] inside a layout of size [layoutSize], given
445      * [density] and [orientation].
446      */
447     fun source(
448         layoutSize: IntSize,
449         position: IntOffset,
450         density: Density,
451         orientation: Orientation,
452     ): SwipeSource?
453 }
454 
455 /** The result of performing a [UserAction]. */
456 data class UserActionResult(
457     /** The scene we should be transitioning to during the [UserAction]. */
458     val toScene: SceneKey,
459 
460     /** The key of the transition that should be used. */
461     val transitionKey: TransitionKey? = null,
462 )
463 
464 interface UserActionDistance {
465     /**
466      * Return the **absolute** distance of the user action given the size of the scene we are
467      * animating from and the [orientation].
468      *
469      * Note: This function will be called for each drag event until it returns a value > 0f. This
470      * for instance allows you to return 0f or a negative value until the first layout pass of a
471      * scene, so that you can use the size and position of elements in the scene we are
472      * transitioning to when computing this absolute distance.
473      */
UserActionDistanceScopenull474     fun UserActionDistanceScope.absoluteDistance(
475         fromSceneSize: IntSize,
476         orientation: Orientation
477     ): Float
478 }
479 
480 interface UserActionDistanceScope : Density, ElementStateScope
481 
482 /** The user action has a fixed [absoluteDistance]. */
483 class FixedDistance(private val distance: Dp) : UserActionDistance {
484     override fun UserActionDistanceScope.absoluteDistance(
485         fromSceneSize: IntSize,
486         orientation: Orientation,
487     ): Float = distance.toPx()
488 }
489 
490 /**
491  * An internal version of [SceneTransitionLayout] to be used for tests.
492  *
493  * Important: You should use this only in tests and if you need to access the underlying
494  * [SceneTransitionLayoutImpl]. In other cases, you should use [SceneTransitionLayout].
495  */
496 @Composable
SceneTransitionLayoutForTestingnull497 internal fun SceneTransitionLayoutForTesting(
498     state: SceneTransitionLayoutState,
499     modifier: Modifier = Modifier,
500     swipeSourceDetector: SwipeSourceDetector = DefaultEdgeDetector,
501     swipeDetector: SwipeDetector = DefaultSwipeDetector,
502     transitionInterceptionThreshold: Float = 0f,
503     onLayoutImpl: ((SceneTransitionLayoutImpl) -> Unit)? = null,
504     scenes: SceneTransitionLayoutScope.() -> Unit,
505 ) {
506     val density = LocalDensity.current
507     val coroutineScope = rememberCoroutineScope()
508     val layoutImpl = remember {
509         SceneTransitionLayoutImpl(
510                 state = state as BaseSceneTransitionLayoutState,
511                 density = density,
512                 swipeSourceDetector = swipeSourceDetector,
513                 transitionInterceptionThreshold = transitionInterceptionThreshold,
514                 builder = scenes,
515                 coroutineScope = coroutineScope,
516             )
517             .also { onLayoutImpl?.invoke(it) }
518     }
519 
520     // TODO(b/317014852): Move this into the SideEffect {} again once STLImpl.scenes is not a
521     // SnapshotStateMap anymore.
522     layoutImpl.updateScenes(scenes)
523 
524     SideEffect {
525         if (state != layoutImpl.state) {
526             error(
527                 "This SceneTransitionLayout was bound to a different SceneTransitionLayoutState" +
528                     " that was used when creating it, which is not supported"
529             )
530         }
531 
532         layoutImpl.density = density
533         layoutImpl.swipeSourceDetector = swipeSourceDetector
534         layoutImpl.transitionInterceptionThreshold = transitionInterceptionThreshold
535     }
536 
537     layoutImpl.Content(modifier, swipeDetector)
538 }
539