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

Commit deb0526d authored by Omar Miatello's avatar Omar Miatello Committed by Android (Google) Code Review
Browse files

Merge "MM: Introduce MotionValueNode" into main

parents deed20bb fbb5856f
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