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

Commit fbb5856f authored by Omar Miatello's avatar Omar Miatello
Browse files

MM: Introduce MotionValueNode

The core logic for managing a `MotionValue` within a Compose `Modifier
.Node` has been extracted into a new, reusable `MotionValueNode`. This
node encapsulates the lifecycle management, coroutine scope, and
observation logic for a `MotionValue`.

Modifiers like `VerticalFadeContentRevealModifier` and
`VerticalTactileSurfaceRevealModifier` have been updated to delegate to
this new `MotionValueNode`, which simplifies their implementation by
removing boilerplate code related to creating, running, and cleaning up
the `MotionValue` instance.

Add `MotionValue.isOutputFixed` for Optimization

This boolean indicates whether the output value is guaranteed not to
change, even if the input value changes. This occurs when the animation
is stable and the input falls within a segment that maps to a constant
value (e.g., `Mapping.Fixed`).

The primary benefit of this is to optimize layout performance. The
`ApproachLayoutModifierNode` implementations now use `!isOutputFixed` to
determine if a measurement approach is in progress. This is more precise
than the previous logic, preventing unnecessary re-measurements when the
output is static, thus improving efficiency.

Test: atest MotionValueTest
Bug: 419520966
Flag: com.android.systemui.scene_container
Change-Id: I356528db2bab7b4f7a9960e9426269844ed3a052
parent 18926f4e
Loading
Loading
Loading
Loading
+124 −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.compose.modifier

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.observeReads
import com.android.mechanics.GestureContext
import com.android.mechanics.MotionValue
import com.android.mechanics.MotionValue.Companion.StableThresholdEffect
import com.android.mechanics.debug.MotionValueDebugger
import com.android.mechanics.debug.findMotionValueDebugger
import com.android.mechanics.spec.MotionSpec
import kotlinx.coroutines.launch

/**
 * A [Modifier.Node] that encapsulates a [MotionValue] and its lifecycle.
 *
 * This node observes an [input] value and drives a [MotionValue] animation based on a [MotionSpec].
 * It handles the creation, running, and cleanup of the motion value instance.
 *
 * Note: This is primarily intended to be used via the [DelegatingNode.delegate] function.
 *
 * @param input A lambda that provides the current input value for the motion.
 * @param gestureContext The context for gesture-driven animations.
 * @param initialSpec The initial [MotionSpec] to configure the animation.
 * @param label An optional label for debugging purposes.
 * @param stableThreshold The threshold to determine if the motion value is stable.
 * @param debug Whether this value needs to be registered to a [MotionValueDebugger].
 */
internal class MotionValueNode(
    private var input: () -> Float,
    gestureContext: GestureContext,
    initialSpec: MotionSpec = MotionSpec.Empty,
    label: String? = null,
    stableThreshold: Float = StableThresholdEffect,
    private val debug: Boolean = false,
) : Modifier.Node(), ObserverModifierNode {
    private var currentInputState by mutableFloatStateOf(input())

    private val motionValue =
        MotionValue(
            currentInput = { currentInputState },
            gestureContext = gestureContext,
            initialSpec = initialSpec,
            label = label,
            stableThreshold = stableThreshold,
        )

    /**
     * Whether the output value of the [motionValue] is currently fixed.
     *
     * This is true if the animation is at rest and the current input maps to a fixed output that
     * has not changed, which can be used to prevent unnecessary recompositions or layouts.
     */
    var isOutputFixed by mutableStateOf(motionValue.isOutputFixed)
        private set

    var output by mutableFloatStateOf(motionValue.output)
        private set

    override fun onAttach() {
        onObservedReadsChanged()

        coroutineScope.launch {
            val disposableHandle =
                if (debug) {
                    findMotionValueDebugger()?.register(motionValue)
                } else {
                    null
                }
            try {
                motionValue.keepRunning()
            } finally {
                disposableHandle?.dispose()
            }
        }
    }

    /**
     * This function is called by Compose whenever a state object that was read inside the
     * `observeReads` block has changed.
     *
     * Note: that this callback may not be invoked immediately, but can be deferred until a later
     * stage, such as after the measure and layout pass.
     */
    override fun onObservedReadsChanged() {
        observeReads { updateStates() }
    }

    /** Reads the latest input and updates the internal states of this node. */
    private fun updateStates() {
        currentInputState = input()
        isOutputFixed = motionValue.isOutputFixed

        // Only invoke the update callback if the output might have changed.
        if (isOutputFixed) return

        output = motionValue.output
    }

    fun updateSpec(spec: MotionSpec) {
        motionValue.spec = spec
    }
}
+25 −47
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import androidx.compose.ui.layout.ApproachMeasureScope
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.observeReads
@@ -38,16 +39,12 @@ import com.android.compose.animation.scene.ContentScope
import com.android.compose.animation.scene.ElementKey
import com.android.compose.animation.scene.content.state.TransitionState
import com.android.compose.animation.scene.mechanics.gestureContextOrDefault
import com.android.mechanics.MotionValue
import com.android.mechanics.debug.findMotionValueDebugger
import com.android.mechanics.effects.FixedValue
import com.android.mechanics.spec.Mapping
import com.android.mechanics.spec.MotionSpec
import com.android.mechanics.spec.builder.MotionBuilderContext
import com.android.mechanics.spec.builder.directionalMotionSpec
import com.android.mechanics.spec.builder.effectsMotionSpec
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

