• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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