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

Commit 86128b7f authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Expose new STLState.seekFoo() APIs

This CL exposes new APIs on MutableSTLState to easily seek a transition.
The APIs are based on the predictive back APIs: the transitions are
driven by a Flow and the transition is confirmed once the flow stops
emitting normally, or cancelled if the flow is cancelled.

Given that those new APIs are suspend functions, waiting for the
animation to finish or cancelling it works out of the box.

Bug: 362727477
Test: atest SceneTransitionLayoutStateTest
Flag: com.android.systemui.scene_container
Change-Id: Idc36c1eb2fff7c5f299e36cc2a495b921b878cf1
parent 71399a66
Loading
Loading
Loading
Loading
+16 −45
Original line number Diff line number Diff line
@@ -18,18 +18,17 @@ package com.android.compose.animation.scene

import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.snap
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.runtime.Composable
import com.android.compose.animation.scene.UserActionResult.ChangeScene
import com.android.compose.animation.scene.UserActionResult.HideOverlay
import com.android.compose.animation.scene.UserActionResult.ReplaceByOverlay
import com.android.compose.animation.scene.UserActionResult.ShowOverlay
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.coroutineScope
import com.android.compose.animation.scene.transition.animateProgress
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.map

@Composable
internal fun PredictiveBackHandler(
@@ -38,10 +37,10 @@ internal fun PredictiveBackHandler(
) {
    PredictiveBackHandler(
        enabled = result != null,
    ) { progress: Flow<BackEventCompat> ->
    ) { events: Flow<BackEventCompat> ->
        if (result == null) {
            // Note: We have to collect progress otherwise PredictiveBackHandler will throw.
            progress.first()
            events.first()
            return@PredictiveBackHandler
        }

@@ -60,49 +59,21 @@ internal fun PredictiveBackHandler(
                distance = 1f,
            )

        animate(layoutImpl, animation, progress)
    }
}
        animateProgress(
            state = layoutImpl.state,
            animation = animation,
            progress = events.map { it.progress },

private suspend fun <T : ContentKey> animate(
    layoutImpl: SceneTransitionLayoutImpl,
    animation: SwipeAnimation<T>,
    progress: Flow<BackEventCompat>,
) {
    fun animateOffset(targetContent: T, spec: AnimationSpec<Float>? = null) {
        if (
            layoutImpl.state.transitionState != animation.contentTransition ||
                animation.isAnimatingOffset()
        ) {
            return
        }
            // Use the transformationSpec.progressSpec. We will lazily access it later once the
            // transition has been started, because at this point the transformation spec of the
            // transition is not computed yet.
            commitSpec = null,

        animation.animateOffset(
            initialVelocity = 0f,
            targetContent = targetContent,
            spec = spec,
            // The predictive back APIs will automatically animate the progress for us in this case
            // so there is no need to animate it.
            cancelSpec = snap(),
        )
    }

    coroutineScope {
        launch {
            try {
                progress.collect { backEvent -> animation.dragOffset = backEvent.progress }

                // Back gesture successful.
                animateOffset(
                    animation.toContent,
                    animation.contentTransition.transformationSpec.progressSpec,
                )
            } catch (e: CancellationException) {
                // Back gesture cancelled.
                animateOffset(animation.fromContent)
            }
        }

        // Start the transition.
        layoutImpl.state.startTransition(animation.contentTransition)
    }
}

private fun UserActionResult.copy(
+187 −0
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

import androidx.annotation.FloatRange
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.ui.util.fastCoerceIn
import com.android.compose.animation.scene.ContentKey
import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
import com.android.compose.animation.scene.MutableSceneTransitionLayoutStateImpl
import com.android.compose.animation.scene.OverlayKey
import com.android.compose.animation.scene.SceneKey
import com.android.compose.animation.scene.SwipeAnimation
import com.android.compose.animation.scene.TransitionKey
import com.android.compose.animation.scene.UserActionResult
import com.android.compose.animation.scene.createSwipeAnimation
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch

/**
 * Seek to the given [scene] using [progress].
 *
 * This will start a transition from the
 * [current scene][MutableSceneTransitionLayoutState.currentScene] to [scene], driven by the
 * progress in [progress]. Once [progress] stops emitting, we will animate progress to 1f (using
 * [animationSpec]) if it stopped normally or to 0f if it stopped with a
 * [kotlin.coroutines.cancellation.CancellationException].
 */
suspend fun MutableSceneTransitionLayoutState.seekToScene(
    scene: SceneKey,
    @FloatRange(0.0, 1.0) progress: Flow<Float>,
    transitionKey: TransitionKey? = null,
    animationSpec: AnimationSpec<Float>? = null,
) {
    require(scene != currentScene) {
        "seekToScene($scene) has to be called with a different scene than the current scene"
    }

    seek(UserActionResult.ChangeScene(scene, transitionKey), progress, animationSpec)
}

/**
 * Seek to show the given [overlay] using [progress].
 *
 * This will start a transition to show [overlay] from the
 * [current scene][MutableSceneTransitionLayoutState.currentScene], driven by the progress in
 * [progress]. Once [progress] stops emitting, we will animate progress to 1f (using
 * [animationSpec]) if it stopped normally or to 0f if it stopped with a
 * [kotlin.coroutines.cancellation.CancellationException].
 */
suspend fun MutableSceneTransitionLayoutState.seekToShowOverlay(
    overlay: OverlayKey,
    @FloatRange(0.0, 1.0) progress: Flow<Float>,
    transitionKey: TransitionKey? = null,
    animationSpec: AnimationSpec<Float>? = null,
) {
    require(overlay in currentOverlays) {
        "seekToShowOverlay($overlay) can be called only when the overlay is in currentOverlays"
    }

    seek(UserActionResult.ShowOverlay(overlay, transitionKey), progress, animationSpec)
}

/**
 * Seek to hide the given [overlay] using [progress].
 *
 * This will start a transition to hide [overlay] to the
 * [current scene][MutableSceneTransitionLayoutState.currentScene], driven by the progress in
 * [progress]. Once [progress] stops emitting, we will animate progress to 1f (using
 * [animationSpec]) if it stopped normally or to 0f if it stopped with a
 * [kotlin.coroutines.cancellation.CancellationException].
 */
suspend fun MutableSceneTransitionLayoutState.seekToHideOverlay(
    overlay: OverlayKey,
    @FloatRange(0.0, 1.0) progress: Flow<Float>,
    transitionKey: TransitionKey? = null,
    animationSpec: AnimationSpec<Float>? = null,
) {
    require(overlay !in currentOverlays) {
        "seekToHideOverlay($overlay) can be called only when the overlay is *not* in " +
            "currentOverlays"
    }

    seek(UserActionResult.HideOverlay(overlay, transitionKey), progress, animationSpec)
}

private suspend fun MutableSceneTransitionLayoutState.seek(
    result: UserActionResult,
    progress: Flow<Float>,
    animationSpec: AnimationSpec<Float>?,
) {
    val layoutState =
        when (this) {
            is MutableSceneTransitionLayoutStateImpl -> this
        }

    val swipeAnimation =
        createSwipeAnimation(
            layoutState = layoutState,
            result = result,

            // We are animating progress, so distance is always 1f.
            distance = 1f,

            // The orientation and isUpOrLeft don't matter here given that they are only used during
            // overscroll, which is disabled for progress-based transitions.
            orientation = Orientation.Horizontal,
            isUpOrLeft = false,
        )

    animateProgress(
        state = layoutState,
        animation = swipeAnimation,
        progress = progress,
        commitSpec = animationSpec,
        cancelSpec = animationSpec,
    )
}

internal suspend fun <T : ContentKey> animateProgress(
    state: MutableSceneTransitionLayoutStateImpl,
    animation: SwipeAnimation<T>,
    progress: Flow<Float>,
    commitSpec: AnimationSpec<Float>?,
    cancelSpec: AnimationSpec<Float>?,
) {
    fun animateOffset(targetContent: T, spec: AnimationSpec<Float>?) {
        if (state.transitionState != animation.contentTransition || animation.isAnimatingOffset()) {
            return
        }

        animation.animateOffset(
            initialVelocity = 0f,
            targetContent = targetContent,

            // Important: we have to specify a spec that correctly animates *progress* (low
            // visibility threshold) and not *offset* (higher visibility threshold).
            spec = spec ?: animation.contentTransition.transformationSpec.progressSpec,
        )
    }

    coroutineScope {
        val collectionJob = launch {
            try {
                progress.collectLatest { progress ->
                    // Progress based animation should never overscroll given that the
                    // absoluteDistance exposed to overscroll builders is always 1f and will not
                    // lead to any noticeable transformation.
                    animation.dragOffset = progress.fastCoerceIn(0f, 1f)
                }

                // Transition committed.
                animateOffset(animation.toContent, commitSpec)
            } catch (e: CancellationException) {
                // Transition cancelled.
                animateOffset(animation.fromContent, cancelSpec)
            }
        }

        // Start the transition.
        state.startTransition(animation.contentTransition)

        // The transition is done. Cancel the collection in case the transition was finished because
        // it was interrupted by another transition.
        if (collectionJob.isActive) {
            collectionJob.cancel()
        }
    }
}
+75 −0
Original line number Diff line number Diff line
@@ -30,13 +30,17 @@ 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.TestTransition
import com.android.compose.test.runMonotonicClockTest
import com.android.compose.test.transition
import com.google.common.truth.Truth.assertThat
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -660,4 +664,75 @@ class SceneTransitionLayoutStateTest {
        assertThat(state.transitionState).isIdle()
        assertThat(state.transitionState).hasCurrentScene(SceneC)
    }

    @Test
    fun seekToScene() = runMonotonicClockTest {
        val state = MutableSceneTransitionLayoutState(SceneA)
        val progress = Channel<Float>()

        val job =
            launch(start = CoroutineStart.UNDISPATCHED) {
                state.seekToScene(SceneB, progress.consumeAsFlow())
            }

        val transition = assertThat(state.transitionState).isSceneTransition()
        assertThat(transition).hasFromScene(SceneA)
        assertThat(transition).hasToScene(SceneB)
        assertThat(transition).hasProgress(0f)

        // Change progress.
        progress.send(0.4f)
        assertThat(transition).hasProgress(0.4f)

        // Close the channel normally to confirm the transition.
        progress.close()
        job.join()
        assertThat(state.transitionState).isIdle()
        assertThat(state.transitionState).hasCurrentScene(SceneB)
    }

    @Test
    fun seekToScene_cancelled() = runMonotonicClockTest {
        val state = MutableSceneTransitionLayoutState(SceneA)
        val progress = Channel<Float>()

        val job =
            launch(start = CoroutineStart.UNDISPATCHED) {
                state.seekToScene(SceneB, progress.consumeAsFlow())
            }

        val transition = assertThat(state.transitionState).isSceneTransition()
        assertThat(transition).hasFromScene(SceneA)
        assertThat(transition).hasToScene(SceneB)
        assertThat(transition).hasProgress(0f)

        // Change progress.
        progress.send(0.4f)
        assertThat(transition).hasProgress(0.4f)

        // Close the channel with a CancellationException to cancel the transition.
        progress.close(CancellationException())
        job.join()
        assertThat(state.transitionState).isIdle()
        assertThat(state.transitionState).hasCurrentScene(SceneA)
    }

    @Test
    fun seekToScene_interrupted() = runMonotonicClockTest {
        val state = MutableSceneTransitionLayoutState(SceneA)
        val progress = Channel<Float>()

        val job =
            launch(start = CoroutineStart.UNDISPATCHED) {
                state.seekToScene(SceneB, progress.consumeAsFlow())
            }

        assertThat(state.transitionState).isSceneTransition()

        // Start a new transition, interrupting the seek transition.
        state.setTargetScene(SceneB, coroutineScope = this)

        // The previous job is cancelled and does not infinitely collect the progress.
        job.join()
    }
}