/**
 * This component remains hidden until it reach its target height.
@@ -115,21 +112,24 @@ private class FadeContentRevealNode(
    private var container: ElementKey,
    private var deltaY: Float,
    label: String?,
    private val debug: Boolean,
) : Modifier.Node(), ApproachLayoutModifierNode, ObserverModifierNode {
    debug: Boolean,
) : DelegatingNode(), ApproachLayoutModifierNode, ObserverModifierNode {

    private val motionValue =
        MotionValue(
            currentInput = {
    private val motionValueNode: MotionValueNode =
        delegate(
            MotionValueNode(
                input = {
                    with(contentScope) {
                        val containerHeight =
                        container.lastSize(contentKey)?.height ?: return@MotionValue 0f
                            container.lastSize(contentKey)?.height ?: return@MotionValueNode 0f
                        containerHeight + deltaY
                    }
                },
            initialSpec = MotionSpec(directionalMotionSpec(Mapping.Zero)),
                gestureContext = contentScope.gestureContextOrDefault(),
                initialSpec = MotionSpec(directionalMotionSpec(Mapping.Zero)),
                label = "FadeContentReveal(${label.orEmpty()})",
                debug = debug,
            )
        )

    fun update(
@@ -145,29 +145,8 @@ private class FadeContentRevealNode(
        updateMotionSpec(contentScope.layoutState.transitionState)
    }

    private var motionValueJob: Job? = null

    override fun onAttach() {
        onObservedReadsChanged()

        motionValueJob =
            coroutineScope.launch {
                val disposableHandle =
                    if (debug) {
                        findMotionValueDebugger()?.register(motionValue)
                    } else {
                        null
                    }
                try {
                    motionValue.keepRunning()
                } finally {
                    disposableHandle?.dispose()
                }
            }
    }

    override fun onDetach() {
        motionValueJob?.cancel()
    }

    override fun onObservedReadsChanged() {
@@ -175,19 +154,16 @@ private class FadeContentRevealNode(
    }

    private var targetBounds = Rect.Zero
    private var isContentTransition = false

    private fun updateMotionSpec(transitionState: TransitionState) {
        isContentTransition = transitionState is TransitionState.Transition

        val height = targetBounds.height
        if (height == 0f) {
            // We cannot compute specs for height 0.
            motionValue.spec = MotionSpec(directionalMotionSpec(Mapping.Fixed(0f)))
            motionValueNode.updateSpec(MotionSpec(directionalMotionSpec(Mapping.Fixed(0f))))
            return
        }

        motionValue.spec =
        motionValueNode.updateSpec(
            when (transitionState) {
                is TransitionState.Idle -> {
                    val containerMinHeight = 0
@@ -206,16 +182,18 @@ private class FadeContentRevealNode(
                        }
                    MotionSpec(directionalMotionSpec(Mapping.Fixed(if (isRevealed) 1f else 0f)))
                }

                is TransitionState.Transition -> {
                    motionBuilderContext.effectsMotionSpec(Mapping.Zero) {
                        after(targetBounds.bottom, FixedValue.One)
                    }
                }
            }
        )
    }

    override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean {
        return isContentTransition || !motionValue.isStable
        return !motionValueNode.isOutputFixed
    }

    override fun MeasureScope.measure(
@@ -245,7 +223,7 @@ private class FadeContentRevealNode(
    ): MeasureResult {
        return measurable.measure(constraints).run {
            layout(width, height) {
                val revealAlpha = motionValue.output
                val revealAlpha = motionValueNode.output.fastCoerceAtLeast(0f)
                if (revealAlpha < 1) {
                    placeWithLayer(IntOffset.Zero) {
                        alpha = revealAlpha.fastCoerceAtLeast(0f)
+26 −47
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import androidx.compose.ui.layout.ApproachMeasureScope
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.observeReads
@@ -39,8 +40,6 @@ import com.android.compose.animation.scene.ContentScope
import com.android.compose.animation.scene.ElementKey
import com.android.compose.animation.scene.content.state.TransitionState
import com.android.compose.animation.scene.mechanics.gestureContextOrDefault
import com.android.mechanics.MotionValue
import com.android.mechanics.debug.findMotionValueDebugger
import com.android.mechanics.effects.RevealOnThreshold
import com.android.mechanics.spec.Mapping
import com.android.mechanics.spec.MotionSpec
@@ -48,8 +47,6 @@ import com.android.mechanics.spec.builder.MotionBuilderContext
import com.android.mechanics.spec.builder.directionalMotionSpec
import com.android.mechanics.spec.builder.spatialMotionSpec
import kotlin.math.roundToInt
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch

/**
 * This component remains hidden until its target height meets a minimum threshold. At that point,
@@ -127,22 +124,25 @@ private class VerticalTactileSurfaceRevealNode(
    private var deltaY: Float,
    private var revealOnThreshold: RevealOnThreshold,
    label: String?,
    private val debug: Boolean,
) : Modifier.Node(), ApproachLayoutModifierNode, ObserverModifierNode {
    debug: Boolean,
) : DelegatingNode(), ApproachLayoutModifierNode, ObserverModifierNode {

    private val motionValue =
        MotionValue(
            currentInput = {
    private val motionValueNode: MotionValueNode =
        delegate(
            MotionValueNode(
                input = {
                    with(contentScope) {
                        val containerHeight =
                        container.lastSize(contentKey)?.height ?: return@MotionValue 0f
                            container.lastSize(contentKey)?.height ?: return@MotionValueNode 0f
                        containerHeight + deltaY
                    }
                },
            initialSpec = MotionSpec(directionalMotionSpec(Mapping.Zero)),
                gestureContext = contentScope.gestureContextOrDefault(),
                initialSpec = MotionSpec(directionalMotionSpec(Mapping.Zero)),
                label = "TactileSurfaceReveal(${label.orEmpty()})",
                stableThreshold = MotionBuilderContext.StableThresholdSpatial,
                debug = debug,
            )
        )

    fun update(
@@ -160,29 +160,8 @@ private class VerticalTactileSurfaceRevealNode(
        updateMotionSpec(contentScope.layoutState.transitionState)
    }

    private var motionValueJob: Job? = null

    override fun onAttach() {
        onObservedReadsChanged()

        motionValueJob =
            coroutineScope.launch {
                val disposableHandle =
                    if (debug) {
                        findMotionValueDebugger()?.register(motionValue)
                    } else {
                        null
                    }
                try {
                    motionValue.keepRunning()
                } finally {
                    disposableHandle?.dispose()
                }
            }
    }

    override fun onDetach() {
        motionValueJob?.cancel()
    }

    override fun onObservedReadsChanged() {
@@ -190,19 +169,16 @@ private class VerticalTactileSurfaceRevealNode(
    }

    private var targetBounds = Rect.Zero
    private var isContentTransition = false

    private fun updateMotionSpec(transitionState: TransitionState) {
        isContentTransition = transitionState is TransitionState.Transition

        val height = targetBounds.height
        if (height == 0f) {
            // We cannot compute specs for height 0.
            motionValue.spec = MotionSpec(directionalMotionSpec(Mapping.Fixed(0f)))
            motionValueNode.updateSpec(MotionSpec(directionalMotionSpec(Mapping.Fixed(0f))))
            return
        }

        motionValue.spec =
        motionValueNode.updateSpec(
            when (transitionState) {
                is TransitionState.Idle -> {
                    val containerMinHeight = 0
@@ -221,6 +197,7 @@ private class VerticalTactileSurfaceRevealNode(
                        }
                    MotionSpec(directionalMotionSpec(Mapping.Fixed(if (isRevealed) height else 0f)))
                }

                is TransitionState.Transition -> {
                    motionBuilderContext.spatialMotionSpec(Mapping.Zero) {
                        between(
@@ -231,10 +208,11 @@ private class VerticalTactileSurfaceRevealNode(
                    }
                }
            }
        )
    }

    override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean {
        return isContentTransition || !motionValue.isStable
        return !motionValueNode.isOutputFixed
    }

    override fun MeasureScope.measure(
@@ -262,7 +240,8 @@ private class VerticalTactileSurfaceRevealNode(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val height = motionValue.output.roundToInt().fastCoerceAtLeast(0)
        val height = motionValueNode.output.roundToInt().fastCoerceAtLeast(0)

        val animatedConstraints = constraints.copy(maxHeight = height)
        return measurable.measure(animatedConstraints).run {
            layout(width, height) {
+7 −7
Original line number Diff line number Diff line
@@ -295,7 +295,7 @@
        50,
        38.8,
        18.4,
        1.2,
        4.8,
        0,
        0,
        0,
@@ -341,7 +341,7 @@
        120,
        108.8,
        88.4,
        71.2,
        74.8,
        65.2,
        53.6,
        44.4,
@@ -433,7 +433,7 @@
        132.8,
        116.8,
        96.4,
        79.2,
        82.8,
        73.2,
        61.6,
        52.4,
@@ -525,7 +525,7 @@
        140.8,
        124.8,
        104.4,
        87.2,
        90.8,
        81.2,
        69.6,
        60.4,
@@ -617,7 +617,7 @@
        148.8,
        132.8,
        112.4,
        95.2,
        98.8,
        89.2,
        77.6,
        68.4,
@@ -709,7 +709,7 @@
        156.8,
        140.8,
        120.4,
        103.2,
        106.8,
        97.2,
        85.6,
        76.4,
@@ -801,7 +801,7 @@
        164.8,
        148.8,
        128.4,
        111.2,
        114.8,
        105.2,
        93.6,
        84.4,
+8 −8
Original line number Diff line number Diff line
@@ -240,7 +240,7 @@
          "type": "not_found"
        },
        0,
        10.8,
        10.4,
        21.2,
        29.2,
        44.4,
@@ -302,7 +302,7 @@
          "type": "not_found"
        },
        12,
        22.8,
        22.4,
        33.2,
        41.2,
        56.4,
@@ -426,7 +426,7 @@
          "type": "not_found"
        },
        20,
        30.8,
        30.4,
        41.2,
        49.2,
        64.4,
@@ -550,7 +550,7 @@
          "type": "not_found"
        },
        28,
        38.8,
        38.4,
        49.2,
        57.2,
        72.4,
@@ -674,7 +674,7 @@
          "type": "not_found"
        },
        36,
        46.8,
        46.4,
        57.2,
        65.2,
        80.4,
@@ -798,7 +798,7 @@
          "type": "not_found"
        },
        44,
        54.8,
        54.4,
        65.2,
        73.2,
        88.4,
@@ -922,7 +922,7 @@
          "type": "not_found"
        },
        52,
        62.8,
        62.4,
        73.2,
        81.2,
        96.4,
@@ -1046,7 +1046,7 @@
          "type": "not_found"
        },
        60,
        70.8,
        70.4,
        81.2,
        89.2,
        104.4,
Loading