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

Commit 8b4b9e72 authored by Mike Schneider's avatar Mike Schneider
Browse files

Implement `ViewMotionValue` runtime independent of compose

Provide a Java-like interface (intentionally using callbacks instead of
flows for a zero-requirements API)
Use the previously extracted `Computations` to compute the output.

This is only intended to be used where using Compose's Snapshot state is
not an option. All other clients should use the regular MotionValue (with SnapshotViewBinding if needed)

Flag: EXEMPT not used yet
Bug: 389637975
Test: atest mechanics_test
Change-Id: I3c4aed46f58ab2974deaff5763ed5c120ee2e0de
parent 6195ebbb
Loading
Loading
Loading
Loading
+135 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.mechanics.view

import android.content.Context
import android.view.ViewConfiguration
import androidx.compose.ui.util.fastForEach
import com.android.mechanics.spec.InputDirection
import kotlin.math.max
import kotlin.math.min

fun interface GestureContextUpdateListener {
    fun onGestureContextUpdated()
}

interface ViewGestureContext {
    val direction: InputDirection
    val dragOffset: Float

    fun addUpdateCallback(listener: GestureContextUpdateListener)

    fun removeUpdateCallback(listener: GestureContextUpdateListener)
}

/**
 * [ViewGestureContext] driven by a gesture distance.
 *
 * The direction is determined from the gesture input, where going further than
 * [directionChangeSlop] in the opposite direction toggles the direction.
 *
 * @param initialDragOffset The initial [dragOffset] of the [ViewGestureContext]
 * @param initialDirection The initial [direction] of the [ViewGestureContext]
 * @param directionChangeSlop the amount [dragOffset] must be moved in the opposite direction for
 *   the [direction] to flip.
 */
class DistanceGestureContext(
    initialDragOffset: Float,
    initialDirection: InputDirection,
    private val directionChangeSlop: Float,
) : ViewGestureContext {
    init {
        require(directionChangeSlop > 0) {
            "directionChangeSlop must be greater than 0, was $directionChangeSlop"
        }
    }

    companion object {

        fun create(
            context: Context,
            initialDragOffset: Float = 0f,
            initialDirection: InputDirection = InputDirection.Max,
        ): DistanceGestureContext {
            val directionChangeSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
            return DistanceGestureContext(initialDragOffset, initialDirection, directionChangeSlop)
        }
    }

    private val callbacks = mutableListOf<GestureContextUpdateListener>()

    override var dragOffset: Float = initialDragOffset
        set(value) {
            if (field == value) return

            field = value
            direction =
                when (direction) {
                    InputDirection.Max -> {
                        if (furthestDragOffset - value > directionChangeSlop) {
                            furthestDragOffset = value
                            InputDirection.Min
                        } else {
                            furthestDragOffset = max(value, furthestDragOffset)
                            InputDirection.Max
                        }
                    }

                    InputDirection.Min -> {
                        if (value - furthestDragOffset > directionChangeSlop) {
                            furthestDragOffset = value
                            InputDirection.Max
                        } else {
                            furthestDragOffset = min(value, furthestDragOffset)
                            InputDirection.Min
                        }
                    }
                }
            invokeCallbacks()
        }

    override var direction = initialDirection
        private set

    private var furthestDragOffset = initialDragOffset

    /**
     * Sets [dragOffset] and [direction] to the specified values.
     *
     * This also resets memoized [furthestDragOffset], which is used to determine the direction
     * change.
     */
    fun reset(dragOffset: Float, direction: InputDirection) {
        this.dragOffset = dragOffset
        this.direction = direction
        this.furthestDragOffset = dragOffset

        invokeCallbacks()
    }

    override fun addUpdateCallback(listener: GestureContextUpdateListener) {
        callbacks.add(listener)
    }

    override fun removeUpdateCallback(listener: GestureContextUpdateListener) {
        callbacks.remove(listener)
    }

    private fun invokeCallbacks() {
        callbacks.fastForEach { it.onGestureContextUpdated() }
    }
}
+338 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.mechanics.view

