Loading mechanics/Android.bp +1 −0 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ android_library { min_sdk_version: "current", static_libs: [ "androidx.compose.runtime_runtime", "androidx.compose.material3_material3", "androidx.compose.ui_ui-util", "androidx.compose.foundation_foundation-layout", ], Loading mechanics/src/com/android/mechanics/debug/DebugVisualization.kt +137 −40 Original line number Diff line number Diff line Loading @@ -17,18 +17,26 @@ package com.android.mechanics.debug import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.ObserverModifierNode Loading @@ -39,9 +47,13 @@ import androidx.compose.ui.util.fastForEachIndexed import com.android.mechanics.MotionValue import com.android.mechanics.spec.DirectionalMotionSpec import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.MotionSpec import com.android.mechanics.spec.SegmentKey import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** * A debug visualization of the [motionValue]. Loading @@ -52,25 +64,48 @@ import kotlin.math.min * * @param motionValue The [MotionValue] to inspect. * @param inputRange The relevant range of the input (x) axis, for which to draw the graph. * @param color Color for the dots indicating the value * @param historySize Number of past values to draw as a trail. * @param maxAgeMillis Max age of the elements in the history trail. */ @Composable fun DebugMotionValueVisualization( motionValue: MotionValue, inputRange: ClosedFloatingPointRange<Float>, modifier: Modifier = Modifier, color: Color = Color.DarkGray, historySize: Int = 100, maxAgeMillis: Long = 1000L, ) { val spec = motionValue.spec val outputRange = remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) } val inspector = remember(motionValue) { motionValue.debugInspector() } DisposableEffect(inspector) { onDispose { inspector.dispose() } } val colorScheme = MaterialTheme.colorScheme val axisColor = colorScheme.outline val specColor = colorScheme.tertiary val valueColor = colorScheme.primary val primarySpec = motionValue.spec.get(inspector.frame.gestureDirection) val activeSegment = inspector.frame.segmentKey Spacer( modifier = modifier .debugMotionSpecGraph(spec, inputRange, outputRange) .debugMotionValueGraph(motionValue, color, inputRange, outputRange, historySize) .debugMotionSpecGraph( primarySpec, inputRange, outputRange, axisColor, specColor, activeSegment, ) .debugMotionValueGraph( motionValue, valueColor, inputRange, outputRange, maxAgeMillis, ) ) } Loading @@ -83,17 +118,15 @@ fun DebugMotionValueVisualization( * @param outputRange The range of the output (y) axis. */ fun Modifier.debugMotionSpecGraph( spec: MotionSpec, spec: DirectionalMotionSpec, inputRange: ClosedFloatingPointRange<Float>, outputRange: ClosedFloatingPointRange<Float>, axisColor: Color = Color.Gray, specColor: Color = Color.Blue, activeSegment: SegmentKey? = null, ): Modifier = drawBehind { drawAxis(Color.Gray) if (spec.isUnidirectional) { drawDirectionalSpec(spec.maxDirection, inputRange, outputRange, Color.Red) } else { drawDirectionalSpec(spec.minDirection, inputRange, outputRange, Color.Red) drawDirectionalSpec(spec.maxDirection, inputRange, outputRange, Color.Blue) } drawAxis(axisColor) drawDirectionalSpec(spec, inputRange, outputRange, specColor, activeSegment) } /** Loading @@ -107,7 +140,7 @@ fun Modifier.debugMotionSpecGraph( * @param color Color for the dots indicating the value * @param inputRange The range of the input (x) axis * @param outputRange The range of the output (y) axis. * @param historySize Number of past values to draw as a trail. * @param maxAgeMillis Max age of the elements in the history trail. */ @Composable fun Modifier.debugMotionValueGraph( Loading @@ -115,9 +148,10 @@ fun Modifier.debugMotionValueGraph( color: Color, inputRange: ClosedFloatingPointRange<Float>, outputRange: ClosedFloatingPointRange<Float>, historySize: Int = 100, maxAgeMillis: Long = 1000L, ): Modifier = this then DebugMotionValueGraphElement(motionValue, color, inputRange, outputRange, historySize) this then DebugMotionValueGraphElement(motionValue, color, inputRange, outputRange, maxAgeMillis) /** * Utility to compute the min/max output values of the spec for the given input. Loading Loading @@ -176,22 +210,22 @@ private data class DebugMotionValueGraphElement( val color: Color, val inputRange: ClosedFloatingPointRange<Float>, val outputRange: ClosedFloatingPointRange<Float>, val historySize: Int, val maxAgeMillis: Long, ) : ModifierNodeElement<DebugMotionValueGraphNode>() { init { require(historySize > 0) require(maxAgeMillis > 0) } override fun create() = DebugMotionValueGraphNode(motionValue, color, inputRange, outputRange, historySize) DebugMotionValueGraphNode(motionValue, color, inputRange, outputRange, maxAgeMillis) override fun update(node: DebugMotionValueGraphNode) { node.motionValue = motionValue node.color = color node.inputRange = inputRange node.outputRange = outputRange node.historySize = historySize node.maxAgeMillis = maxAgeMillis } override fun InspectorInfo.inspectableProperties() { Loading @@ -204,21 +238,12 @@ private class DebugMotionValueGraphNode( var color: Color, var inputRange: ClosedFloatingPointRange<Float>, var outputRange: ClosedFloatingPointRange<Float>, historySize: Int, var maxAgeMillis: Long, ) : DrawModifierNode, ObserverModifierNode, Modifier.Node() { private var debugInspector by mutableStateOf<DebugInspector?>(null) private val history = mutableStateListOf<FrameData>() var historySize = historySize set(value) { field = value if (history.size > value) { history.removeRange(0, value - historySize) } } var motionValue = motionValue set(value) { if (value != field) { Loading @@ -233,6 +258,25 @@ private class DebugMotionValueGraphNode( override fun onAttach() { acquireDebugInspector() coroutineScope.launch { while (true) { if (history.size > 1) { withFrameNanos { thisFrameTime -> while ( history.size > 1 && (thisFrameTime - history.first().frameTimeNanos) > maxAgeMillis * 1_000_000 ) { history.removeFirst() } } } snapshotFlow { history.size > 1 }.first { it } } } } override fun onDetach() { Loading @@ -251,6 +295,9 @@ private class DebugMotionValueGraphNode( } override fun ContentDrawScope.draw() { if (history.isNotEmpty()) { drawDirectionAndAnimationStatus(history.last()) } drawInputOutputTrail(history, inputRange, outputRange, color) drawContent() } Loading @@ -260,12 +307,7 @@ private class DebugMotionValueGraphNode( observeReads { lastFrame = debugInspector?.frame } lastFrame?.also { history.add(it) if (history.size > historySize) { history.removeFirst() } } lastFrame?.also { history.add(it) } } override fun onObservedReadsChanged() { Loading Loading @@ -297,12 +339,16 @@ private fun DrawScope.drawDirectionalSpec( inputRange: ClosedFloatingPointRange<Float>, outputRange: ClosedFloatingPointRange<Float>, color: Color, activeSegment: SegmentKey?, ) { val startSegment = spec.findBreakpointIndex(inputRange.start) val endSegment = spec.findBreakpointIndex(inputRange.endInclusive) for (segmentIndex in startSegment..endSegment) { val isActiveSegment = activeSegment?.let { spec.findSegmentIndex(it) == segmentIndex } ?: false val mapping = spec.mappings[segmentIndex] val startBreakpoint = spec.breakpoints[segmentIndex] val segmentStart = startBreakpoint.position Loading @@ -317,14 +363,18 @@ private fun DrawScope.drawDirectionalSpec( val start = Offset(mapPointInInputToX(fromInput, inputRange), fromY) val end = Offset(mapPointInInputToX(toInput, inputRange), toY) drawLine(color, start, end) val strokeWidth = if (isActiveSegment) 2.dp.toPx() else Stroke.HairlineWidth val dotSize = if (isActiveSegment) 4.dp.toPx() else 2.dp.toPx() drawLine(color, start, end, strokeWidth = strokeWidth) if (segmentStart == fromInput) { drawCircle(color, 2.dp.toPx(), start) drawCircle(color, dotSize, start) } if (segmentEnd == toInput) { drawCircle(color, 2.dp.toPx(), end) drawCircle(color, dotSize, end) } val guarantee = startBreakpoint.guarantee Loading @@ -351,6 +401,53 @@ private fun DrawScope.drawDirectionalSpec( } } private fun DrawScope.drawDirectionAndAnimationStatus(currentFrame: FrameData) { val indicatorSize = min(this.size.height, 24.dp.toPx()) this.scale( scaleX = if (currentFrame.gestureDirection == InputDirection.Max) 1f else -1f, scaleY = 1f, ) { val color = if (currentFrame.isStable) Color.Green else Color.Red val strokeWidth = 1.dp.toPx() val d1 = indicatorSize / 2f val d2 = indicatorSize / 3f translate(left = 2.dp.toPx()) { drawLine( color, Offset(center.x - d2, center.y - d1), center, strokeWidth = strokeWidth, cap = StrokeCap.Round, ) drawLine( color, Offset(center.x - d2, center.y + d1), center, strokeWidth = strokeWidth, cap = StrokeCap.Round, ) } translate(left = -2.dp.toPx()) { drawLine( color, Offset(center.x - d2, center.y - d1), center, strokeWidth = strokeWidth, cap = StrokeCap.Round, ) drawLine( color, Offset(center.x - d2, center.y + d1), center, strokeWidth = strokeWidth, cap = StrokeCap.Round, ) } } } private fun DrawScope.drawInputOutputTrail( history: List<FrameData>, inputRange: ClosedFloatingPointRange<Float>, Loading mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt 0 → 100644 +130 −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.debug import androidx.compose.runtime.mutableStateListOf import androidx.compose.ui.Modifier import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.TraversableNode import androidx.compose.ui.node.findNearestAncestor import androidx.compose.ui.platform.InspectorInfo import com.android.mechanics.MotionValue import com.android.mechanics.debug.MotionValueDebuggerNode.Companion.TRAVERSAL_NODE_KEY import kotlinx.coroutines.DisposableHandle /** State for the [MotionValueDebugger]. */ sealed interface MotionValueDebuggerState { val observedMotionValues: List<MotionValue> } /** Factory for [MotionValueDebugger]. */ fun MotionValueDebuggerState(): MotionValueDebuggerState { return MotionValueDebuggerStateImpl() } /** Collector for [MotionValue]s in the Node subtree that should be observed for debug purposes. */ fun Modifier.motionValueDebugger(state: MotionValueDebuggerState): Modifier = this.then(MotionValueDebuggerElement(state as MotionValueDebuggerStateImpl)) /** * [motionValueDebugger]'s interface, nodes in the subtree of a [motionValueDebugger] can retrieve * it using [findMotionValueDebugger]. */ sealed interface MotionValueDebugger { fun register(motionValue: MotionValue): DisposableHandle } /** Finds a [MotionValueDebugger] that was registered via a [motionValueDebugger] modifier. */ fun DelegatableNode.findMotionValueDebugger(): MotionValueDebugger? { return findNearestAncestor(TRAVERSAL_NODE_KEY) as? MotionValueDebugger } /** Registers the motion value for debugging with the parent [MotionValue]. */ fun Modifier.debugMotionValue(motionValue: MotionValue): Modifier = this.then(DebugMotionValueElement(motionValue)) internal class MotionValueDebuggerNode(internal var state: MotionValueDebuggerStateImpl) : Modifier.Node(), TraversableNode, MotionValueDebugger { override val traverseKey = TRAVERSAL_NODE_KEY override fun register(motionValue: MotionValue): DisposableHandle { val state = state state.observedMotionValues.add(motionValue) return DisposableHandle { state.observedMotionValues.remove(motionValue) } } companion object { const val TRAVERSAL_NODE_KEY = "com.android.mechanics.debug.DEBUG_CONNECTOR_NODE_KEY" } } private data class MotionValueDebuggerElement(val state: MotionValueDebuggerStateImpl) : ModifierNodeElement<MotionValueDebuggerNode>() { override fun create(): MotionValueDebuggerNode = MotionValueDebuggerNode(state) override fun InspectorInfo.inspectableProperties() { // Intentionally empty } override fun update(node: MotionValueDebuggerNode) { check(node.state === state) } } internal class DebugMotionValueNode(motionValue: MotionValue) : Modifier.Node() { private var debugger: MotionValueDebugger? = null internal var motionValue = motionValue set(value) { registration?.dispose() registration = debugger?.register(value) field = value } internal var registration: DisposableHandle? = null override fun onAttach() { debugger = findMotionValueDebugger() registration = debugger?.register(motionValue) } override fun onDetach() { debugger = null registration?.dispose() registration = null } } private data class DebugMotionValueElement(val motionValue: MotionValue) : ModifierNodeElement<DebugMotionValueNode>() { override fun create(): DebugMotionValueNode = DebugMotionValueNode(motionValue) override fun InspectorInfo.inspectableProperties() { // Intentionally empty } override fun update(node: DebugMotionValueNode) { node.motionValue = motionValue } } internal class MotionValueDebuggerStateImpl : MotionValueDebuggerState { override val observedMotionValues: MutableList<MotionValue> = mutableStateListOf() } mechanics/tests/Android.bp +1 −0 Original line number Diff line number Diff line Loading @@ -33,6 +33,7 @@ android_test { static_libs: [ // ":mechanics" dependencies "androidx.compose.runtime_runtime", "androidx.compose.material3_material3", "androidx.compose.ui_ui-util", "androidx.compose.foundation_foundation-layout", Loading mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt 0 → 100644 +94 −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.debug import androidx.compose.foundation.layout.Box import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.mechanics.MotionValue import com.android.mechanics.ProvidedGestureContext import com.android.mechanics.spec.InputDirection import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class MotionValueDebuggerTest { private val input: () -> Float = { 0f } private val gestureContext = ProvidedGestureContext(dragOffset = 0f, direction = InputDirection.Max) @get:Rule(order = 0) val rule = createComposeRule() @Test fun debugMotionValue_registersMotionValue_whenAddingToComposition() { val debuggerState = MotionValueDebuggerState() var hasValue by mutableStateOf(false) rule.setContent { Box(modifier = Modifier.motionValueDebugger(debuggerState)) { if (hasValue) { val toDebug = remember { MotionValue(input, gestureContext) } Box(modifier = Modifier.debugMotionValue(toDebug)) } } } assertThat(debuggerState.observedMotionValues).isEmpty() hasValue = true rule.waitForIdle() assertThat(debuggerState.observedMotionValues).hasSize(1) } @Test fun debugMotionValue_unregistersMotionValue_whenLeavingComposition() { val debuggerState = MotionValueDebuggerState() var hasValue by mutableStateOf(true) rule.setContent { Box(modifier = Modifier.motionValueDebugger(debuggerState)) { if (hasValue) { val toDebug = remember { MotionValue(input, gestureContext) } Box(modifier = Modifier.debugMotionValue(toDebug)) } } } assertThat(debuggerState.observedMotionValues).hasSize(1) hasValue = false rule.waitForIdle() assertThat(debuggerState.observedMotionValues).isEmpty() } @Test fun debugMotionValue_noDebugger_isNoOp() { rule.setContent { val toDebug = remember { MotionValue(input, gestureContext) } Box(modifier = Modifier.debugMotionValue(toDebug)) } } } Loading
mechanics/Android.bp +1 −0 Original line number Diff line number Diff line Loading @@ -31,6 +31,7 @@ android_library { min_sdk_version: "current", static_libs: [ "androidx.compose.runtime_runtime", "androidx.compose.material3_material3", "androidx.compose.ui_ui-util", "androidx.compose.foundation_foundation-layout", ], Loading
mechanics/src/com/android/mechanics/debug/DebugVisualization.kt +137 −40 Original line number Diff line number Diff line Loading @@ -17,18 +17,26 @@ package com.android.mechanics.debug import androidx.compose.foundation.layout.Spacer import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.ContentDrawScope import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.node.DrawModifierNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.ObserverModifierNode Loading @@ -39,9 +47,13 @@ import androidx.compose.ui.util.fastForEachIndexed import com.android.mechanics.MotionValue import com.android.mechanics.spec.DirectionalMotionSpec import com.android.mechanics.spec.Guarantee import com.android.mechanics.spec.InputDirection import com.android.mechanics.spec.MotionSpec import com.android.mechanics.spec.SegmentKey import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch /** * A debug visualization of the [motionValue]. Loading @@ -52,25 +64,48 @@ import kotlin.math.min * * @param motionValue The [MotionValue] to inspect. * @param inputRange The relevant range of the input (x) axis, for which to draw the graph. * @param color Color for the dots indicating the value * @param historySize Number of past values to draw as a trail. * @param maxAgeMillis Max age of the elements in the history trail. */ @Composable fun DebugMotionValueVisualization( motionValue: MotionValue, inputRange: ClosedFloatingPointRange<Float>, modifier: Modifier = Modifier, color: Color = Color.DarkGray, historySize: Int = 100, maxAgeMillis: Long = 1000L, ) { val spec = motionValue.spec val outputRange = remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) } val inspector = remember(motionValue) { motionValue.debugInspector() } DisposableEffect(inspector) { onDispose { inspector.dispose() } } val colorScheme = MaterialTheme.colorScheme val axisColor = colorScheme.outline val specColor = colorScheme.tertiary val valueColor = colorScheme.primary val primarySpec = motionValue.spec.get(inspector.frame.gestureDirection) val activeSegment = inspector.frame.segmentKey Spacer( modifier = modifier .debugMotionSpecGraph(spec, inputRange, outputRange) .debugMotionValueGraph(motionValue, color, inputRange, outputRange, historySize) .debugMotionSpecGraph( primarySpec, inputRange, outputRange, axisColor, specColor, activeSegment, ) .debugMotionValueGraph( motionValue, valueColor, inputRange, outputRange, maxAgeMillis, ) ) } Loading @@ -83,17 +118,15 @@ fun DebugMotionValueVisualization( * @param outputRange The range of the output (y) axis. */ fun Modifier.debugMotionSpecGraph( spec: MotionSpec, spec: DirectionalMotionSpec, inputRange: ClosedFloatingPointRange<Float>, outputRange: ClosedFloatingPointRange<Float>, axisColor: Color = Color.Gray, specColor: Color = Color.Blue, activeSegment: SegmentKey? = null, ): Modifier = drawBehind { drawAxis(Color.Gray) if (spec.isUnidirectional) { drawDirectionalSpec(spec.maxDirection, inputRange, outputRange, Color.Red) } else { drawDirectionalSpec(spec.minDirection, inputRange, outputRange, Color.Red) drawDirectionalSpec(spec.maxDirection, inputRange, outputRange, Color.Blue) } drawAxis(axisColor) drawDirectionalSpec(spec, inputRange, outputRange, specColor, activeSegment) } /** Loading @@ -107,7 +140,7 @@ fun Modifier.debugMotionSpecGraph( * @param color Color for the dots indicating the value * @param inputRange The range of the input (x) axis * @param outputRange The range of the output (y) axis. * @param historySize Number of past values to draw as a trail. * @param maxAgeMillis Max age of the elements in the history trail. */ @Composable fun Modifier.debugMotionValueGraph( Loading @@ -115,9 +148,10 @@ fun Modifier.debugMotionValueGraph( color: Color, inputRange: ClosedFloatingPointRange<Float>, outputRange: ClosedFloatingPointRange<Float>, historySize: Int = 100, maxAgeMillis: Long = 1000L, ): Modifier = this then DebugMotionValueGraphElement(motionValue, color, inputRange, outputRange, historySize) this then DebugMotionValueGraphElement(motionValue, color, inputRange, outputRange, maxAgeMillis) /** * Utility to compute the min/max output values of the spec for the given input. Loading Loading @@ -176,22 +210,22 @@ private data class DebugMotionValueGraphElement( val color: Color, val inputRange: ClosedFloatingPointRange<Float>, val outputRange: ClosedFloatingPointRange<Float>, val historySize: Int, val maxAgeMillis: Long, ) : ModifierNodeElement<DebugMotionValueGraphNode>() { init { require(historySize > 0) require(maxAgeMillis > 0) } override fun create() = DebugMotionValueGraphNode(motionValue, color, inputRange, outputRange, historySize) DebugMotionValueGraphNode(motionValue, color, inputRange, outputRange, maxAgeMillis) override fun update(node: DebugMotionValueGraphNode) { node.motionValue = motionValue node.color = color node.inputRange = inputRange node.outputRange = outputRange node.historySize = historySize node.maxAgeMillis = maxAgeMillis } override fun InspectorInfo.inspectableProperties() { Loading @@ -204,21 +238,12 @@ private class DebugMotionValueGraphNode( var color: Color, var inputRange: ClosedFloatingPointRange<Float>, var outputRange: ClosedFloatingPointRange<Float>, historySize: Int, var maxAgeMillis: Long, ) : DrawModifierNode, ObserverModifierNode, Modifier.Node() { private var debugInspector by mutableStateOf<DebugInspector?>(null) private val history = mutableStateListOf<FrameData>() var historySize = historySize set(value) { field = value if (history.size > value) { history.removeRange(0, value - historySize) } } var motionValue = motionValue set(value) { if (value != field) { Loading @@ -233,6 +258,25 @@ private class DebugMotionValueGraphNode( override fun onAttach() { acquireDebugInspector() coroutineScope.launch { while (true) { if (history.size > 1) { withFrameNanos { thisFrameTime -> while ( history.size > 1 && (thisFrameTime - history.first().frameTimeNanos) > maxAgeMillis * 1_000_000 ) { history.removeFirst() } } } snapshotFlow { history.size > 1 }.first { it } } } } override fun onDetach() { Loading @@ -251,6 +295,9 @@ private class DebugMotionValueGraphNode( } override fun ContentDrawScope.draw() { if (history.isNotEmpty()) { drawDirectionAndAnimationStatus(history.last()) } drawInputOutputTrail(history, inputRange, outputRange, color) drawContent() } Loading @@ -260,12 +307,7 @@ private class DebugMotionValueGraphNode( observeReads { lastFrame = debugInspector?.frame } lastFrame?.also { history.add(it) if (history.size > historySize) { history.removeFirst() } } lastFrame?.also { history.add(it) } } override fun onObservedReadsChanged() { Loading Loading @@ -297,12 +339,16 @@ private fun DrawScope.drawDirectionalSpec( inputRange: ClosedFloatingPointRange<Float>, outputRange: ClosedFloatingPointRange<Float>, color: Color, activeSegment: SegmentKey?, ) { val startSegment = spec.findBreakpointIndex(inputRange.start) val endSegment = spec.findBreakpointIndex(inputRange.endInclusive) for (segmentIndex in startSegment..endSegment) { val isActiveSegment = activeSegment?.let { spec.findSegmentIndex(it) == segmentIndex } ?: false val mapping = spec.mappings[segmentIndex] val startBreakpoint = spec.breakpoints[segmentIndex] val segmentStart = startBreakpoint.position Loading @@ -317,14 +363,18 @@ private fun DrawScope.drawDirectionalSpec( val start = Offset(mapPointInInputToX(fromInput, inputRange), fromY) val end = Offset(mapPointInInputToX(toInput, inputRange), toY) drawLine(color, start, end) val strokeWidth = if (isActiveSegment) 2.dp.toPx() else Stroke.HairlineWidth val dotSize = if (isActiveSegment) 4.dp.toPx() else 2.dp.toPx() drawLine(color, start, end, strokeWidth = strokeWidth) if (segmentStart == fromInput) { drawCircle(color, 2.dp.toPx(), start) drawCircle(color, dotSize, start) } if (segmentEnd == toInput) { drawCircle(color, 2.dp.toPx(), end) drawCircle(color, dotSize, end) } val guarantee = startBreakpoint.guarantee Loading @@ -351,6 +401,53 @@ private fun DrawScope.drawDirectionalSpec( } } private fun DrawScope.drawDirectionAndAnimationStatus(currentFrame: FrameData) { val indicatorSize = min(this.size.height, 24.dp.toPx()) this.scale( scaleX = if (currentFrame.gestureDirection == InputDirection.Max) 1f else -1f, scaleY = 1f, ) { val color = if (currentFrame.isStable) Color.Green else Color.Red val strokeWidth = 1.dp.toPx() val d1 = indicatorSize / 2f val d2 = indicatorSize / 3f translate(left = 2.dp.toPx()) { drawLine( color, Offset(center.x - d2, center.y - d1), center, strokeWidth = strokeWidth, cap = StrokeCap.Round, ) drawLine( color, Offset(center.x - d2, center.y + d1), center, strokeWidth = strokeWidth, cap = StrokeCap.Round, ) } translate(left = -2.dp.toPx()) { drawLine( color, Offset(center.x - d2, center.y - d1), center, strokeWidth = strokeWidth, cap = StrokeCap.Round, ) drawLine( color, Offset(center.x - d2, center.y + d1), center, strokeWidth = strokeWidth, cap = StrokeCap.Round, ) } } } private fun DrawScope.drawInputOutputTrail( history: List<FrameData>, inputRange: ClosedFloatingPointRange<Float>, Loading
mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt 0 → 100644 +130 −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.debug import androidx.compose.runtime.mutableStateListOf import androidx.compose.ui.Modifier import androidx.compose.ui.node.DelegatableNode import androidx.compose.ui.node.ModifierNodeElement import androidx.compose.ui.node.TraversableNode import androidx.compose.ui.node.findNearestAncestor import androidx.compose.ui.platform.InspectorInfo import com.android.mechanics.MotionValue import com.android.mechanics.debug.MotionValueDebuggerNode.Companion.TRAVERSAL_NODE_KEY import kotlinx.coroutines.DisposableHandle /** State for the [MotionValueDebugger]. */ sealed interface MotionValueDebuggerState { val observedMotionValues: List<MotionValue> } /** Factory for [MotionValueDebugger]. */ fun MotionValueDebuggerState(): MotionValueDebuggerState { return MotionValueDebuggerStateImpl() } /** Collector for [MotionValue]s in the Node subtree that should be observed for debug purposes. */ fun Modifier.motionValueDebugger(state: MotionValueDebuggerState): Modifier = this.then(MotionValueDebuggerElement(state as MotionValueDebuggerStateImpl)) /** * [motionValueDebugger]'s interface, nodes in the subtree of a [motionValueDebugger] can retrieve * it using [findMotionValueDebugger]. */ sealed interface MotionValueDebugger { fun register(motionValue: MotionValue): DisposableHandle } /** Finds a [MotionValueDebugger] that was registered via a [motionValueDebugger] modifier. */ fun DelegatableNode.findMotionValueDebugger(): MotionValueDebugger? { return findNearestAncestor(TRAVERSAL_NODE_KEY) as? MotionValueDebugger } /** Registers the motion value for debugging with the parent [MotionValue]. */ fun Modifier.debugMotionValue(motionValue: MotionValue): Modifier = this.then(DebugMotionValueElement(motionValue)) internal class MotionValueDebuggerNode(internal var state: MotionValueDebuggerStateImpl) : Modifier.Node(), TraversableNode, MotionValueDebugger { override val traverseKey = TRAVERSAL_NODE_KEY override fun register(motionValue: MotionValue): DisposableHandle { val state = state state.observedMotionValues.add(motionValue) return DisposableHandle { state.observedMotionValues.remove(motionValue) } } companion object { const val TRAVERSAL_NODE_KEY = "com.android.mechanics.debug.DEBUG_CONNECTOR_NODE_KEY" } } private data class MotionValueDebuggerElement(val state: MotionValueDebuggerStateImpl) : ModifierNodeElement<MotionValueDebuggerNode>() { override fun create(): MotionValueDebuggerNode = MotionValueDebuggerNode(state) override fun InspectorInfo.inspectableProperties() { // Intentionally empty } override fun update(node: MotionValueDebuggerNode) { check(node.state === state) } } internal class DebugMotionValueNode(motionValue: MotionValue) : Modifier.Node() { private var debugger: MotionValueDebugger? = null internal var motionValue = motionValue set(value) { registration?.dispose() registration = debugger?.register(value) field = value } internal var registration: DisposableHandle? = null override fun onAttach() { debugger = findMotionValueDebugger() registration = debugger?.register(motionValue) } override fun onDetach() { debugger = null registration?.dispose() registration = null } } private data class DebugMotionValueElement(val motionValue: MotionValue) : ModifierNodeElement<DebugMotionValueNode>() { override fun create(): DebugMotionValueNode = DebugMotionValueNode(motionValue) override fun InspectorInfo.inspectableProperties() { // Intentionally empty } override fun update(node: DebugMotionValueNode) { node.motionValue = motionValue } } internal class MotionValueDebuggerStateImpl : MotionValueDebuggerState { override val observedMotionValues: MutableList<MotionValue> = mutableStateListOf() }
mechanics/tests/Android.bp +1 −0 Original line number Diff line number Diff line Loading @@ -33,6 +33,7 @@ android_test { static_libs: [ // ":mechanics" dependencies "androidx.compose.runtime_runtime", "androidx.compose.material3_material3", "androidx.compose.ui_ui-util", "androidx.compose.foundation_foundation-layout", Loading
mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt 0 → 100644 +94 −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.debug import androidx.compose.foundation.layout.Box import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.test.junit4.createComposeRule import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.mechanics.MotionValue import com.android.mechanics.ProvidedGestureContext import com.android.mechanics.spec.InputDirection import com.google.common.truth.Truth.assertThat import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class MotionValueDebuggerTest { private val input: () -> Float = { 0f } private val gestureContext = ProvidedGestureContext(dragOffset = 0f, direction = InputDirection.Max) @get:Rule(order = 0) val rule = createComposeRule() @Test fun debugMotionValue_registersMotionValue_whenAddingToComposition() { val debuggerState = MotionValueDebuggerState() var hasValue by mutableStateOf(false) rule.setContent { Box(modifier = Modifier.motionValueDebugger(debuggerState)) { if (hasValue) { val toDebug = remember { MotionValue(input, gestureContext) } Box(modifier = Modifier.debugMotionValue(toDebug)) } } } assertThat(debuggerState.observedMotionValues).isEmpty() hasValue = true rule.waitForIdle() assertThat(debuggerState.observedMotionValues).hasSize(1) } @Test fun debugMotionValue_unregistersMotionValue_whenLeavingComposition() { val debuggerState = MotionValueDebuggerState() var hasValue by mutableStateOf(true) rule.setContent { Box(modifier = Modifier.motionValueDebugger(debuggerState)) { if (hasValue) { val toDebug = remember { MotionValue(input, gestureContext) } Box(modifier = Modifier.debugMotionValue(toDebug)) } } } assertThat(debuggerState.observedMotionValues).hasSize(1) hasValue = false rule.waitForIdle() assertThat(debuggerState.observedMotionValues).isEmpty() } @Test fun debugMotionValue_noDebugger_isNoOp() { rule.setContent { val toDebug = remember { MotionValue(input, gestureContext) } Box(modifier = Modifier.debugMotionValue(toDebug)) } } }