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.animation.rememberSplineBasedDecay
21 import androidx.compose.foundation.LocalOverscrollFactory
22 import androidx.compose.foundation.OverscrollEffect
23 import androidx.compose.foundation.OverscrollFactory
24 import androidx.compose.foundation.gestures.Orientation
25 import androidx.compose.foundation.layout.BoxScope
26 import androidx.compose.runtime.Composable
27 import androidx.compose.runtime.SideEffect
28 import androidx.compose.runtime.Stable
29 import androidx.compose.runtime.remember
30 import androidx.compose.runtime.rememberCoroutineScope
31 import androidx.compose.ui.Alignment
32 import androidx.compose.ui.Modifier
33 import androidx.compose.ui.geometry.Offset
34 import androidx.compose.ui.input.pointer.PointerType
35 import androidx.compose.ui.layout.LookaheadScope
36 import androidx.compose.ui.platform.LocalDensity
37 import androidx.compose.ui.platform.LocalLayoutDirection
38 import androidx.compose.ui.platform.LocalViewConfiguration
39 import androidx.compose.ui.unit.Density
40 import androidx.compose.ui.unit.Dp
41 import androidx.compose.ui.unit.IntOffset
42 import androidx.compose.ui.unit.IntSize
43 import androidx.compose.ui.unit.LayoutDirection
44 import com.android.compose.gesture.NestedScrollableBound
45
46 /**
47 * [SceneTransitionLayout] is a container that automatically animates its content whenever its state
48 * changes.
49 *
50 * Note: You should use [androidx.compose.animation.AnimatedContent] instead of
51 * [SceneTransitionLayout] if it fits your need. Use [SceneTransitionLayout] over AnimatedContent if
52 * you need support for swipe gestures, shared elements or transitions defined declaratively outside
53 * UI code.
54 *
55 * @param state the state of this layout.
56 * @param swipeSourceDetector the edge detector used to detect which edge a swipe is started from,
57 * if any.
58 * @param transitionInterceptionThreshold used during a scene transition. For the scene to be
59 * intercepted, the progress value must be above the threshold, and below (1 - threshold).
60 * @param builder the configuration of the different scenes and overlays of this layout.
61 */
62 @Composable
SceneTransitionLayoutnull63 fun SceneTransitionLayout(
64 state: SceneTransitionLayoutState,
65 modifier: Modifier = Modifier,
66 swipeSourceDetector: SwipeSourceDetector = DefaultEdgeDetector,
67 swipeDetector: SwipeDetector = DefaultSwipeDetector,
68 @FloatRange(from = 0.0, to = 0.5) transitionInterceptionThreshold: Float = 0.05f,
69 // TODO(b/240432457) Remove this once test utils can access the internal STLForTesting().
70 implicitTestTags: Boolean = false,
71 builder: SceneTransitionLayoutScope<ContentScope>.() -> Unit,
72 ) {
73 SceneTransitionLayoutForTesting(
74 state,
75 modifier,
76 swipeSourceDetector,
77 swipeDetector,
78 transitionInterceptionThreshold,
79 implicitTestTags = implicitTestTags,
80 onLayoutImpl = null,
81 builder = builder,
82 )
83 }
84
85 interface SceneTransitionLayoutScope<out CS : ContentScope> {
86 /**
87 * Add a scene to this layout, identified by [key].
88 *
89 * You can configure [userActions] so that swiping on this layout or navigating back will
90 * transition to a different scene.
91 *
92 * By default, [verticalOverscrollEffect][ContentScope.verticalOverscrollEffect] and
93 * [horizontalOverscrollEffect][ContentScope.horizontalOverscrollEffect] of this scene will be
94 * created using [LocalOverscrollFactory]. You can specify a non-null [effectFactory] to set up
95 * a custom factory that will be used by this scene and by any calls to
96 * rememberOverscrollEffect() inside the scene.
97 *
98 * Important: scene order along the z-axis follows call order. Calling scene(A) followed by
99 * scene(B) will mean that scene B renders after/above scene A.
100 */
scenenull101 fun scene(
102 key: SceneKey,
103 userActions: Map<UserAction, UserActionResult> = emptyMap(),
104 effectFactory: OverscrollFactory? = null,
105 alwaysCompose: Boolean = false,
106 content: @Composable CS.() -> Unit,
107 )
108
109 /**
110 * Add an overlay to this layout, identified by [key].
111 *
112 * Overlays are displayed above scenes and can be toggled using
113 * [MutableSceneTransitionLayoutState.showOverlay] and
114 * [MutableSceneTransitionLayoutState.hideOverlay].
115 *
116 * Overlays will have a maximum size that is the size of the layout without overlays, i.e. an
117 * overlay can be fillMaxSize() to match the layout size but it won't make the layout bigger.
118 *
119 * By default overlays are centered in their layout but they can be aligned differently using
120 * [alignment].
121 *
122 * If [isModal] is true (the default), then a protective layer will be added behind the overlay
123 * to prevent swipes from reaching other scenes or overlays behind this one. Clicking this
124 * protective layer will close the overlay.
125 *
126 * By default, [verticalOverscrollEffect][ContentScope.verticalOverscrollEffect] and
127 * [horizontalOverscrollEffect][ContentScope.horizontalOverscrollEffect] of this overlay will be
128 * created using [LocalOverscrollFactory]. You can specify a non-null [effectFactory] to set up
129 * a custom factory that will be used by this content and by any calls to
130 * rememberOverscrollEffect() inside the content.
131 *
132 * Important: overlays must be defined after all scenes. Overlay order along the z-axis follows
133 * call order. Calling overlay(A) followed by overlay(B) will mean that overlay B renders
134 * after/above overlay A.
135 */
136 fun overlay(
137 key: OverlayKey,
138 userActions: Map<UserAction, UserActionResult> =
139 mapOf(Back to UserActionResult.HideOverlay(key)),
140 alignment: Alignment = Alignment.Center,
141 isModal: Boolean = true,
142 effectFactory: OverscrollFactory? = null,
143 content: @Composable CS.() -> Unit,
144 )
145 }
146
147 /**
148 * A DSL marker to prevent people from nesting calls to Modifier.element() inside a MovableElement,
149 * which is not supported.
150 */
151 @DslMarker annotation class ElementDsl
152
153 /** A scope that can be used to query the target state of an element or scene. */
154 interface ElementStateScope {
155 /**
156 * Return the *target* size of [this] element in the given [content], i.e. the size of the
157 * element when idle, or `null` if the element is not composed and measured in that content
158 * (yet).
159 */
160 fun ElementKey.targetSize(content: ContentKey): IntSize?
161
162 /**
163 * Return the *approaching* size of [this] element in the given [content], i.e. thethe size the
164 * element when is transitioning, or `null` if the element is not composed and measured in that
165 * content (yet).
166 */
167 fun ElementKey.approachSize(content: ContentKey): IntSize?
168
169 /**
170 * Return the *target* offset of [this] element in the given [content], i.e. the size of the
171 * element when idle, or `null` if the element is not composed and placed in that content (yet).
172 */
173 fun ElementKey.targetOffset(content: ContentKey): Offset?
174
175 /**
176 * Return the *target* size of [this] content, i.e. the size of the content when idle, or `null`
177 * if the content was not composed (yet).
178 */
179 fun ContentKey.targetSize(): IntSize?
180 }
181
182 @Stable
183 @ElementDsl
184 interface BaseContentScope : ElementStateScope {
185 /** The key of this content. */
186 val contentKey: ContentKey
187
188 /** The state of the [SceneTransitionLayout] in which this content is contained. */
189 val layoutState: SceneTransitionLayoutState
190
191 /** The [LookaheadScope] used by the [SceneTransitionLayout]. */
192 val lookaheadScope: LookaheadScope
193
194 /**
195 * Tag an element identified by [key].
196 *
197 * Tagging an element will allow you to reference that element when defining transitions, so
198 * that the element can be transformed and animated when the content transitions in or out.
199 *
200 * Additionally, this [key] will be used to detect elements that are shared between contents to
201 * automatically interpolate their size and offset. If you need to animate shared element values
202 * (i.e. values associated to this element that change depending on which content it is composed
203 * in), use [ElementWithValues] instead.
204 *
205 * Note that shared elements tagged using this function will be duplicated in each content they
206 * are part of, so any **internal** state (e.g. state created using `remember {
207 * mutableStateOf(...) }`) will be lost. If you need to preserve internal state, you should use
208 * [MovableElement] instead.
209 *
210 * @see Element
211 * @see ElementWithValues
212 * @see MovableElement
213 */
214 // TODO(b/389985793): Does replacing this by Element have a noticeable impact on performance and
215 // should we deprecate it?
elementnull216 @Stable fun Modifier.element(key: ElementKey): Modifier
217
218 /**
219 * Create an element identified by [key].
220 *
221 * Similar to [element], this creates an element that will be automatically shared when present
222 * in multiple contents and that can be transformed during transitions, the same way that
223 * [element] does.
224 *
225 * The only difference with [element] is that [Element] introduces its own recomposition scope
226 * and layout node, which can be helpful to avoid expensive recompositions when a transition is
227 * started or finished (see b/389985793#comment103 for details).
228 *
229 * @see element
230 * @see ElementWithValues
231 * @see MovableElement
232 */
233 @Composable
234 fun Element(key: ElementKey, modifier: Modifier, content: @Composable BoxScope.() -> Unit)
235
236 /**
237 * Create an element identified by [key].
238 *
239 * The only difference with [Element] is that the provided [ElementScope] allows you to
240 * [animate element values][ElementScope.animateElementValueAsState].
241 *
242 * @see element
243 * @see Element
244 * @see MovableElement
245 */
246 @Composable
247 fun ElementWithValues(
248 key: ElementKey,
249 modifier: Modifier,
250
251 // TODO(b/317026105): As discussed in http://shortn/_gJVdltF8Si, remove the @Composable
252 // scope here to make sure that callers specify the content in ElementScope.content {} or
253 // ElementScope.movableContent {}.
254 content: @Composable ElementScope<ElementContentScope>.() -> Unit,
255 )
256
257 /**
258 * Create a *movable* element identified by [key].
259 *
260 * Similar to [ElementWithValues], this creates an element that will be automatically shared
261 * when present in multiple contents and that can be transformed during transitions, and you can
262 * also use the provided [ElementScope] to
263 * [animate element values][ElementScope.animateElementValueAsState].
264 *
265 * The important difference with [element], [Element] and [ElementWithValues] is that this
266 * element [content][ElementScope.content] will be "moved" and composed only once during
267 * transitions, as opposed to [element], [Element] and [ElementWithValues] that duplicates
268 * shared elements, so that any internal state is preserved during and after the transition.
269 *
270 * @see element
271 * @see Element
272 * @see ElementWithValues
273 */
274 @Composable
275 fun MovableElement(
276 key: MovableElementKey,
277 modifier: Modifier,
278
279 // TODO(b/317026105): As discussed in http://shortn/_gJVdltF8Si, remove the @Composable
280 // scope here to make sure that callers specify the content in ElementScope.content {} or
281 // ElementScope.movableContent {}.
282 content: @Composable ElementScope<MovableElementContentScope>.() -> Unit,
283 )
284
285 /**
286 * Don't resize during transitions. This can for instance be used to make sure that scrollable
287 * lists keep a constant size during transitions even if its elements are growing/shrinking.
288 */
289 fun Modifier.noResizeDuringTransitions(): Modifier
290
291 /**
292 * Temporarily disable this content swipe actions when any scrollable below this modifier has
293 * consumed any amount of scroll delta, until the scroll gesture is finished.
294 *
295 * This can for instance be used to ensure that a scrollable list is overscrolled once it
296 * reached its bounds instead of directly starting a scene transition from the same scroll
297 * gesture.
298 */
299 fun Modifier.disableSwipesWhenScrolling(
300 bounds: NestedScrollableBound = NestedScrollableBound.Any
301 ): Modifier
302 }
303
304 @Stable
305 @ElementDsl
306 interface ContentScope : BaseContentScope {
307 /**
308 * The overscroll effect applied to the content in the vertical direction. This can be used to
309 * customize how the content behaves when the scene is over scrolled.
310 *
311 * You should use this effect exactly once with the `Modifier.overscroll()` modifier:
312 * ```kotlin
313 * @Composable
314 * fun ContentScope.MyScene() {
315 * Box(
316 * modifier = Modifier
317 * // Apply the effect
318 * .overscroll(verticalOverscrollEffect)
319 * ) {
320 * // ... your content ...
321 * }
322 * }
323 * ```
324 *
325 * @see horizontalOverscrollEffect
326 */
327 val verticalOverscrollEffect: OverscrollEffect
328
329 /**
330 * The overscroll effect applied to the content in the horizontal direction. This can be used to
331 * customize how the content behaves when the scene is over scrolled.
332 *
333 * @see verticalOverscrollEffect
334 */
335 val horizontalOverscrollEffect: OverscrollEffect
336
337 /**
338 * Animate some value at the content level.
339 *
340 * @param value the value of this shared value in the current content.
341 * @param key the key of this shared value.
342 * @param type the [SharedValueType] of this animated value.
343 * @param canOverflow whether this value can overflow past the values it is interpolated
344 * between, for instance because the transition is animated using a bouncy spring.
345 * @see animateContentIntAsState
346 * @see animateContentFloatAsState
347 * @see animateContentDpAsState
348 * @see animateContentColorAsState
349 */
350 @Composable
351 fun <T> animateContentValueAsState(
352 value: T,
353 key: ValueKey,
354 type: SharedValueType<T, *>,
355 canOverflow: Boolean,
356 ): AnimatedState<T>
357
358 /**
359 * A [NestedSceneTransitionLayout] will share its elements with its ancestor STLs therefore
360 * enabling sharedElement transitions between them.
361 */
362 // TODO(b/380070506): Add more parameters when default params are supported in Kotlin 2.0.21
363 @Composable
364 fun NestedSceneTransitionLayout(
365 state: SceneTransitionLayoutState,
366 modifier: Modifier,
367 builder: SceneTransitionLayoutScope<ContentScope>.() -> Unit,
368 )
369 }
370
371 internal interface InternalContentScope : ContentScope {
372
373 @Composable
NestedSceneTransitionLayoutForTestingnull374 fun NestedSceneTransitionLayoutForTesting(
375 state: SceneTransitionLayoutState,
376 modifier: Modifier,
377 onLayoutImpl: ((SceneTransitionLayoutImpl) -> Unit)?,
378 builder: SceneTransitionLayoutScope<InternalContentScope>.() -> Unit,
379 )
380 }
381
382 /**
383 * The type of a shared value animated using [ElementScope.animateElementValueAsState] or
384 * [ContentScope.animateContentValueAsState].
385 */
386 @Stable
387 interface SharedValueType<T, Delta> {
388 /** The unspecified value for this type. */
389 val unspecifiedValue: T
390
391 /**
392 * The zero value of this type. It should be equal to what [diff(x, x)] returns for any value of
393 * x.
394 */
395 val zeroDeltaValue: Delta
396
397 /**
398 * Return the linear interpolation of [a] and [b] at the given [progress], i.e. `a + (b - a) *
399 * progress`.
400 */
401 fun lerp(a: T, b: T, progress: Float): T
402
403 /** Return `a - b`. */
404 fun diff(a: T, b: T): Delta
405
406 /** Return `a + b * bWeight`. */
407 fun addWeighted(a: T, b: Delta, bWeight: Float): T
408 }
409
410 @Stable
411 @ElementDsl
412 interface ElementScope<ContentScope> {
413 /**
414 * Animate some value associated to this element.
415 *
416 * @param value the value of this shared value in the current content.
417 * @param key the key of this shared value.
418 * @param type the [SharedValueType] of this animated value.
419 * @param canOverflow whether this value can overflow past the values it is interpolated
420 * between, for instance because the transition is animated using a bouncy spring.
421 * @see animateElementIntAsState
422 * @see animateElementFloatAsState
423 * @see animateElementDpAsState
424 * @see animateElementColorAsState
425 */
426 @Composable
animateElementValueAsStatenull427 fun <T> animateElementValueAsState(
428 value: T,
429 key: ValueKey,
430 type: SharedValueType<T, *>,
431 canOverflow: Boolean,
432 ): AnimatedState<T>
433
434 /**
435 * The content of this element.
436 *
437 * Important: This must be called exactly once, after all calls to [animateElementValueAsState].
438 */
439 @Composable fun content(content: @Composable ContentScope.() -> Unit)
440 }
441
442 /**
443 * The exact same scope as [androidx.compose.foundation.layout.BoxScope].
444 *
445 * We can't reuse BoxScope directly because of the @LayoutScopeMarker annotation on it, which would
446 * prevent us from calling Modifier.element() and other methods of [ContentScope] inside any Box {}
447 * in the [content][ElementScope.content] of a [ContentScope.ElementWithValues] or a
448 * [ContentScope.MovableElement].
449 */
450 @Stable
451 @ElementDsl
452 interface ElementBoxScope {
453 /** @see [androidx.compose.foundation.layout.BoxScope.align]. */
454 @Stable fun Modifier.align(alignment: Alignment): Modifier
455
456 /** @see [androidx.compose.foundation.layout.BoxScope.matchParentSize]. */
457 @Stable fun Modifier.matchParentSize(): Modifier
458 }
459
460 /** The scope for "normal" (not movable) elements. */
461 @Stable @ElementDsl interface ElementContentScope : ContentScope, ElementBoxScope
462
463 /**
464 * The scope for the content of movable elements.
465 *
466 * Note that it extends [BaseContentScope] and not [ContentScope] because movable elements should
467 * not call [ContentScope.animateContentValueAsState], given that their content is not composed in
468 * all scenes.
469 */
470 @Stable @ElementDsl interface MovableElementContentScope : BaseContentScope, ElementBoxScope
471
472 /** An action performed by the user. */
473 sealed class UserAction {
tonull474 infix fun to(scene: SceneKey): Pair<UserAction, UserActionResult> {
475 return this to UserActionResult(toScene = scene)
476 }
477
tonull478 infix fun to(overlay: OverlayKey): Pair<UserAction, UserActionResult> {
479 return this to UserActionResult.ShowOverlay(overlay)
480 }
481
482 /** Resolve this into a [Resolved] user action given [layoutDirection]. */
resolvenull483 internal abstract fun resolve(layoutDirection: LayoutDirection): Resolved
484
485 /** A resolved [UserAction] that does not depend on the layout direction. */
486 internal sealed class Resolved
487 }
488
489 /** The user navigated back, either using a gesture or by triggering a KEYCODE_BACK event. */
490 data object Back : UserAction() {
491 override fun resolve(layoutDirection: LayoutDirection): Resolved = Resolved
492
493 internal object Resolved : UserAction.Resolved()
494 }
495
496 /** The user swiped on the container. */
497 data class Swipe
498 private constructor(
499 val direction: SwipeDirection,
500 val pointerCount: Int = 1,
501 val pointerType: PointerType? = null,
502 val fromSource: SwipeSource? = null,
503 ) : UserAction() {
504 companion object {
505 val Left = Swipe(SwipeDirection.Left)
506 val Up = Swipe(SwipeDirection.Up)
507 val Right = Swipe(SwipeDirection.Right)
508 val Down = Swipe(SwipeDirection.Down)
509 val Start = Swipe(SwipeDirection.Start)
510 val End = Swipe(SwipeDirection.End)
511
Leftnull512 fun Left(
513 pointerCount: Int = 1,
514 pointerType: PointerType? = null,
515 fromSource: SwipeSource? = null,
516 ) = Swipe(SwipeDirection.Left, pointerCount, pointerType, fromSource)
517
518 fun Up(
519 pointerCount: Int = 1,
520 pointerType: PointerType? = null,
521 fromSource: SwipeSource? = null,
522 ) = Swipe(SwipeDirection.Up, pointerCount, pointerType, fromSource)
523
524 fun Right(
525 pointerCount: Int = 1,
526 pointerType: PointerType? = null,
527 fromSource: SwipeSource? = null,
528 ) = Swipe(SwipeDirection.Right, pointerCount, pointerType, fromSource)
529
530 fun Down(
531 pointerCount: Int = 1,
532 pointerType: PointerType? = null,
533 fromSource: SwipeSource? = null,
534 ) = Swipe(SwipeDirection.Down, pointerCount, pointerType, fromSource)
535
536 fun Start(
537 pointerCount: Int = 1,
538 pointerType: PointerType? = null,
539 fromSource: SwipeSource? = null,
540 ) = Swipe(SwipeDirection.Start, pointerCount, pointerType, fromSource)
541
542 fun End(
543 pointerCount: Int = 1,
544 pointerType: PointerType? = null,
545 fromSource: SwipeSource? = null,
546 ) = Swipe(SwipeDirection.End, pointerCount, pointerType, fromSource)
547 }
548
549 override fun resolve(layoutDirection: LayoutDirection): UserAction.Resolved {
550 return Resolved(
551 direction = direction.resolve(layoutDirection),
552 pointerCount = pointerCount,
553 pointerType = pointerType,
554 fromSource = fromSource?.resolve(layoutDirection),
555 )
556 }
557
558 /** A resolved [Swipe] that does not depend on the layout direction. */
559 internal data class Resolved(
560 val direction: SwipeDirection.Resolved,
561 val pointerCount: Int,
562 val fromSource: SwipeSource.Resolved?,
563 val pointerType: PointerType?,
564 ) : UserAction.Resolved()
565 }
566
567 enum class SwipeDirection(internal val resolve: (LayoutDirection) -> Resolved) {
<lambda>null568 Up(resolve = { Resolved.Up }),
<lambda>null569 Down(resolve = { Resolved.Down }),
<lambda>null570 Left(resolve = { Resolved.Left }),
<lambda>null571 Right(resolve = { Resolved.Right }),
<lambda>null572 Start(resolve = { if (it == LayoutDirection.Ltr) Resolved.Left else Resolved.Right }),
<lambda>null573 End(resolve = { if (it == LayoutDirection.Ltr) Resolved.Right else Resolved.Left });
574
575 /** A resolved [SwipeDirection] that does not depend on the layout direction. */
576 internal enum class Resolved(val orientation: Orientation) {
577 Up(Orientation.Vertical),
578 Down(Orientation.Vertical),
579 Left(Orientation.Horizontal),
580 Right(Orientation.Horizontal),
581 }
582 }
583
584 /**
585 * The source of a Swipe.
586 *
587 * Important: This can be anything that can be returned by any [SwipeSourceDetector], but this must
588 * implement [equals] and [hashCode]. Note that those can be trivially implemented using data
589 * classes.
590 */
591 interface SwipeSource {
592 // Require equals() and hashCode() to be implemented.
equalsnull593 override fun equals(other: Any?): Boolean
594
595 override fun hashCode(): Int
596
597 /** Resolve this into a [Resolved] swipe source given [layoutDirection]. */
598 fun resolve(layoutDirection: LayoutDirection): Resolved
599
600 /** A resolved [SwipeSource] that does not depend on the layout direction. */
601 interface Resolved {
602 override fun equals(other: Any?): Boolean
603
604 override fun hashCode(): Int
605 }
606 }
607
608 interface SwipeSourceDetector {
609 /**
610 * Return the [SwipeSource] associated to [position] inside a layout of size [layoutSize], given
611 * [density] and [orientation].
612 */
sourcenull613 fun source(
614 layoutSize: IntSize,
615 position: IntOffset,
616 density: Density,
617 orientation: Orientation,
618 ): SwipeSource.Resolved?
619 }
620
621 /** The result of performing a [UserAction]. */
622 sealed class UserActionResult(
623 /** The key of the transition that should be used. */
624 open val transitionKey: TransitionKey? = null,
625
626 /**
627 * If `true`, the swipe will be committed and we will settle to [toScene] if only if the user
628 * swiped at least the swipe distance, i.e. the transition progress was already equal to or
629 * bigger than 100% when the user released their finger. `
630 */
631 open val requiresFullDistanceSwipe: Boolean,
632 ) {
633 internal abstract fun toContent(currentScene: SceneKey): ContentKey
634
635 data class ChangeScene
636 internal constructor(
637 /** The scene we should be transitioning to during the [UserAction]. */
638 val toScene: SceneKey,
639 override val transitionKey: TransitionKey? = null,
640 override val requiresFullDistanceSwipe: Boolean = false,
641 ) : UserActionResult(transitionKey, requiresFullDistanceSwipe) {
642 override fun toContent(currentScene: SceneKey): ContentKey = toScene
643 }
644
645 /** A [UserActionResult] that shows [overlay]. */
646 data class ShowOverlay(
647 val overlay: OverlayKey,
648 override val transitionKey: TransitionKey? = null,
649 override val requiresFullDistanceSwipe: Boolean = false,
650
651 /** Specify which overlays (if any) should be hidden when this user action is started. */
652 val hideCurrentOverlays: HideCurrentOverlays = HideCurrentOverlays.None,
653 ) : UserActionResult(transitionKey, requiresFullDistanceSwipe) {
654 override fun toContent(currentScene: SceneKey): ContentKey = overlay
655
656 sealed class HideCurrentOverlays {
657 /** Hide none of the current overlays. */
658 object None : HideCurrentOverlays()
659
660 /** Hide all current overlays. */
661 object All : HideCurrentOverlays()
662
663 /** Hide [overlays], for those in that set that are currently shown. */
664 class Some(val overlays: Set<OverlayKey>) : HideCurrentOverlays() {
665 constructor(vararg overlays: OverlayKey) : this(overlays.toSet())
666 }
667 }
668 }
669
670 /** A [UserActionResult] that hides [overlay]. */
671 data class HideOverlay(
672 val overlay: OverlayKey,
673 override val transitionKey: TransitionKey? = null,
674 override val requiresFullDistanceSwipe: Boolean = false,
675 ) : UserActionResult(transitionKey, requiresFullDistanceSwipe) {
676 override fun toContent(currentScene: SceneKey): ContentKey = currentScene
677 }
678
679 /**
680 * A [UserActionResult] that replaces the current overlay by [overlay].
681 *
682 * Note: This result can only be used for user actions of overlays and an exception will be
683 * thrown if it is used for a scene.
684 */
685 data class ReplaceByOverlay(
686 val overlay: OverlayKey,
687 override val transitionKey: TransitionKey? = null,
688 override val requiresFullDistanceSwipe: Boolean = false,
689 ) : UserActionResult(transitionKey, requiresFullDistanceSwipe) {
690 override fun toContent(currentScene: SceneKey): ContentKey = overlay
691 }
692
693 companion object {
694 /** A [UserActionResult] that changes the current scene to [toScene]. */
695 operator fun invoke(
696 /** The scene we should be transitioning to during the [UserAction]. */
697 toScene: SceneKey,
698
699 /** The key of the transition that should be used. */
700 transitionKey: TransitionKey? = null,
701
702 /**
703 * If `true`, the swipe will be committed if only if the user swiped at least the swipe
704 * distance, i.e. the transition progress was already equal to or bigger than 100% when
705 * the user released their finger.
706 */
707 requiresFullDistanceSwipe: Boolean = false,
708 ): UserActionResult = ChangeScene(toScene, transitionKey, requiresFullDistanceSwipe)
709 }
710 }
711
interfacenull712 fun interface UserActionDistance {
713 /**
714 * Return the **absolute** distance of the user action when going from [fromContent] to
715 * [toContent] in the given [orientation].
716 *
717 * Note: This function will be called for each drag event until it returns a value > 0f. This
718 * for instance allows you to return 0f or a negative value until the first layout pass of a
719 * scene, so that you can use the size and position of elements in the scene we are
720 * transitioning to when computing this absolute distance.
721 */
722 fun UserActionDistanceScope.absoluteDistance(
723 fromContent: ContentKey,
724 toContent: ContentKey,
725 orientation: Orientation,
726 ): Float
727 }
728
729 interface UserActionDistanceScope : Density, ElementStateScope
730
731 /** The user action has a fixed [absoluteDistance]. */
732 class FixedDistance(private val distance: Dp) : UserActionDistance {
absoluteDistancenull733 override fun UserActionDistanceScope.absoluteDistance(
734 fromContent: ContentKey,
735 toContent: ContentKey,
736 orientation: Orientation,
737 ): Float = distance.toPx()
738 }
739
740 /**
741 * An internal version of [SceneTransitionLayout] to be used for tests, that provides access to the
742 * internal [SceneTransitionLayoutImpl] and implicitly tags all scenes and elements.
743 */
744 @Composable
745 internal fun SceneTransitionLayoutForTesting(
746 state: SceneTransitionLayoutState,
747 modifier: Modifier = Modifier,
748 swipeSourceDetector: SwipeSourceDetector = DefaultEdgeDetector,
749 swipeDetector: SwipeDetector = DefaultSwipeDetector,
750 transitionInterceptionThreshold: Float = 0f,
751 onLayoutImpl: ((SceneTransitionLayoutImpl) -> Unit)? = null,
752 sharedElementMap: MutableMap<ElementKey, Element> = remember { mutableMapOf() },
<lambda>null753 ancestors: List<Ancestor> = remember { emptyList() },
754 lookaheadScope: LookaheadScope? = null,
755 implicitTestTags: Boolean = true,
756 builder: SceneTransitionLayoutScope<InternalContentScope>.() -> Unit,
757 ) {
758 val density = LocalDensity.current
759 val directionChangeSlop = LocalViewConfiguration.current.touchSlop
760 val layoutDirection = LocalLayoutDirection.current
761 val defaultEffectFactory = checkNotNull(LocalOverscrollFactory.current)
762 val animationScope = rememberCoroutineScope()
763 val decayAnimationSpec = rememberSplineBasedDecay<Float>()
<lambda>null764 val layoutImpl = remember {
765 SceneTransitionLayoutImpl(
766 state = state as MutableSceneTransitionLayoutStateImpl,
767 density = density,
768 layoutDirection = layoutDirection,
769 swipeSourceDetector = swipeSourceDetector,
770 swipeDetector = swipeDetector,
771 transitionInterceptionThreshold = transitionInterceptionThreshold,
772 builder = builder,
773 animationScope = animationScope,
774 elements = sharedElementMap,
775 ancestors = ancestors,
776 lookaheadScope = lookaheadScope,
777 directionChangeSlop = directionChangeSlop,
778 defaultEffectFactory = defaultEffectFactory,
779 decayAnimationSpec = decayAnimationSpec,
780 implicitTestTags = implicitTestTags,
781 )
782 .also { onLayoutImpl?.invoke(it) }
783 }
784
785 // TODO(b/317014852): Move this into the SideEffect {} again once STLImpl.scenes is not a
786 // SnapshotStateMap anymore.
787 layoutImpl.updateContents(builder, layoutDirection, defaultEffectFactory)
788
<lambda>null789 SideEffect {
790 if (state != layoutImpl.state) {
791 error(
792 "This SceneTransitionLayout was bound to a different SceneTransitionLayoutState" +
793 " that was used when creating it, which is not supported"
794 )
795 }
796 if (layoutImpl.elements != sharedElementMap) {
797 error(
798 "This SceneTransitionLayout was bound to a different elements map that was used " +
799 "when creating it, which is not supported"
800 )
801 }
802 if (layoutImpl.ancestors != ancestors) {
803 error(
804 "This SceneTransitionLayout was bound to a different ancestors that was " +
805 "used when creating it, which is not supported"
806 )
807 }
808 if (lookaheadScope != null && layoutImpl.lookaheadScope != lookaheadScope) {
809 error(
810 "This SceneTransitionLayout was bound to a different lookaheadScope that was " +
811 "used when creating it, which is not supported"
812 )
813 }
814
815 layoutImpl.density = density
816 layoutImpl.layoutDirection = layoutDirection
817 layoutImpl.swipeSourceDetector = swipeSourceDetector
818 layoutImpl.swipeDetector = swipeDetector
819 layoutImpl.transitionInterceptionThreshold = transitionInterceptionThreshold
820 layoutImpl.decayAnimationSpec = decayAnimationSpec
821 }
822
823 layoutImpl.Content(modifier)
824 }
825