import android.animation.ValueAnimator
import androidx.compose.ui.util.fastForEach
import com.android.mechanics.MotionValue.Companion.StableThresholdEffect
import com.android.mechanics.debug.DebugInspector
import com.android.mechanics.debug.FrameData
import com.android.mechanics.impl.Computations
import com.android.mechanics.impl.DiscontinuityAnimation
import com.android.mechanics.impl.GuaranteeState
import com.android.mechanics.spec.InputDirection
import com.android.mechanics.spec.MotionSpec
import com.android.mechanics.spec.SegmentData
import com.android.mechanics.spring.SpringState
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.DisposableHandle

/** Observe MotionValue output changes. */
fun interface ViewMotionValueListener {
    /** Invoked whenever the ViewMotionValue computed a new output. */
    fun onMotionValueUpdated(motionValue: ViewMotionValue)
}

/**
 * [MotionValue] implementation for View-based UIs.
 *
 * See the documentation of [MotionValue].
 */
class ViewMotionValue(
    initialInput: Float,
    gestureContext: ViewGestureContext,
    initialSpec: MotionSpec = MotionSpec.Empty,
    label: String? = null,
    stableThreshold: Float = StableThresholdEffect,
) : DisposableHandle {

    private val impl =
        ImperativeComputations(
            this,
            initialInput,
            gestureContext,
            initialSpec,
            stableThreshold,
            label,
        )

    var input: Float by impl::currentInput

    var spec: MotionSpec by impl::spec

    /** Animated [output] value. */
    val output: Float by impl::output

    /**
     * [output] value, but without animations.
     *
     * This value always reports the target value, even before a animation is finished.
     *
     * While [isStable], [outputTarget] and [output] are the same value.
     */
    val outputTarget: Float by impl::outputTarget

    /** Whether an animation is currently running. */
    val isStable: Boolean by impl::isStable

    val label: String? by impl::label

    fun addUpdateCallback(listener: ViewMotionValueListener) {
        check(impl.isActive)
        impl.listeners.add(listener)
    }

    fun removeUpdateCallback(listener: ViewMotionValueListener) {
        impl.listeners.remove(listener)
    }

    override fun dispose() {
        impl.dispose()
    }

    companion object {
        internal const val TAG = "ViewMotionValue"
    }

    private var debugInspectorRefCount = AtomicInteger(0)

    private fun onDisposeDebugInspector() {
        if (debugInspectorRefCount.decrementAndGet() == 0) {
            impl.debugInspector = null
        }
    }

    /**
     * Provides access to internal state for debug tooling and tests.
     *
     * The returned [DebugInspector] must be [DebugInspector.dispose]d when no longer needed.
     */
    fun debugInspector(): DebugInspector {
        if (debugInspectorRefCount.getAndIncrement() == 0) {
            impl.debugInspector =
                DebugInspector(
                    FrameData(
                        impl.lastInput,
                        impl.lastSegment.direction,
                        impl.lastGestureDragOffset,
                        impl.lastFrameTimeNanos,
                        impl.lastSpringState,
                        impl.lastSegment,
                        impl.lastAnimation,
                    ),
                    impl.isActive,
                    impl.animationFrameDriver.isRunning,
                    ::onDisposeDebugInspector,
                )
        }

        return checkNotNull(impl.debugInspector)
    }
}

