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