Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit b275f421 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Android (Google) Code Review
Browse files

Merge changes I3d5a16fd,Ie91771be into main

* changes:
  Remove linked transitions
  Ensure that transitions are started only once
parents 8af5658c 58cc2587
Loading
Loading
Loading
Loading
+2 −66
Original line number Diff line number Diff line
@@ -25,17 +25,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFilter
import androidx.compose.ui.util.fastForEach
import com.android.compose.animation.scene.content.state.TransitionState
import com.android.compose.animation.scene.transformation.SharedElementTransformation
import com.android.compose.animation.scene.transition.link.LinkedTransition
import com.android.compose.animation.scene.transition.link.StateLink
import kotlin.math.absoluteValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch

/**
@@ -236,7 +232,6 @@ fun MutableSceneTransitionLayoutState(
    canShowOverlay: (OverlayKey) -> Boolean = { true },
    canHideOverlay: (OverlayKey) -> Boolean = { true },
    canReplaceOverlay: (from: OverlayKey, to: OverlayKey) -> Boolean = { _, _ -> true },
    stateLinks: List<StateLink> = emptyList(),
): MutableSceneTransitionLayoutState {
    return MutableSceneTransitionLayoutStateImpl(
        initialScene,
@@ -246,7 +241,6 @@ fun MutableSceneTransitionLayoutState(
        canShowOverlay,
        canHideOverlay,
        canReplaceOverlay,
        stateLinks,
    )
}

@@ -258,10 +252,7 @@ internal class MutableSceneTransitionLayoutStateImpl(
    internal val canChangeScene: (SceneKey) -> Boolean = { true },
    internal val canShowOverlay: (OverlayKey) -> Boolean = { true },
    internal val canHideOverlay: (OverlayKey) -> Boolean = { true },
    internal val canReplaceOverlay: (from: OverlayKey, to: OverlayKey) -> Boolean = { _, _ ->
        true
    },
    private val stateLinks: List<StateLink> = emptyList(),
    internal val canReplaceOverlay: (from: OverlayKey, to: OverlayKey) -> Boolean = { _, _ -> true },
) : MutableSceneTransitionLayoutState {
    private val creationThread: Thread = Thread.currentThread()

@@ -364,20 +355,11 @@ internal class MutableSceneTransitionLayoutStateImpl(
        checkThread()

        try {
            // Keep a reference to the previous transition (if any).
            val previousTransition = currentTransition

            // Start the transition.
            startTransitionInternal(transition, chain)

            // Handle transition links.
            previousTransition?.let { cancelActiveTransitionLinks(it) }
            if (stateLinks.isNotEmpty()) {
                coroutineScope { setupTransitionLinks(transition) }
            }

            // Run the transition until it is finished.
            transition.run()
            transition.runInternal()
        } finally {
            finishTransition(transition)
        }
@@ -471,42 +453,6 @@ internal class MutableSceneTransitionLayoutStateImpl(
        )
    }

    private fun cancelActiveTransitionLinks(transition: TransitionState.Transition) {
        transition.activeTransitionLinks.forEach { (link, linkedTransition) ->
            link.target.finishTransition(linkedTransition)
        }
        transition.activeTransitionLinks.clear()
    }

    private fun CoroutineScope.setupTransitionLinks(transition: TransitionState.Transition) {
        stateLinks.fastForEach { stateLink ->
            val matchingLinks =
                stateLink.transitionLinks.fastFilter { it.isMatchingLink(transition) }
            if (matchingLinks.isEmpty()) return@fastForEach
            if (matchingLinks.size > 1) error("More than one link matched.")

            val targetCurrentScene = stateLink.target.transitionState.currentScene
            val matchingLink = matchingLinks[0]

            if (!matchingLink.targetIsInValidState(targetCurrentScene)) return@fastForEach

            val linkedTransition =
                LinkedTransition(
                    originalTransition = transition,
                    fromScene = targetCurrentScene,
                    toScene = matchingLink.targetTo,
                    key = matchingLink.targetTransitionKey,
                )

            // Start with UNDISPATCHED so that startTransition is called directly and the new linked
            // transition is observable directly.
            launch(start = CoroutineStart.UNDISPATCHED) {
                stateLink.target.startTransition(linkedTransition)
            }
            transition.activeTransitionLinks[stateLink] = linkedTransition
        }
    }

    /**
     * Notify that [transition] was finished and that it settled to its
     * [currentScene][TransitionState.currentScene]. This will do nothing if [transition] was
@@ -535,9 +481,6 @@ internal class MutableSceneTransitionLayoutStateImpl(
        // Mark this transition as finished.
        finishedTransitions.add(transition)

        // Finish all linked transitions.
        finishActiveTransitionLinks(transition)

        // Keep a reference to the last transition, in case we remove all transitions and should
        // settle to Idle.
        val lastTransition = transitionStates.last()
@@ -584,13 +527,6 @@ internal class MutableSceneTransitionLayoutStateImpl(
        transitionStates = listOf(TransitionState.Idle(scene, currentOverlays))
    }

    private fun finishActiveTransitionLinks(transition: TransitionState.Transition) {
        for ((link, linkedTransition) in transition.activeTransitionLinks) {
            link.target.finishTransition(linkedTransition)
        }
        transition.activeTransitionLinks.clear()
    }

    /**
     * Check if a transition is in progress. If the progress value is near 0 or 1, immediately snap
     * to the closest scene.
+10 −5
Original line number Diff line number Diff line
@@ -34,8 +34,6 @@ import com.android.compose.animation.scene.SceneTransitionLayoutImpl
import com.android.compose.animation.scene.TransformationSpec
import com.android.compose.animation.scene.TransformationSpecImpl
import com.android.compose.animation.scene.TransitionKey
import com.android.compose.animation.scene.transition.link.LinkedTransition
import com.android.compose.animation.scene.transition.link.StateLink
import kotlinx.coroutines.launch

/** The state associated to a [SceneTransitionLayout] at some specific point in time. */
@@ -280,8 +278,8 @@ sealed interface TransitionState {
         */
        private var interruptionDecay: Animatable<Float, AnimationVector1D>? = null

        /** The map of active links that connects this transition to other transitions. */
        internal val activeTransitionLinks = mutableMapOf<StateLink, LinkedTransition>()
        /** Whether this transition was already started. */
        private var wasStarted = false

        init {
            check(fromContent != toContent)
@@ -328,7 +326,7 @@ sealed interface TransitionState {
        }

        /** Run this transition and return once it is finished. */
        abstract suspend fun run()
        protected abstract suspend fun run()

        /**
         * Freeze this transition state so that neither [currentScene] nor [currentOverlays] will
@@ -341,6 +339,13 @@ sealed interface TransitionState {
         */
        abstract fun freezeAndAnimateToCurrentState()

        internal suspend fun runInternal() {
            check(!wasStarted) { "A Transition can be started only once." }
            wasStarted = true

            run()
        }

        internal fun updateOverscrollSpecs(
            fromSpec: OverscrollSpecImpl?,
            toSpec: OverscrollSpecImpl?,
+0 −59
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.compose.animation.scene.transition.link

import com.android.compose.animation.scene.SceneKey
import com.android.compose.animation.scene.TransitionKey
import com.android.compose.animation.scene.content.state.TransitionState

/** A linked transition which is driven by a [originalTransition]. */
internal class LinkedTransition(
    private val originalTransition: TransitionState.Transition,
    fromScene: SceneKey,
    toScene: SceneKey,
    override val key: TransitionKey? = null,
) : TransitionState.Transition.ChangeScene(fromScene, toScene) {

    override val currentScene: SceneKey
        get() {
            return when (originalTransition.currentScene) {
                originalTransition.fromContent -> fromScene
                originalTransition.toContent -> toScene
                else -> error("Original currentScene is neither FromScene nor ToScene")
            }
        }

    override val isInitiatedByUserInput: Boolean
        get() = originalTransition.isInitiatedByUserInput

    override val isUserInputOngoing: Boolean
        get() = originalTransition.isUserInputOngoing

    override val progress: Float
        get() = originalTransition.progress

    override val progressVelocity: Float
        get() = originalTransition.progressVelocity

    override suspend fun run() {
        originalTransition.run()
    }

    override fun freezeAndAnimateToCurrentState() {
        originalTransition.freezeAndAnimateToCurrentState()
    }
}
+0 −63
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.compose.animation.scene.transition.link

import com.android.compose.animation.scene.ContentKey
import com.android.compose.animation.scene.MutableSceneTransitionLayoutStateImpl
import com.android.compose.animation.scene.SceneKey
import com.android.compose.animation.scene.SceneTransitionLayoutState
import com.android.compose.animation.scene.TransitionKey
import com.android.compose.animation.scene.content.state.TransitionState

/** A link between a source (implicit) and [target] `SceneTransitionLayoutState`. */
class StateLink(target: SceneTransitionLayoutState, val transitionLinks: List<TransitionLink>) {

    internal val target = target as MutableSceneTransitionLayoutStateImpl

    /**
     * Links two transitions (source and target) together.
     *
     * `null` can be passed to indicate that any SceneKey should match. e.g. passing `null`, `null`,
     * `null`, `SceneA` means that any transition at the source will trigger a transition in the
     * target to `SceneA` from any current scene.
     */
    class TransitionLink(
        val sourceFrom: ContentKey?,
        val sourceTo: ContentKey?,
        val targetFrom: SceneKey?,
        val targetTo: SceneKey,
        val targetTransitionKey: TransitionKey? = null,
    ) {
        init {
            if (
                (sourceFrom != null && sourceFrom == sourceTo) ||
                    (targetFrom != null && targetFrom == targetTo)
            )
                error("From and To can't be the same")
        }

        internal fun isMatchingLink(transition: TransitionState.Transition): Boolean {
            return (sourceFrom == null || sourceFrom == transition.fromContent) &&
                (sourceTo == null || sourceTo == transition.toContent)
        }

        internal fun targetIsInValidState(targetCurrentContent: ContentKey): Boolean {
            return (targetFrom == null || targetFrom == targetCurrentContent) &&
                targetTo != targetCurrentContent
        }
    }
}
+12 −188
Original line number Diff line number Diff line
@@ -26,10 +26,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.compose.animation.scene.TestScenes.SceneA
import com.android.compose.animation.scene.TestScenes.SceneB
import com.android.compose.animation.scene.TestScenes.SceneC
import com.android.compose.animation.scene.TestScenes.SceneD
import com.android.compose.animation.scene.content.state.TransitionState
import com.android.compose.animation.scene.subjects.assertThat
import com.android.compose.animation.scene.transition.link.StateLink
import com.android.compose.animation.scene.transition.seekToScene
import com.android.compose.test.MonotonicClockTestScope
import com.android.compose.test.TestSceneTransition
@@ -42,6 +40,7 @@ import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertThrows
import org.junit.Rule
@@ -132,147 +131,6 @@ class SceneTransitionLayoutStateTest {
        assertThat(state.transitionState).isEqualTo(TransitionState.Idle(SceneB))
    }

    private fun setupLinkedStates(
        parentInitialScene: SceneKey = SceneC,
        childInitialScene: SceneKey = SceneA,
        sourceFrom: SceneKey? = SceneA,
        sourceTo: SceneKey? = SceneB,
        targetFrom: SceneKey? = SceneC,
        targetTo: SceneKey = SceneD,
    ): Pair<MutableSceneTransitionLayoutStateImpl, MutableSceneTransitionLayoutStateImpl> {
        val parentState = MutableSceneTransitionLayoutState(parentInitialScene)
        val link =
            listOf(
                StateLink(
                    parentState,
                    listOf(StateLink.TransitionLink(sourceFrom, sourceTo, targetFrom, targetTo)),
                )
            )
        val childState = MutableSceneTransitionLayoutState(childInitialScene, stateLinks = link)
        return Pair(
            parentState as MutableSceneTransitionLayoutStateImpl,
            childState as MutableSceneTransitionLayoutStateImpl,
        )
    }

    @Test
    fun linkedTransition_startsLinkAndFinishesLinkInToState() = runTest {
        val (parentState, childState) = setupLinkedStates()

        val childTransition = transition(SceneA, SceneB)

        val job =
            childState.startTransitionImmediately(animationScope = backgroundScope, childTransition)
        assertThat(childState.isTransitioning(SceneA, SceneB)).isTrue()
        assertThat(parentState.isTransitioning(SceneC, SceneD)).isTrue()

        childTransition.finish()
        job.join()
        assertThat(childState.transitionState).isEqualTo(TransitionState.Idle(SceneB))
        assertThat(parentState.transitionState).isEqualTo(TransitionState.Idle(SceneD))
    }

    @Test
    fun linkedTransition_transitiveLink() = runTest {
        val parentParentState =
            MutableSceneTransitionLayoutState(SceneB) as MutableSceneTransitionLayoutStateImpl
        val parentLink =
            listOf(
                StateLink(
                    parentParentState,
                    listOf(StateLink.TransitionLink(SceneC, SceneD, SceneB, SceneC)),
                )
            )
        val parentState =
            MutableSceneTransitionLayoutState(SceneC, stateLinks = parentLink)
                as MutableSceneTransitionLayoutStateImpl
        val link =
            listOf(
                StateLink(
                    parentState,
                    listOf(StateLink.TransitionLink(SceneA, SceneB, SceneC, SceneD)),
                )
            )
        val childState =
            MutableSceneTransitionLayoutState(SceneA, stateLinks = link)
                as MutableSceneTransitionLayoutStateImpl

        val childTransition = transition(SceneA, SceneB)

        val job =
            childState.startTransitionImmediately(animationScope = backgroundScope, childTransition)
        assertThat(childState.isTransitioning(SceneA, SceneB)).isTrue()
        assertThat(parentState.isTransitioning(SceneC, SceneD)).isTrue()
        assertThat(parentParentState.isTransitioning(SceneB, SceneC)).isTrue()

        childTransition.finish()
        job.join()
        assertThat(childState.transitionState).isEqualTo(TransitionState.Idle(SceneB))
        assertThat(parentState.transitionState).isEqualTo(TransitionState.Idle(SceneD))
        assertThat(parentParentState.transitionState).isEqualTo(TransitionState.Idle(SceneC))
    }

    @Test
    fun linkedTransition_linkProgressIsEqual() = runTest {
        val (parentState, childState) = setupLinkedStates()

        var progress = 0f
        val childTransition = transition(SceneA, SceneB, progress = { progress })

        childState.startTransitionImmediately(animationScope = backgroundScope, childTransition)
        assertThat(parentState.currentTransition?.progress).isEqualTo(0f)

        progress = .5f
        assertThat(parentState.currentTransition?.progress).isEqualTo(.5f)
    }

    @Test
    fun linkedTransition_reverseTransitionIsNotLinked() = runTest {
        val (parentState, childState) = setupLinkedStates()

        val childTransition = transition(SceneB, SceneA, current = { SceneB })

        val job =
            childState.startTransitionImmediately(animationScope = backgroundScope, childTransition)
        assertThat(childState.isTransitioning(SceneB, SceneA)).isTrue()
        assertThat(parentState.transitionState).isEqualTo(TransitionState.Idle(SceneC))

        childTransition.finish()
        job.join()
        assertThat(childState.transitionState).isEqualTo(TransitionState.Idle(SceneB))
        assertThat(parentState.transitionState).isEqualTo(TransitionState.Idle(SceneC))
    }

    @Test
    fun linkedTransition_startsLinkAndFinishesLinkInFromState() = runTest {
        val (parentState, childState) = setupLinkedStates()

        val childTransition = transition(SceneA, SceneB, current = { SceneA })
        val job =
            childState.startTransitionImmediately(animationScope = backgroundScope, childTransition)

        childTransition.finish()
        job.join()
        assertThat(childState.transitionState).isEqualTo(TransitionState.Idle(SceneA))
        assertThat(parentState.transitionState).isEqualTo(TransitionState.Idle(SceneC))
    }

    @Test
    fun linkedTransition_startsLinkButLinkedStateIsTakenOver() = runTest {
        val (parentState, childState) = setupLinkedStates()

        val childTransition = transition(SceneA, SceneB)
        val parentTransition = transition(SceneC, SceneA)
        val job =
            childState.startTransitionImmediately(animationScope = backgroundScope, childTransition)
        parentState.startTransitionImmediately(animationScope = backgroundScope, parentTransition)

        childTransition.finish()
        job.join()
        assertThat(childState.transitionState).isEqualTo(TransitionState.Idle(SceneB))
        assertThat(parentState.transitionState).isEqualTo(parentTransition)
    }

    @Test
    fun setTargetScene_withTransitionKey() = runMonotonicClockTest {
        val transitionkey = TransitionKey(debugName = "foo")
@@ -393,51 +251,6 @@ class SceneTransitionLayoutStateTest {
        assertThat(state.isTransitioning()).isTrue()
    }

    @Test
    fun linkedTransition_fuzzyLinksAreMatchedAndStarted() = runTest {
        val (parentState, childState) = setupLinkedStates(SceneC, SceneA, null, null, null, SceneD)
        val childTransition = transition(SceneA, SceneB)

        val job =
            childState.startTransitionImmediately(animationScope = backgroundScope, childTransition)
        assertThat(childState.isTransitioning(SceneA, SceneB)).isTrue()
        assertThat(parentState.isTransitioning(SceneC, SceneD)).isTrue()

        childTransition.finish()
        job.join()
        assertThat(childState.transitionState).isEqualTo(TransitionState.Idle(SceneB))
        assertThat(parentState.transitionState).isEqualTo(TransitionState.Idle(SceneD))
    }

    @Test
    fun linkedTransition_fuzzyLinksAreMatchedAndResetToProperPreviousScene() = runTest {
        val (parentState, childState) =
            setupLinkedStates(SceneC, SceneA, SceneA, null, null, SceneD)

        val childTransition = transition(SceneA, SceneB, current = { SceneA })

        val job =
            childState.startTransitionImmediately(animationScope = backgroundScope, childTransition)
        assertThat(childState.isTransitioning(SceneA, SceneB)).isTrue()
        assertThat(parentState.isTransitioning(SceneC, SceneD)).isTrue()

        childTransition.finish()
        job.join()
        assertThat(childState.transitionState).isEqualTo(TransitionState.Idle(SceneA))
        assertThat(parentState.transitionState).isEqualTo(TransitionState.Idle(SceneC))
    }

    @Test
    fun linkedTransition_fuzzyLinksAreNotMatched() = runTest {
        val (parentState, childState) =
            setupLinkedStates(SceneC, SceneA, SceneB, null, SceneC, SceneD)
        val childTransition = transition(SceneA, SceneB)

        childState.startTransitionImmediately(animationScope = backgroundScope, childTransition)
        assertThat(childState.isTransitioning(SceneA, SceneB)).isTrue()
        assertThat(parentState.isTransitioning(SceneC, SceneD)).isFalse()
    }

    private fun MonotonicClockTestScope.startOverscrollableTransistionFromAtoB(
        progress: () -> Float,
        sceneTransitions: SceneTransitions,
@@ -777,4 +590,15 @@ class SceneTransitionLayoutStateTest {
        assertThat(transition.progressTo(SceneA)).isEqualTo(1f - 0.2f)
        assertThrows(IllegalArgumentException::class.java) { transition.progressTo(SceneC) }
    }

    @Test
    fun transitionCanBeStartedOnlyOnce() = runTest {
        val state = MutableSceneTransitionLayoutState(SceneA)
        val transition = transition(from = SceneA, to = SceneB)

        state.startTransitionImmediately(backgroundScope, transition)
        assertThrows(IllegalStateException::class.java) {
            runBlocking { state.startTransition(transition) }
        }
    }
}