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

Commit d15c088c authored by Mike Schneider's avatar Mike Schneider Committed by Android (Google) Code Review
Browse files

Merge "Implement `ViewMotionValue` runtime independent of compose" into main

parents a5f64fa3 8b4b9e72
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