Loading mechanics/src/com/android/mechanics/view/ViewGestureContext.kt 0 → 100644 +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() } } } mechanics/src/com/android/mechanics/view/ViewMotionValue.kt 0 → 100644 +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 } } mechanics/tests/Android.bp +1 −0 Original line number Diff line number Diff line Loading @@ -46,6 +46,7 @@ android_test { "androidx.test.runner", "androidx.test.ext.junit", "kotlin-test", "testables", "truth", ], asset_dirs: ["goldens"], Loading mechanics/tests/AndroidManifest.xml +12 −0 Original line number Diff line number Diff line Loading @@ -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" Loading mechanics/tests/goldens/view/emptySpec_outputMatchesInput_withoutAnimation.json 0 → 100644 +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
mechanics/src/com/android/mechanics/view/ViewGestureContext.kt 0 → 100644 +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() } } }
mechanics/src/com/android/mechanics/view/ViewMotionValue.kt 0 → 100644 +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 } }
mechanics/tests/Android.bp +1 −0 Original line number Diff line number Diff line Loading @@ -46,6 +46,7 @@ android_test { "androidx.test.runner", "androidx.test.ext.junit", "kotlin-test", "testables", "truth", ], asset_dirs: ["goldens"], Loading
mechanics/tests/AndroidManifest.xml +12 −0 Original line number Diff line number Diff line Loading @@ -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" Loading
mechanics/tests/goldens/view/emptySpec_outputMatchesInput_withoutAnimation.json 0 → 100644 +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