1 /*
<lambda>null2  * 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 android.util.Log
20 import androidx.annotation.VisibleForTesting
21 import androidx.compose.animation.core.Animatable
22 import androidx.compose.animation.core.AnimationVector1D
23 import androidx.compose.animation.core.spring
24 import androidx.compose.foundation.gestures.Orientation
25 import androidx.compose.runtime.Composable
26 import androidx.compose.runtime.LaunchedEffect
27 import androidx.compose.runtime.SideEffect
28 import androidx.compose.runtime.Stable
29 import androidx.compose.runtime.getValue
30 import androidx.compose.runtime.mutableStateOf
31 import androidx.compose.runtime.remember
32 import androidx.compose.runtime.setValue
33 import androidx.compose.ui.util.fastAll
34 import androidx.compose.ui.util.fastFilter
35 import androidx.compose.ui.util.fastForEach
36 import com.android.compose.animation.scene.transition.link.LinkedTransition
37 import com.android.compose.animation.scene.transition.link.StateLink
38 import kotlin.math.absoluteValue
39 import kotlinx.coroutines.CoroutineScope
40 import kotlinx.coroutines.Job
41 import kotlinx.coroutines.channels.Channel
42 import kotlinx.coroutines.launch
43 
44 /**
45  * The state of a [SceneTransitionLayout].
46  *
47  * @see MutableSceneTransitionLayoutState
48  * @see updateSceneTransitionLayoutState
49  */
50 @Stable
51 sealed interface SceneTransitionLayoutState {
52     /**
53      * The current [TransitionState]. All values read here are backed by the Snapshot system.
54      *
55      * To observe those values outside of Compose/the Snapshot system, use
56      * [SceneTransitionLayoutState.observableTransitionState] instead.
57      */
58     val transitionState: TransitionState
59 
60     /**
61      * The current transition, or `null` if we are idle.
62      *
63      * Note: If you need to handle interruptions and multiple transitions running in parallel, use
64      * [currentTransitions] instead.
65      */
66     val currentTransition: TransitionState.Transition?
67         get() = transitionState as? TransitionState.Transition
68 
69     /**
70      * The list of [TransitionState.Transition] currently running. This will be the empty list if we
71      * are idle.
72      */
73     val currentTransitions: List<TransitionState.Transition>
74 
75     /** The [SceneTransitions] used when animating this state. */
76     val transitions: SceneTransitions
77 
78     /**
79      * Whether we are transitioning. If [from] or [to] is empty, we will also check that they match
80      * the scenes we are animating from and/or to.
81      */
82     fun isTransitioning(from: SceneKey? = null, to: SceneKey? = null): Boolean
83 
84     /** Whether we are transitioning from [scene] to [other], or from [other] to [scene]. */
85     fun isTransitioningBetween(scene: SceneKey, other: SceneKey): Boolean
86 }
87 
88 /** A [SceneTransitionLayoutState] whose target scene can be imperatively set. */
89 sealed interface MutableSceneTransitionLayoutState : SceneTransitionLayoutState {
90     /** The [SceneTransitions] used when animating this state. */
91     override var transitions: SceneTransitions
92 
93     /**
94      * Set the target scene of this state to [targetScene].
95      *
96      * If [targetScene] is the same as the [currentScene][TransitionState.currentScene] of
97      * [transitionState], then nothing will happen and this will return `null`. Note that this means
98      * that this will also do nothing if the user is currently swiping from [targetScene] to another
99      * scene, or if we were already animating to [targetScene].
100      *
101      * If [targetScene] is different than the [currentScene][TransitionState.currentScene] of
102      * [transitionState], then this will animate to [targetScene]. The associated
103      * [TransitionState.Transition] will be returned and will be set as the current
104      * [transitionState] of this [MutableSceneTransitionLayoutState].
105      *
106      * Note that because a non-null [TransitionState.Transition] is returned does not mean that the
107      * transition will finish and that we will settle to [targetScene]. The returned transition
108      * might still be interrupted, for instance by another call to [setTargetScene] or by a user
109      * gesture.
110      *
111      * If [this] [CoroutineScope] is cancelled during the transition and that the transition was
112      * still active, then the [transitionState] of this [MutableSceneTransitionLayoutState] will be
113      * set to `TransitionState.Idle(targetScene)`.
114      *
115      * TODO(b/318794193): Add APIs to await() and cancel() any [TransitionState.Transition].
116      */
setTargetScenenull117     fun setTargetScene(
118         targetScene: SceneKey,
119         coroutineScope: CoroutineScope,
120         transitionKey: TransitionKey? = null,
121     ): TransitionState.Transition?
122 
123     /** Immediately snap to the given [scene]. */
124     fun snapToScene(scene: SceneKey)
125 }
126 
127 /**
128  * Return a [MutableSceneTransitionLayoutState] initially idle at [initialScene].
129  *
130  * @param initialScene the initial scene to which this state is initialized.
131  * @param transitions the [SceneTransitions] used when this state is transitioning between scenes.
132  * @param canChangeScene whether we can transition to the given scene. This is called when the user
133  *   commits a transition to a new scene because of a [UserAction]. If [canChangeScene] returns
134  *   `true`, then the gesture will be committed and we will animate to the other scene. Otherwise,
135  *   the gesture will be cancelled and we will animate back to the current scene.
136  * @param stateLinks the [StateLink] connecting this [SceneTransitionLayoutState] to other
137  *   [SceneTransitionLayoutState]s.
138  */
139 fun MutableSceneTransitionLayoutState(
140     initialScene: SceneKey,
141     transitions: SceneTransitions = SceneTransitions.Empty,
142     canChangeScene: (SceneKey) -> Boolean = { true },
143     stateLinks: List<StateLink> = emptyList(),
144     enableInterruptions: Boolean = DEFAULT_INTERRUPTIONS_ENABLED,
145 ): MutableSceneTransitionLayoutState {
146     return MutableSceneTransitionLayoutStateImpl(
147         initialScene,
148         transitions,
149         canChangeScene,
150         stateLinks,
151         enableInterruptions,
152     )
153 }
154 
155 /**
156  * Sets up a [SceneTransitionLayoutState] and keeps it synced with [currentScene], [onChangeScene]
157  * and [transitions]. New transitions will automatically be started whenever [currentScene] is
158  * changed.
159  *
160  * @param currentScene the current scene
161  * @param onChangeScene a mutator that should set [currentScene] to the given scene when called.
162  *   This is called when the user commits a transition to a new scene because of a [UserAction], for
163  *   instance by triggering back navigation or by swiping to a new scene.
164  * @param transitions the definition of the transitions used to animate a change of scene.
165  * @param canChangeScene whether we can transition to the given scene. This is called when the user
166  *   commits a transition to a new scene because of a [UserAction]. If [canChangeScene] returns
167  *   `true`, then [onChangeScene] will be called right afterwards with the same [SceneKey]. If it
168  *   returns `false`, the user action will be cancelled and we will animate back to the current
169  *   scene.
170  * @param stateLinks the [StateLink] connecting this [SceneTransitionLayoutState] to other
171  *   [SceneTransitionLayoutState]s.
172  */
173 @Composable
updateSceneTransitionLayoutStatenull174 fun updateSceneTransitionLayoutState(
175     currentScene: SceneKey,
176     onChangeScene: (SceneKey) -> Unit,
177     transitions: SceneTransitions = SceneTransitions.Empty,
178     canChangeScene: (SceneKey) -> Boolean = { true },
179     stateLinks: List<StateLink> = emptyList(),
180     enableInterruptions: Boolean = DEFAULT_INTERRUPTIONS_ENABLED,
181 ): SceneTransitionLayoutState {
<lambda>null182     return remember {
183             HoistedSceneTransitionLayoutState(
184                 currentScene,
185                 transitions,
186                 onChangeScene,
187                 canChangeScene,
188                 stateLinks,
189                 enableInterruptions,
190             )
191         }
<lambda>null192         .apply {
193             update(
194                 currentScene,
195                 onChangeScene,
196                 canChangeScene,
197                 transitions,
198                 stateLinks,
199                 enableInterruptions,
200             )
201         }
202 }
203 
204 @Stable
205 sealed interface TransitionState {
206     /**
207      * The current effective scene. If a new transition was triggered, it would start from this
208      * scene.
209      *
210      * For instance, when swiping from scene A to scene B, the [currentScene] is A when the swipe
211      * gesture starts, but then if the user flings their finger and commits the transition to scene
212      * B, then [currentScene] becomes scene B even if the transition is not finished yet and is
213      * still animating to settle to scene B.
214      */
215     val currentScene: SceneKey
216 
217     /** No transition/animation is currently running. */
218     data class Idle(override val currentScene: SceneKey) : TransitionState
219 
220     /** There is a transition animating between two scenes. */
221     abstract class Transition(
222         /** The scene this transition is starting from. Can't be the same as toScene */
223         val fromScene: SceneKey,
224 
225         /** The scene this transition is going to. Can't be the same as fromScene */
226         val toScene: SceneKey,
227     ) : TransitionState {
228         /**
229          * The key of this transition. This should usually be null, but it can be specified to use a
230          * specific set of transformations associated to this transition.
231          */
232         open val key: TransitionKey? = null
233 
234         /**
235          * The progress of the transition. This is usually in the `[0; 1]` range, but it can also be
236          * less than `0` or greater than `1` when using transitions with a spring AnimationSpec or
237          * when flinging quickly during a swipe gesture.
238          */
239         abstract val progress: Float
240 
241         /** The current velocity of [progress], in progress units. */
242         abstract val progressVelocity: Float
243 
244         /** Whether the transition was triggered by user input rather than being programmatic. */
245         abstract val isInitiatedByUserInput: Boolean
246 
247         /** Whether user input is currently driving the transition. */
248         abstract val isUserInputOngoing: Boolean
249 
250         /**
251          * The current [TransformationSpecImpl] and [OverscrollSpecImpl] associated to this
252          * transition.
253          *
254          * Important: These will be set exactly once, when this transition is
255          * [started][BaseSceneTransitionLayoutState.startTransition].
256          */
257         internal var transformationSpec: TransformationSpecImpl = TransformationSpec.Empty
258         private var fromOverscrollSpec: OverscrollSpecImpl? = null
259         private var toOverscrollSpec: OverscrollSpecImpl? = null
260 
261         /** The current [OverscrollSpecImpl], if this transition is currently overscrolling. */
262         internal val currentOverscrollSpec: OverscrollSpecImpl?
263             get() {
264                 if (this !is HasOverscrollProperties) return null
265                 val progress = progress
266                 val bouncingScene = bouncingScene
267                 return when {
268                     progress < 0f || bouncingScene == fromScene -> fromOverscrollSpec
269                     progress > 1f || bouncingScene == toScene -> toOverscrollSpec
270                     else -> null
271                 }
272             }
273 
274         /**
275          * An animatable that animates from 1f to 0f. This will be used to nicely animate the sudden
276          * jump of values when this transitions interrupts another one.
277          */
278         private var interruptionDecay: Animatable<Float, AnimationVector1D>? = null
279 
280         init {
281             check(fromScene != toScene)
282         }
283 
284         /**
285          * Force this transition to finish and animate to [currentScene], so that this transition
286          * progress will settle to either 0% (if [currentScene] == [fromScene]) or 100% (if
287          * [currentScene] == [toScene]) in a finite amount of time.
288          *
289          * @return the [Job] that animates the progress to [currentScene]. It can be used to wait
290          *   until the animation is complete or cancel it to snap to [currentScene]. Calling
291          *   [finish] multiple times will return the same [Job].
292          */
finishnull293         abstract fun finish(): Job
294 
295         /**
296          * Whether we are transitioning. If [from] or [to] is empty, we will also check that they
297          * match the scenes we are animating from and/or to.
298          */
299         fun isTransitioning(from: SceneKey? = null, to: SceneKey? = null): Boolean {
300             return (from == null || fromScene == from) && (to == null || toScene == to)
301         }
302 
303         /** Whether we are transitioning from [scene] to [other], or from [other] to [scene]. */
isTransitioningBetweennull304         fun isTransitioningBetween(scene: SceneKey, other: SceneKey): Boolean {
305             return isTransitioning(from = scene, to = other) ||
306                 isTransitioning(from = other, to = scene)
307         }
308 
updateOverscrollSpecsnull309         internal fun updateOverscrollSpecs(
310             fromSpec: OverscrollSpecImpl?,
311             toSpec: OverscrollSpecImpl?,
312         ) {
313             fromOverscrollSpec = fromSpec
314             toOverscrollSpec = toSpec
315         }
316 
interruptionProgressnull317         internal open fun interruptionProgress(
318             layoutImpl: SceneTransitionLayoutImpl,
319         ): Float {
320             if (!layoutImpl.state.enableInterruptions) {
321                 return 0f
322             }
323 
324             fun create(): Animatable<Float, AnimationVector1D> {
325                 val animatable = Animatable(1f, visibilityThreshold = ProgressVisibilityThreshold)
326                 layoutImpl.coroutineScope.launch {
327                     val swipeSpec = layoutImpl.state.transitions.defaultSwipeSpec
328                     val progressSpec =
329                         spring(
330                             stiffness = swipeSpec.stiffness,
331                             dampingRatio = swipeSpec.dampingRatio,
332                             visibilityThreshold = ProgressVisibilityThreshold,
333                         )
334                     animatable.animateTo(0f, progressSpec)
335                 }
336 
337                 return animatable
338             }
339 
340             val animatable = interruptionDecay ?: create().also { interruptionDecay = it }
341             return animatable.value
342         }
343     }
344 
345     interface HasOverscrollProperties {
346         /**
347          * The position of the [Transition.toScene].
348          *
349          * Used to understand the direction of the overscroll.
350          */
351         val isUpOrLeft: Boolean
352 
353         /**
354          * The relative orientation between [Transition.fromScene] and [Transition.toScene].
355          *
356          * Used to understand the orientation of the overscroll.
357          */
358         val orientation: Orientation
359 
360         /**
361          * Scope which can be used in the Overscroll DSL to define a transformation based on the
362          * distance between [Transition.fromScene] and [Transition.toScene].
363          */
364         val overscrollScope: OverscrollScope
365 
366         /**
367          * The scene around which the transition is currently bouncing. When not `null`, this
368          * transition is currently oscillating around this scene and will soon settle to that scene.
369          */
370         val bouncingScene: SceneKey?
371 
372         companion object {
373             const val DistanceUnspecified = 0f
374         }
375     }
376 }
377 
378 internal abstract class BaseSceneTransitionLayoutState(
379     initialScene: SceneKey,
380     protected var stateLinks: List<StateLink>,
381 
382     // TODO(b/290930950): Remove this flag.
383     internal var enableInterruptions: Boolean,
384 ) : SceneTransitionLayoutState {
385     private val creationThread: Thread = Thread.currentThread()
386 
387     /**
388      * The current [TransitionState]. This list will either be:
389      * 1. A list with a single [TransitionState.Idle] element, when we are idle.
390      * 2. A list with one or more [TransitionState.Transition], when we are transitioning.
391      */
392     @VisibleForTesting
393     internal var transitionStates: List<TransitionState> by
394         mutableStateOf(listOf(TransitionState.Idle(initialScene)))
395         private set
396 
397     override val transitionState: TransitionState
398         get() = transitionStates.last()
399 
400     private val activeTransitionLinks = mutableMapOf<StateLink, LinkedTransition>()
401 
402     override val currentTransitions: List<TransitionState.Transition>
403         get() {
404             if (transitionStates.last() is TransitionState.Idle) {
405                 check(transitionStates.size == 1)
406                 return emptyList()
407             } else {
408                 @Suppress("UNCHECKED_CAST")
409                 return transitionStates as List<TransitionState.Transition>
410             }
411         }
412 
413     /**
414      * The mapping of transitions that are finished, i.e. for which [finishTransition] was called,
415      * to their idle scene.
416      */
417     @VisibleForTesting
418     internal val finishedTransitions = mutableMapOf<TransitionState.Transition, SceneKey>()
419 
420     /** Whether we can transition to the given [scene]. */
canChangeScenenull421     internal abstract fun canChangeScene(scene: SceneKey): Boolean
422 
423     /**
424      * Called when the [current scene][TransitionState.currentScene] should be changed to [scene].
425      *
426      * When this is called, the source of truth for the current scene should be changed so that
427      * [transitionState] will animate and settle to [scene].
428      */
429     internal abstract fun CoroutineScope.onChangeScene(scene: SceneKey)
430 
431     internal fun checkThread() {
432         val current = Thread.currentThread()
433         if (current !== creationThread) {
434             error(
435                 """
436                     Only the original thread that created a SceneTransitionLayoutState can mutate it
437                       Expected: ${creationThread.name}
438                       Current: ${current.name}
439                 """
440                     .trimIndent()
441             )
442         }
443     }
444 
isTransitioningnull445     override fun isTransitioning(from: SceneKey?, to: SceneKey?): Boolean {
446         val transition = currentTransition ?: return false
447         return transition.isTransitioning(from, to)
448     }
449 
isTransitioningBetweennull450     override fun isTransitioningBetween(scene: SceneKey, other: SceneKey): Boolean {
451         val transition = currentTransition ?: return false
452         return transition.isTransitioningBetween(scene, other)
453     }
454 
455     /**
456      * Start a new [transition].
457      *
458      * If [chain] is `true`, then the transitions will simply be added to [currentTransitions] and
459      * will run in parallel to the current transitions. If [chain] is `false`, then the list of
460      * [currentTransitions] will be cleared and [transition] will be the only running transition.
461      *
462      * Important: you *must* call [finishTransition] once the transition is finished.
463      */
startTransitionnull464     internal fun startTransition(transition: TransitionState.Transition, chain: Boolean = true) {
465         checkThread()
466 
467         // Compute the [TransformationSpec] when the transition starts.
468         val fromScene = transition.fromScene
469         val toScene = transition.toScene
470         val orientation = (transition as? TransitionState.HasOverscrollProperties)?.orientation
471 
472         // Update the transition specs.
473         transition.transformationSpec =
474             transitions
475                 .transitionSpec(fromScene, toScene, key = transition.key)
476                 .transformationSpec()
477         if (orientation != null) {
478             transition.updateOverscrollSpecs(
479                 fromSpec = transitions.overscrollSpec(fromScene, orientation),
480                 toSpec = transitions.overscrollSpec(toScene, orientation),
481             )
482         } else {
483             transition.updateOverscrollSpecs(fromSpec = null, toSpec = null)
484         }
485 
486         // Handle transition links.
487         cancelActiveTransitionLinks()
488         setupTransitionLinks(transition)
489 
490         if (!enableInterruptions) {
491             // Set the current transition.
492             check(transitionStates.size == 1)
493             transitionStates = listOf(transition)
494             return
495         }
496 
497         when (val currentState = transitionStates.last()) {
498             is TransitionState.Idle -> {
499                 // Replace [Idle] by [transition].
500                 check(transitionStates.size == 1)
501                 transitionStates = listOf(transition)
502             }
503             is TransitionState.Transition -> {
504                 // Force the current transition to finish to currentScene. The transition will call
505                 // [finishTransition] once it's finished.
506                 currentState.finish()
507 
508                 val tooManyTransitions = transitionStates.size >= MAX_CONCURRENT_TRANSITIONS
509                 val clearCurrentTransitions = !chain || tooManyTransitions
510                 if (clearCurrentTransitions) {
511                     if (tooManyTransitions) logTooManyTransitions()
512 
513                     // Force finish all transitions.
514                     while (currentTransitions.isNotEmpty()) {
515                         val transition = transitionStates[0] as TransitionState.Transition
516                         finishTransition(transition, transition.currentScene)
517                     }
518 
519                     // We finished all transitions, so we are now idle. We remove this state so that
520                     // we end up only with the new transition after appending it.
521                     check(transitionStates.size == 1)
522                     check(transitionStates[0] is TransitionState.Idle)
523                     transitionStates = listOf(transition)
524                 } else {
525                     // Append the new transition.
526                     transitionStates = transitionStates + transition
527                 }
528             }
529         }
530     }
531 
logTooManyTransitionsnull532     private fun logTooManyTransitions() {
533         Log.wtf(
534             TAG,
535             buildString {
536                 appendLine("Potential leak detected in SceneTransitionLayoutState!")
537                 appendLine("  Some transition(s) never called STLState.finishTransition().")
538                 appendLine("  Transitions (size=${transitionStates.size}):")
539                 transitionStates.fastForEach { state ->
540                     val transition = state as TransitionState.Transition
541                     val from = transition.fromScene
542                     val to = transition.toScene
543                     val indicator = if (finishedTransitions.contains(transition)) "x" else " "
544                     appendLine("  [$indicator] $from => $to ($transition)")
545                 }
546             }
547         )
548     }
549 
cancelActiveTransitionLinksnull550     private fun cancelActiveTransitionLinks() {
551         for ((link, linkedTransition) in activeTransitionLinks) {
552             link.target.finishTransition(linkedTransition, linkedTransition.currentScene)
553         }
554         activeTransitionLinks.clear()
555     }
556 
setupTransitionLinksnull557     private fun setupTransitionLinks(transitionState: TransitionState) {
558         if (transitionState !is TransitionState.Transition) return
559         stateLinks.fastForEach { stateLink ->
560             val matchingLinks =
561                 stateLink.transitionLinks.fastFilter { it.isMatchingLink(transitionState) }
562             if (matchingLinks.isEmpty()) return@fastForEach
563             if (matchingLinks.size > 1) error("More than one link matched.")
564 
565             val targetCurrentScene = stateLink.target.transitionState.currentScene
566             val matchingLink = matchingLinks[0]
567 
568             if (!matchingLink.targetIsInValidState(targetCurrentScene)) return@fastForEach
569 
570             val linkedTransition =
571                 LinkedTransition(
572                     originalTransition = transitionState,
573                     fromScene = targetCurrentScene,
574                     toScene = matchingLink.targetTo,
575                     key = matchingLink.targetTransitionKey,
576                 )
577 
578             stateLink.target.startTransition(linkedTransition)
579             activeTransitionLinks[stateLink] = linkedTransition
580         }
581     }
582 
583     /**
584      * Notify that [transition] was finished and that we should settle to [idleScene]. This will do
585      * nothing if [transition] was interrupted since it was started.
586      */
finishTransitionnull587     internal fun finishTransition(transition: TransitionState.Transition, idleScene: SceneKey) {
588         checkThread()
589 
590         val existingIdleScene = finishedTransitions[transition]
591         if (existingIdleScene != null) {
592             // This transition was already finished.
593             check(idleScene == existingIdleScene) {
594                 "Transition $transition was finished multiple times with different " +
595                     "idleScene ($existingIdleScene != $idleScene)"
596             }
597             return
598         }
599 
600         val transitionStates = this.transitionStates
601         if (!transitionStates.contains(transition)) {
602             // This transition was already removed from transitionStates.
603             return
604         }
605 
606         check(transitionStates.fastAll { it is TransitionState.Transition })
607 
608         // Mark this transition as finished and save the scene it is settling at.
609         finishedTransitions[transition] = idleScene
610 
611         // Finish all linked transitions.
612         finishActiveTransitionLinks(idleScene)
613 
614         // Keep a reference to the idle scene of the last removed transition, in case we remove all
615         // transitions and should settle to Idle.
616         var lastRemovedIdleScene: SceneKey? = null
617 
618         // Remove all first n finished transitions.
619         var i = 0
620         val nStates = transitionStates.size
621         while (i < nStates) {
622             val t = transitionStates[i]
623             if (!finishedTransitions.contains(t)) {
624                 // Stop here.
625                 break
626             }
627 
628             // Remove the transition from the set of finished transitions.
629             lastRemovedIdleScene = finishedTransitions.remove(t)
630             i++
631         }
632 
633         // If all transitions are finished, we are idle.
634         if (i == nStates) {
635             check(finishedTransitions.isEmpty())
636             this.transitionStates = listOf(TransitionState.Idle(checkNotNull(lastRemovedIdleScene)))
637         } else if (i > 0) {
638             this.transitionStates = transitionStates.subList(fromIndex = i, toIndex = nStates)
639         }
640     }
641 
snapToScenenull642     fun snapToScene(scene: SceneKey) {
643         checkThread()
644 
645         // Force finish all transitions.
646         while (currentTransitions.isNotEmpty()) {
647             val transition = transitionStates[0] as TransitionState.Transition
648             finishTransition(transition, transition.currentScene)
649         }
650 
651         check(transitionStates.size == 1)
652         transitionStates = listOf(TransitionState.Idle(scene))
653     }
654 
finishActiveTransitionLinksnull655     private fun finishActiveTransitionLinks(idleScene: SceneKey) {
656         val previousTransition = this.transitionState as? TransitionState.Transition ?: return
657         for ((link, linkedTransition) in activeTransitionLinks) {
658             if (previousTransition.fromScene == idleScene) {
659                 // The transition ended by arriving at the fromScene, move link to Idle(fromScene).
660                 link.target.finishTransition(linkedTransition, linkedTransition.fromScene)
661             } else if (previousTransition.toScene == idleScene) {
662                 // The transition ended by arriving at the toScene, move link to Idle(toScene).
663                 link.target.finishTransition(linkedTransition, linkedTransition.toScene)
664             } else {
665                 // The transition was interrupted by something else, we reset to initial state.
666                 link.target.finishTransition(linkedTransition, linkedTransition.fromScene)
667             }
668         }
669         activeTransitionLinks.clear()
670     }
671 
672     /**
673      * Check if a transition is in progress. If the progress value is near 0 or 1, immediately snap
674      * to the closest scene.
675      *
676      * Important: Snapping to the closest scene will instantly finish *all* ongoing transitions,
677      * only the progress of the last transition will be checked.
678      *
679      * @return true if snapped to the closest scene.
680      */
snapToIdleIfClosenull681     internal fun snapToIdleIfClose(threshold: Float): Boolean {
682         val transition = currentTransition ?: return false
683         val progress = transition.progress
684 
685         fun isProgressCloseTo(value: Float) = (progress - value).absoluteValue <= threshold
686 
687         fun finishAllTransitions(lastTransitionIdleScene: SceneKey) {
688             // Force finish all transitions.
689             while (currentTransitions.isNotEmpty()) {
690                 val transition = transitionStates[0] as TransitionState.Transition
691                 val idleScene =
692                     if (transitionStates.size == 1) {
693                         lastTransitionIdleScene
694                     } else {
695                         transition.currentScene
696                     }
697 
698                 finishTransition(transition, idleScene)
699             }
700         }
701 
702         return when {
703             isProgressCloseTo(0f) -> {
704                 finishAllTransitions(transition.fromScene)
705                 true
706             }
707             isProgressCloseTo(1f) -> {
708                 finishAllTransitions(transition.toScene)
709                 true
710             }
711             else -> false
712         }
713     }
714 }
715 
716 /**
717  * A [SceneTransitionLayout] whose current scene/source of truth is hoisted (its current value comes
718  * from outside).
719  */
720 internal class HoistedSceneTransitionLayoutState(
721     initialScene: SceneKey,
722     override var transitions: SceneTransitions,
723     private var changeScene: (SceneKey) -> Unit,
724     private var canChangeScene: (SceneKey) -> Boolean,
725     stateLinks: List<StateLink> = emptyList(),
726     enableInterruptions: Boolean = DEFAULT_INTERRUPTIONS_ENABLED,
727 ) : BaseSceneTransitionLayoutState(initialScene, stateLinks, enableInterruptions) {
728     private val targetSceneChannel = Channel<SceneKey>(Channel.CONFLATED)
729 
canChangeScenenull730     override fun canChangeScene(scene: SceneKey): Boolean = canChangeScene.invoke(scene)
731 
732     override fun CoroutineScope.onChangeScene(scene: SceneKey) = changeScene.invoke(scene)
733 
734     @Composable
735     fun update(
736         currentScene: SceneKey,
737         onChangeScene: (SceneKey) -> Unit,
738         canChangeScene: (SceneKey) -> Boolean,
739         transitions: SceneTransitions,
740         stateLinks: List<StateLink>,
741         enableInterruptions: Boolean,
742     ) {
743         SideEffect {
744             this.changeScene = onChangeScene
745             this.canChangeScene = canChangeScene
746             this.transitions = transitions
747             this.stateLinks = stateLinks
748             this.enableInterruptions = enableInterruptions
749 
750             targetSceneChannel.trySend(currentScene)
751         }
752 
753         LaunchedEffect(targetSceneChannel) {
754             for (newKey in targetSceneChannel) {
755                 // Inspired by AnimateAsState.kt: let's poll the last value to avoid being one frame
756                 // late.
757                 val newKey = targetSceneChannel.tryReceive().getOrNull() ?: newKey
758                 animateToScene(
759                     layoutState = this@HoistedSceneTransitionLayoutState,
760                     target = newKey,
761                     transitionKey = null,
762                 )
763             }
764         }
765     }
766 }
767 
768 /** A [MutableSceneTransitionLayoutState] that holds the value for the current scene. */
769 internal class MutableSceneTransitionLayoutStateImpl(
770     initialScene: SceneKey,
<lambda>null771     override var transitions: SceneTransitions = transitions {},
<lambda>null772     private val canChangeScene: (SceneKey) -> Boolean = { true },
773     stateLinks: List<StateLink> = emptyList(),
774     enableInterruptions: Boolean = DEFAULT_INTERRUPTIONS_ENABLED,
775 ) :
776     MutableSceneTransitionLayoutState,
777     BaseSceneTransitionLayoutState(initialScene, stateLinks, enableInterruptions) {
setTargetScenenull778     override fun setTargetScene(
779         targetScene: SceneKey,
780         coroutineScope: CoroutineScope,
781         transitionKey: TransitionKey?,
782     ): TransitionState.Transition? {
783         checkThread()
784 
785         return coroutineScope.animateToScene(
786             layoutState = this@MutableSceneTransitionLayoutStateImpl,
787             target = targetScene,
788             transitionKey = transitionKey,
789         )
790     }
791 
canChangeScenenull792     override fun canChangeScene(scene: SceneKey): Boolean = canChangeScene.invoke(scene)
793 
794     override fun CoroutineScope.onChangeScene(scene: SceneKey) {
795         setTargetScene(scene, coroutineScope = this)
796     }
797 }
798 
799 private const val TAG = "SceneTransitionLayoutState"
800 
801 /** Whether support for interruptions in enabled by default. */
802 internal const val DEFAULT_INTERRUPTIONS_ENABLED = true
803 
804 /**
805  * The max number of concurrent transitions. If the number of transitions goes past this number,
806  * this probably means that there is a leak and we will Log.wtf before clearing the list of
807  * transitions.
808  */
809 private const val MAX_CONCURRENT_TRANSITIONS = 100
810