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

Commit 49eeb5b7 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Add support for predictive back in STL

Bug: 350705972
Test: Triggered back in the STL demo app
Test: Triggered back in Flexiglass
Test: atest SceneTransitionLayoutTest
Flag: com.android.systemui.scene_container
Change-Id: I2f870598210dbafc60422d72bb09ad3b96c2eaf2
parent b2ea8ff7
Loading
Loading
Loading
Loading
+130 −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

import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

@Composable
internal fun PredictiveBackHandler(
    state: BaseSceneTransitionLayoutState,
    coroutineScope: CoroutineScope,
    targetSceneForBack: SceneKey? = null,
) {
    PredictiveBackHandler(
        enabled = targetSceneForBack != null,
    ) { progress: Flow<BackEventCompat> ->
        val fromScene = state.transitionState.currentScene
        if (targetSceneForBack == null || targetSceneForBack == fromScene) {
            // Note: We have to collect progress otherwise PredictiveBackHandler will throw.
            progress.first()
            return@PredictiveBackHandler
        }

        val transition =
            PredictiveBackTransition(state, coroutineScope, fromScene, toScene = targetSceneForBack)
        state.startTransition(transition)
        try {
            progress.collect { backEvent -> transition.dragProgress = backEvent.progress }

            // Back gesture successful.
            transition.animateTo(
                if (state.canChangeScene(targetSceneForBack)) {
                    targetSceneForBack
                } else {
                    fromScene
                }
            )
        } catch (e: CancellationException) {
            // Back gesture cancelled.
            transition.animateTo(fromScene)
        }
    }
}

private class PredictiveBackTransition(
    val state: BaseSceneTransitionLayoutState,
    val coroutineScope: CoroutineScope,
    fromScene: SceneKey,
    toScene: SceneKey,
) : TransitionState.Transition(fromScene, toScene) {
    override var currentScene by mutableStateOf(fromScene)
        private set

    /** The animated progress once the gesture was committed or cancelled. */
    private var progressAnimatable by mutableStateOf<Animatable<Float, AnimationVector1D>?>(null)
    var dragProgress: Float by mutableFloatStateOf(0f)

    override val progress: Float
        get() = progressAnimatable?.value ?: dragProgress

    override val progressVelocity: Float
        get() = progressAnimatable?.velocity ?: 0f

    override val isInitiatedByUserInput: Boolean
        get() = true

    override val isUserInputOngoing: Boolean
        get() = progressAnimatable == null

    private var animationJob: Job? = null

    override fun finish(): Job = animateTo(currentScene)

    fun animateTo(scene: SceneKey): Job {
        check(scene == fromScene || scene == toScene)
        animationJob?.let {
            return it
        }

        currentScene = scene
        val targetProgress =
            when (scene) {
                fromScene -> 0f
                toScene -> 1f
                else -> error("scene $scene should be either $fromScene or $toScene")
            }

        val animatable = Animatable(dragProgress).also { progressAnimatable = it }

        // Important: We start atomically to make sure that we start the coroutine even if it is
        // cancelled right after it is launched, so that finishTransition() is correctly called.
        return coroutineScope
            .launch(start = CoroutineStart.ATOMIC) {
                try {
                    animatable.animateTo(targetProgress)
                } finally {
                    state.finishTransition(this@PredictiveBackTransition, scene)
                }
            }
            .also { animationJob = it }
    }
}
+2 −10
Original line number Diff line number Diff line
@@ -16,7 +16,6 @@

package com.android.compose.animation.scene

import androidx.activity.compose.BackHandler
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
@@ -213,16 +212,9 @@ internal class SceneTransitionLayoutImpl(

    @Composable
    private fun BackHandler() {
        val targetSceneForBackOrNull =
        val targetSceneForBack =
            scene(state.transitionState.currentScene).userActions[Back]?.toScene
        BackHandler(enabled = targetSceneForBackOrNull != null) {
            targetSceneForBackOrNull?.let { targetSceneForBack ->
                // TODO(b/290184746): Handle predictive back and use result.distance if specified.
                if (state.canChangeScene(targetSceneForBack)) {
                    with(state) { coroutineScope.onChangeScene(targetSceneForBack) }
                }
            }
        }
        PredictiveBackHandler(state, coroutineScope, targetSceneForBack)
    }

    private fun scenesToCompose(): List<Scene> {
+46 −1
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package com.android.compose.animation.scene

import androidx.activity.BackEventCompat
import androidx.activity.ComponentActivity
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
@@ -168,11 +169,46 @@ class SceneTransitionLayoutTest {

        assertThat(layoutState.transitionState).hasCurrentScene(SceneA)

        rule.activity.onBackPressed()
        rule.runOnUiThread { rule.activity.onBackPressedDispatcher.onBackPressed() }
        rule.waitForIdle()
        assertThat(layoutState.transitionState).hasCurrentScene(SceneB)
    }

    @Test
    fun testPredictiveBack() {
        rule.setContent { TestContent() }

        assertThat(layoutState.transitionState).hasCurrentScene(SceneA)

        // Start back.
        val dispatcher = rule.activity.onBackPressedDispatcher
        rule.runOnUiThread {
            dispatcher.dispatchOnBackStarted(backEvent())
            dispatcher.dispatchOnBackProgressed(backEvent(progress = 0.4f))
        }

        val transition = assertThat(layoutState.transitionState).isTransition()
        assertThat(transition).hasFromScene(SceneA)
        assertThat(transition).hasToScene(SceneB)
        assertThat(transition).hasProgress(0.4f)

        // Cancel it.
        rule.runOnUiThread { dispatcher.dispatchOnBackCancelled() }
        rule.waitForIdle()
        assertThat(layoutState.transitionState).hasCurrentScene(SceneA)
        assertThat(layoutState.transitionState).isIdle()

        // Start again and commit it.
        rule.runOnUiThread {
            dispatcher.dispatchOnBackStarted(backEvent())
            dispatcher.dispatchOnBackProgressed(backEvent(progress = 0.4f))
            dispatcher.onBackPressed()
        }
        rule.waitForIdle()
        assertThat(layoutState.transitionState).hasCurrentScene(SceneB)
        assertThat(layoutState.transitionState).isIdle()
    }

    @Test
    fun testTransitionState() {
        rule.setContent { TestContent() }
@@ -524,4 +560,13 @@ class SceneTransitionLayoutTest {
        assertThat(keyInB).isEqualTo(SceneB)
        assertThat(keyInC).isEqualTo(SceneC)
    }

    private fun backEvent(progress: Float = 0f): BackEventCompat {
        return BackEventCompat(
            touchX = 0f,
            touchY = 0f,
            progress = progress,
            swipeEdge = BackEventCompat.EDGE_LEFT,
        )
    }
}