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

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

Merge "Remove STLState.seekTo{Scene,ShowOverlay,HideOverlay}" into main

parents 934dd3a2 bc25f13a
Loading
Loading
Loading
Loading
+68 −1
Original line number Diff line number Diff line
@@ -18,19 +18,26 @@ 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 androidx.compose.ui.util.fastCoerceIn
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 com.android.compose.animation.scene.transition.animateProgress
import com.android.mechanics.ProvidedGestureContext
import com.android.mechanics.spec.InputDirection
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch

@Composable
internal fun PredictiveBackHandler(
@@ -93,3 +100,63 @@ private fun UserActionResult.copy(
        is ReplaceByOverlay -> copy(transitionKey = transitionKey)
    }
}

private suspend fun <T : ContentKey> animateProgress(
    state: MutableSceneTransitionLayoutStateImpl,
    animation: SwipeAnimation<T>,
    progress: Flow<Float>,
    commitSpec: AnimationSpec<Float>?,
    cancelSpec: AnimationSpec<Float>?,
    animationScope: CoroutineScope? = null,
) {
    suspend 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.
        animationScope?.launch { startTransition(state, animation, collectionJob) }
            ?: startTransition(state, animation, collectionJob)
    }
}

private suspend fun <T : ContentKey> startTransition(
    state: MutableSceneTransitionLayoutStateImpl,
    animation: SwipeAnimation<T>,
    progressCollectionJob: Job,
) {
    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 (progressCollectionJob.isActive) {
        progressCollectionJob.cancel()
    }
}
+0 −203
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 com.android.mechanics.ProvidedGestureContext
import com.android.mechanics.spec.InputDirection
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
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,
            // There is no gesture information available here - animateProgress
            // will set the progress as the dragOffset.
            gestureContext = ProvidedGestureContext(0f, InputDirection.Max),
        )

    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>?,
    animationScope: CoroutineScope? = null,
) {
    suspend 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.
        animationScope?.launch { startTransition(state, animation, collectionJob) }
            ?: startTransition(state, animation, collectionJob)
    }
}

private suspend fun <T : ContentKey> startTransition(
    state: MutableSceneTransitionLayoutStateImpl,
    animation: SwipeAnimation<T>,
    progressCollectionJob: Job,
) {
    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 (progressCollectionJob.isActive) {
        progressCollectionJob.cancel()
    }
}
+0 −75
Original line number Diff line number Diff line
@@ -24,18 +24,14 @@ import com.android.compose.animation.scene.TestScenes.SceneB
import com.android.compose.animation.scene.TestScenes.SceneC
import com.android.compose.animation.scene.content.state.TransitionState
import com.android.compose.animation.scene.subjects.assertThat
import com.android.compose.animation.scene.transition.seekToScene
import com.android.compose.test.TestSceneTransition
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.CompletableDeferred
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.awaitCancellation
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.runCurrent
@@ -281,77 +277,6 @@ class SceneTransitionLayoutStateTest {
        assertThat(state.transitionState).hasCurrentScene(SceneC)
    }

    @Test
    fun seekToScene() = runMonotonicClockTest {
        val state = MutableSceneTransitionLayoutStateForTests(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 = MutableSceneTransitionLayoutStateForTests(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 = MutableSceneTransitionLayoutStateForTests(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, animationScope = this)

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

    @Test
    fun replacedTransitionIsRemovedFromFinishedTransitions() = runTest {
        val state = MutableSceneTransitionLayoutStateForTests(SceneA)