private class ImperativeComputations(
    private val motionValue: ViewMotionValue,
    initialInput: Float,
    val gestureContext: ViewGestureContext,
    initialSpec: MotionSpec,
    override val stableThreshold: Float,
    override val label: String?,
) : Computations, GestureContextUpdateListener {

    init {
        gestureContext.addUpdateCallback(this)
    }

    override fun onGestureContextUpdated() {
        ensureFrameRequested()
    }

    // ----  CurrentFrameInput ---------------------------------------------------------------------

    override var spec: MotionSpec = initialSpec
        set(value) {
            if (field != value) {
                field = value
                ensureFrameRequested()
            }
        }

    override var currentInput: Float = initialInput
        set(value) {
            if (field != value) {
                field = value
                ensureFrameRequested()
            }
        }

    override val currentDirection: InputDirection
        get() = gestureContext.direction

    override val currentGestureDragOffset: Float
        get() = gestureContext.dragOffset

    override var currentAnimationTimeNanos: Long = -1L

    // ----  LastFrameState ---------------------------------------------------------------------

    override var lastSegment: SegmentData = spec.segmentAtInput(currentInput, currentDirection)
    override var lastGuaranteeState: GuaranteeState = GuaranteeState.Inactive
    override var lastAnimation: DiscontinuityAnimation = DiscontinuityAnimation.None
    override var lastSpringState: SpringState = lastAnimation.springStartState
    override var lastFrameTimeNanos: Long = -1L
    override var lastInput: Float = currentInput
    override var lastGestureDragOffset: Float = currentGestureDragOffset
    override var directMappedVelocity: Float = 0f
    var lastDirection: InputDirection = currentDirection

    // ---- Computations ---------------------------------------------------------------------------

    override var currentSegment: SegmentData = computeCurrentSegment()
    override var currentGuaranteeState: GuaranteeState = computeCurrentGuaranteeState()
    override var currentAnimation: DiscontinuityAnimation = computeCurrentAnimation()
    override var currentSpringState: SpringState = computeCurrentSpringState()

    // ---- Lifecycle ------------------------------------------------------------------------------

    // HACK: Use a ValueAnimator to listen to animation frames without using Choreographer directly.
    // This is done solely for testability - because the AnimationHandler is not usable directly[1],
    // this resumes/pauses a - for all practical purposes - infinite animation.
    //
    // [1] the android one is hidden API, the androidx one is package private, and the
    // dynamicanimation one is not controllable from tests).
    val animationFrameDriver =
        ValueAnimator().apply {
            setFloatValues(Float.MIN_VALUE, Float.MAX_VALUE)
            duration = Long.MAX_VALUE
            repeatMode = ValueAnimator.RESTART
            repeatCount = ValueAnimator.INFINITE
            start()
            pause()
            addUpdateListener {
                val isAnimationFinished = updateOutputValue(currentPlayTime)
                if (isAnimationFinished) {
                    pause()
                }
            }
        }

    fun ensureFrameRequested() {
        if (animationFrameDriver.isPaused) {
            animationFrameDriver.resume()
            debugInspector?.isAnimating = true
        }
    }

    fun pauseFrameRequests() {
        if (animationFrameDriver.isRunning) {
            animationFrameDriver.pause()
            debugInspector?.isAnimating = false
        }
    }

    /** `true` until disposed with [MotionValue.dispose]. */
    var isActive = true
        set(value) {
            field = value
            debugInspector?.isActive = value
        }

    var debugInspector: DebugInspector? = null

    val listeners = mutableListOf<ViewMotionValueListener>()

    fun dispose() {
        check(isActive) { "ViewMotionValue[$label] is already disposed" }
        pauseFrameRequests()
        animationFrameDriver.end()
        isActive = false
        listeners.clear()
    }

    // indicates whether doAnimationFrame is called continuously (as opposed to being
    // suspended for an undetermined amount of time in between frames).
    var isAnimatingUninterrupted = false

    fun updateOutputValue(frameTimeMillis: Long): Boolean {
        check(isActive) { "ViewMotionValue($label) is already disposed." }

        currentAnimationTimeNanos = frameTimeMillis * 1_000_000L

        currentSegment = computeCurrentSegment()
        currentGuaranteeState = computeCurrentGuaranteeState()
        currentAnimation = computeCurrentAnimation()
        currentSpringState = computeCurrentSpringState()

        debugInspector?.run {
            frame =
                FrameData(
                    currentInput,
                    currentDirection,
                    currentGestureDragOffset,
                    currentAnimationTimeNanos,
                    currentSpringState,
                    currentSegment,
                    currentAnimation,
                )
        }

        listeners.fastForEach { it.onMotionValueUpdated(motionValue) }

        // Prepare last* state
        if (isAnimatingUninterrupted) {
            val currentDirectMapped = currentDirectMapped
            val lastDirectMapped = lastSegment.mapping.map(lastInput) - lastAnimation.targetValue

            val frameDuration = (currentAnimationTimeNanos - lastFrameTimeNanos) / 1_000_000_000.0
            val staticDelta = (currentDirectMapped - lastDirectMapped)
            directMappedVelocity = (staticDelta / frameDuration).toFloat()
        } else {
            directMappedVelocity = 0f
        }

        var isAnimationFinished = isStable
        if (lastSegment != currentSegment) {
            lastSegment = currentSegment
            isAnimationFinished = false
        }

        if (lastGuaranteeState != currentGuaranteeState) {
            lastGuaranteeState = currentGuaranteeState
            isAnimationFinished = false
        }

        if (lastAnimation != currentAnimation) {
            lastAnimation = currentAnimation
            isAnimationFinished = false
        }

        if (lastSpringState != currentSpringState) {
            lastSpringState = currentSpringState
            isAnimationFinished = false
        }

        if (lastInput != currentInput) {
            lastInput = currentInput
            isAnimationFinished = false
        }

        if (lastGestureDragOffset != currentGestureDragOffset) {
            lastGestureDragOffset = currentGestureDragOffset
            isAnimationFinished = false
        }

        if (lastDirection != currentDirection) {
            lastDirection = currentDirection
            isAnimationFinished = false
        }

        lastFrameTimeNanos = currentAnimationTimeNanos
        isAnimatingUninterrupted = !isAnimationFinished

        return isAnimationFinished
    }
}
+1 −0
Original line number Diff line number Diff line
@@ -46,6 +46,7 @@ android_test {
        "androidx.test.runner",
        "androidx.test.ext.junit",
        "kotlin-test",
        "testables",
        "truth",
    ],
    asset_dirs: ["goldens"],
