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