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

Commit 032991b8 authored by Mike Schneider's avatar Mike Schneider
Browse files

Add `keepRunningWhile()` to end MotionValue based on a condition

This allows clients to terminate a MotionValue when a state changes, as
opposed to manually cancel the coroutine.

Bug: 392534646
Test: MotionValueTest
Flag: com.android.systemui.scene_container
Change-Id: I333fa7467e8ef9a2de03c289e65e3b61e317db8b
parent f0956b91
Loading
Loading
Loading
Loading
+115 −78
Original line number Diff line number Diff line
@@ -28,8 +28,10 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.util.lerp
import androidx.compose.ui.util.packFloats
import androidx.compose.ui.util.packInts
import androidx.compose.ui.util.unpackFloat1
import androidx.compose.ui.util.unpackFloat2
import androidx.compose.ui.util.unpackInt1
import com.android.mechanics.debug.DebugInspector
import com.android.mechanics.debug.FrameData
import com.android.mechanics.spec.Breakpoint
@@ -43,10 +45,10 @@ import com.android.mechanics.spring.SpringState
import com.android.mechanics.spring.calculateUpdatedState
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.max
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext

/**
 * Computes an animated [output] value, by mapping the [currentInput] according to the [spec].
@@ -155,10 +157,28 @@ class MotionValue(
     *
     * Internally, this method does suspend, unless there are animations ongoing.
     */
    suspend fun keepRunning(): Nothing = coroutineScope {
        check(!isActive) { "keepRunning() invoked while already running" }
        isActive = true
        try {
    suspend fun keepRunning(): Nothing {
        keepRunningWhile { true }

        // `keepRunning` above will never finish,
        throw AssertionError("Unreachable code")
    }

    /**
     * Keeps the [MotionValue]'s animated output running while [continueRunning] returns `true`.
     *
     * When [continueRunning] returns `false`, the coroutine will end by the next frame.
     *
     * To keep the [MotionValue] running until the current animations are complete, check for
     * `isStable` as well.
     *
     * ```kotlin
     * motionValue.keepRunningWhile { !shouldEnd() || !isStable }
     * ```
     */
    suspend fun keepRunningWhile(continueRunning: MotionValue.() -> Boolean) =
        withContext(CoroutineName("MotionValue($label)")) {
            check(!isActive) { "MotionValue($label) is already running" }
            // The purpose of this implementation is to run an animation frame (via withFrameNanos)
            // whenever the input changes, or the spring is still settling, but otherwise just
            // suspend.
@@ -168,30 +188,49 @@ class MotionValue(

            // `true` while the spring is settling.
            var runAnimationFrames = !isStable
            launch {
            var cancellationRequested = !continueRunning.invoke(this@MotionValue)

            val observationJob = launch {
                // TODO(b/383979536) use a SnapshotStateObserver instead
                val runAnimationFramesFlag = 1 shl 0
                val cancellationRequestFlag = 1 shl 1

                snapshotFlow {
                        // observe all input values
                        var result = spec.hashCode()
                        result = result * 31 + currentInput().hashCode()
                        result = result * 31 + currentDirection.hashCode()
                        result = result * 31 + currentGestureDragOffset.hashCode()

                        // Track whether the spring needs animation frames to finish
                        // In fact, whether the spring is settling is the only relevant bit to
                        // export from here. For everything else, just cause the flow to emit a
                        // different value (hence the hashing)
                        (result shl 1) + if (isStable) 0 else 1
                    }
                    .collect { hashedState ->
                        // while the 'runAnimationFrames' bit was set on the result
                        runAnimationFrames = (hashedState and 1) != 0
                        // observe all input values.

                        // This part of the code does not actually care about the value itself - it
                        // merely wants to know whether they changed. The hashCode is used as an
                        // proxy for this.
                        var inputHash = spec.hashCode()
                        inputHash = inputHash * 31 + currentInput().hashCode()
                        inputHash = inputHash * 31 + currentDirection.hashCode()
                        inputHash = inputHash * 31 + currentGestureDragOffset.hashCode()

                        // The relevant things to observe here is whether `isStable` changed, or
                        // the `continueRunning()` is updated.
                        val doContinueRunning = continueRunning.invoke(this@MotionValue)

                        val stateFlags =
                            (if (!isStable) runAnimationFramesFlag else 0) or
                                (if (!doContinueRunning) cancellationRequestFlag else 0)

                        // Send both of it as packed value to avoid per-frame allocations.
                        packInts(stateFlags, inputHash)
                    }
                    .collect { packedState ->
                        val stateFlags = unpackInt1(packedState)
                        runAnimationFrames = (stateFlags and runAnimationFramesFlag) != 0
                        cancellationRequested = (stateFlags and cancellationRequestFlag) != 0

                        // nudge the animation runner in case its sleeping.
                        wakeupChannel.send(Unit)
                    }
            }

            while (true) {
            isActive = true
            try {
                while (!cancellationRequested) {

                    if (!runAnimationFrames) {
                        // While the spring does not need animation frames (its stable), wait until
                        // woken up - this can be for a single frame after an input change.
@@ -205,12 +244,12 @@ class MotionValue(
                    // At this point, the complete frame is done (including layout, drawing and
                    // everything else). What follows next is similar what one would do in a
                    // `SideEffect`, were this composable code:
                // If during the last frame, a new animation was started, or a new segment entered,
                // this state is copied over. If nothing changed, the computed `current*` state will
                // be the same, it won't have a side effect.
                    // If during the last frame, a new animation was started, or a new segment
                    // entered,  this state is copied over. If nothing changed, the computed
                    // `current*` state will be the same, it won't have a side effect.

                // Capturing the state here is required since crossing a breakpoint is an event -
                // the code has to record that this happened.
                    // Capturing the state here is required since crossing a breakpoint is an
                    // event - the code has to record that this happened.

                    // Important - capture all values first, and only afterwards update the state.
                    // Interleaving read and update might trigger immediate re-computations.
@@ -244,12 +283,10 @@ class MotionValue(
                            )
                    }
                }

            // Keep the compiler happy - the while (true) {} above will not complete, yet the
            // compiler wants a return value.
            @Suppress("UNREACHABLE_CODE") awaitCancellation()
            } finally {
                observationJob.cancel()
                isActive = false
                debugIsAnimating = false
            }
        }

+32 −2
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ package com.android.mechanics

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.TestMonotonicFrameClock
@@ -41,6 +42,7 @@ import com.android.mechanics.testing.MotionValueToolkit.Companion.isStable
import com.android.mechanics.testing.MotionValueToolkit.Companion.output
import com.android.mechanics.testing.goldenTest
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -337,7 +339,7 @@ class MotionValueTest {

    @Test
    fun keepRunning_concurrentInvocationThrows() = runTestWithFrameClock { testScheduler, _ ->
        val underTest = MotionValue({ 1f }, FakeGestureContext)
        val underTest = MotionValue({ 1f }, FakeGestureContext, label = "Foo")
        val realJob = launch { underTest.keepRunning() }
        testScheduler.runCurrent()

@@ -347,7 +349,7 @@ class MotionValueTest {
            // keepRunning returns Nothing, will never get here
        } catch (e: Throwable) {
            assertThat(e).isInstanceOf(IllegalStateException::class.java)
            assertThat(e).hasMessageThat().contains("keepRunning() invoked while already running")
            assertThat(e).hasMessageThat().contains("MotionValue(Foo) is already running")
        }
        assertThat(realJob.isActive).isTrue()
        realJob.cancel()
@@ -453,6 +455,34 @@ class MotionValueTest {
        assertThat(rule.mainClock.currentTime).isEqualTo(timeBeforeAutoAdvance + 176)
    }

    @Test
    fun keepRunningWhile_stopRunningWhileStable_endsImmediately() = runTest {
        val input = mutableFloatStateOf(0f)
        val spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One)
        val underTest = MotionValue(input::value, FakeGestureContext, spec)

        val continueRunning = mutableStateOf(true)

        rule.setContent {
            LaunchedEffect(Unit) { underTest.keepRunningWhile { continueRunning.value } }
        }

        val inspector = underTest.debugInspector()

        rule.awaitIdle()

        assertWithMessage("isActive").that(inspector.isActive).isTrue()
        assertWithMessage("isAnimating").that(inspector.isAnimating).isFalse()

        val timeBeforeStopRunning = rule.mainClock.currentTime
        continueRunning.value = false
        rule.awaitIdle()

        assertWithMessage("isActive").that(inspector.isActive).isFalse()
        assertWithMessage("isAnimating").that(inspector.isAnimating).isFalse()
        assertThat(rule.mainClock.currentTime).isEqualTo(timeBeforeStopRunning + 16)
    }

    @Test
    fun debugInspector_sameInstance_whileInUse() {
        val underTest = MotionValue({ 1f }, FakeGestureContext)