+12 −0
Original line number Diff line number Diff line
@@ -17,6 +17,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.android.mechanics.tests">

    <application android:debuggable="true">
        <activity
            android:name="com.android.mechanics.testing.EmptyTestActivity"
            android:exported="false">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.DEFAULT" />
            </intent-filter>
        </activity>
        <uses-library android:name="android.test.runner" />
    </application>

    <instrumentation
        android:name="androidx.test.runner.AndroidJUnitRunner"
        android:label="Tests for Motion Mechanics"
+112 −0
Original line number Diff line number Diff line
{
  "frame_ids": [
    0,
    16,
    32,
    48,
    64,
    80,
    96
  ],
  "features": [
    {
      "name": "input",
      "type": "float",
      "data_points": [
        0,
        0,
        20,
        40,
        60,
        80,
        100
      ]
    },
    {
      "name": "gestureDirection",
      "type": "string",
      "data_points": [
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max",
        "Max"
      ]
    },
    {
      "name": "output",
      "type": "float",
      "data_points": [
        0,
        0,
        20,
        40,
        60,
        80,
        100
      ]
    },
    {
      "name": "outputTarget",
      "type": "float",
      "data_points": [
        0,
        0,
        20,
        40,
        60,
        80,
        100
      ]
    },
    {
      "name": "outputSpring",
      "type": "springParameters",
      "data_points": [
        {
          "stiffness": 100000,
          "dampingRatio": 1
        },
        {
          "stiffness": 100000,
          "dampingRatio": 1
        },
        {
          "stiffness": 100000,
          "dampingRatio": 1
        },
        {
          "stiffness": 100000,
          "dampingRatio": 1
        },
        {
          "stiffness": 100000,
          "dampingRatio": 1
        },
        {
          "stiffness": 100000,
          "dampingRatio": 1
        },
        {
          "stiffness": 100000,
          "dampingRatio": 1
        }
      ]
    },
    {
      "name": "isStable",
      "type": "boolean",
      "data_points": [
        true,
        true,
        true,
        true,
        true,
        true,
        true
      ]
    }
  ]
}
 No newline at end of file
Loading