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

Commit f52e06d7 authored by Android Build Coastguard Worker's avatar Android Build Coastguard Worker
Browse files

Snap for 13982012 from 48a34c93 to 25Q4-release

Change-Id: If64aa5e7f7613481b1c0aa1a9e1cb9fb0fffaa3e
parents b5971524 48a34c93
Loading
Loading
Loading
Loading
+325 −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.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.TraversableNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.node.findNearestAncestor
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.util.fastForEach
import com.android.mechanics.GestureContext
import com.android.mechanics.MotionValue
import com.android.mechanics.MotionValue.Companion.StableThresholdEffect
import com.android.mechanics.compose.modifier.MotionDriver.RequestConstraints
import com.android.mechanics.debug.LocalMotionValueDebugController
import com.android.mechanics.spec.MotionSpec
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.launch

private const val TRAVERSAL_NODE_KEY = "MotionDriverNode"

/** Finds the nearest [MotionDriver] (or null) that was registered via a [motionDriver] modifier. */
private fun DelegatableNode.findMotionDriverOrNull(): MotionDriver? {
    return findNearestAncestor(TRAVERSAL_NODE_KEY) as? MotionDriver
}

/** Finds the nearest [MotionDriver] that was registered via a [motionDriver] modifier. */
internal fun DelegatableNode.findMotionDriver(): MotionDriver {
    return checkNotNull(findMotionDriverOrNull()) {
        "Did you forget to add the `motionDriver()` modifier to a parent Composable?"
    }
}

/**
 * A central interface for driving animations based on layout constraints.
 *
 * A `MotionDriver` is attached to a specific layout node using the [motionDriver] modifier.
 * Descendant nodes can find this driver to create animations whose target values are derived from
 * the driver's layout `Constraints`. It also provides access to the layout's geometry and a shared
 * [GestureContext].
 *
 * This allows for coordinated animations within a component tree that react to changes in a
 * parent's size, such as expanding or collapsing.
 */
internal interface MotionDriver {
    /** The [GestureContext] associated with this motion. */
    val gestureContext: GestureContext

    /**
     * The current vertical state of the layout, indicating if it's minimized, maximized, or in
     * transition.
     */
    val verticalState: State

    enum class State {
        MinValue,
        Transition,
        MaxValue,
    }

    /**
     * Calculates the positional offset from the `MotionDriver`'s layout to the current layout.
     *
     * This function should be called from within a `Placeable.PlacementScope` (such as a `layout`
     * block) by a descendant of the `motionDriver` modifier. It's useful for determining the
     * descendant's position relative to the driver's coordinate system, which can then be used as
     * an input for animations or other positional logic.
     *
     * @return The [Offset] of the current layout within the `MotionDriver`'s coordinate space.
     */
    fun Placeable.PlacementScope.driverOffset(): Offset

    /**
     * Creates and registers a [AnimatedApproachMeasurement] that animates based on layout
     * constraints.
     *
     * The returned value will automatically update its output whenever the `MotionDriver`'s layout
     * constraints change.
     *
     * @param request Defines how to extract a `Float` input value from the incoming `Constraints`.
     * @param spec A factory for the [MotionSpec] that governs the animation.
     * @param label A string identifier for debugging purposes.
     * @param stableThreshold The threshold (in pixels) at which the value is considered stable.
     * @return An [AnimatedApproachMeasurement] that provides the animated output.
     */
    fun animatedApproachMeasurement(
        request: RequestConstraints,
        spec: () -> MotionSpec,
        label: String? = null,
        stableThreshold: Float = StableThresholdEffect,
        debug: Boolean = false,
    ): AnimatedApproachMeasurement

    /**
     * A functional interface that defines how to convert layout [Constraints] into a single `Float`
     * value, which serves as the input for a [AnimatedApproachMeasurement].
     */
    fun interface RequestConstraints {

        /**
         * Extracts a `Float` input from the given [constraints].
         *
         * @param constraints The layout constraints provided during the measurement pass.
         * @return The `Float` value to be used as the animation input.
         */
        fun constraintsToInput(constraints: Constraints): Float

        /**
         * A predefined [RequestConstraints] implementation that uses the `maxHeight` of the
         * constraints as the input value.
         */
        object MaxHeight : RequestConstraints {
            override fun constraintsToInput(constraints: Constraints): Float {
                return constraints.maxHeight.toFloat()
            }
        }
    }

    /**
     * Represents a value that is derived from layout constraints and animated by a [MotionSpec].
     *
     * This value is state-backed and can be read in composition or snapshot-aware contexts to
     * trigger recomposition or other effects when it changes.
     */
    interface AnimatedApproachMeasurement : DisposableHandle {
        val inProgress: Boolean

        val value: Float
    }
}

/**
 * Creates and registers a [MotionDriver] for this layout.
 *
 * This allows descendant modifiers or layouts to find this `MotionDriver` (using
 * [findMotionDriver]) and observe its state, which is derived from layout changes (e.g., expanding
 * or collapsing).
 *
 * @param gestureContext The [GestureContext] to be made available through this [MotionDriver].
 * @param label An optional label for debugging and inspector tooling.
 */
fun Modifier.motionDriver(gestureContext: GestureContext, label: String? = null): Modifier =
    this then MotionDriverElement(gestureContext = gestureContext, label = label)

private data class MotionDriverElement(val gestureContext: GestureContext, val label: String?) :
    ModifierNodeElement<MotionDriverNode>() {
    override fun create(): MotionDriverNode = MotionDriverNode(gestureContext = gestureContext)

    override fun update(node: MotionDriverNode) {
        node.update(gestureContext = gestureContext)
    }

    override fun InspectorInfo.inspectableProperties() {
        name = "motionDriver"
        properties["label"] = label
    }
}

private class MotionDriverNode(override var gestureContext: GestureContext) :
    Modifier.Node(),
    TraversableNode,
    LayoutModifierNode,
    MotionDriver,
    CompositionLocalConsumerModifierNode {
    private val animatedValues = mutableListOf<AnimatedApproachMeasurementImpl>()
    private var driverCoordinates: LayoutCoordinates? = null
    private var lookAheadHeight: Int = 0

    override val traverseKey: Any = TRAVERSAL_NODE_KEY
    override var verticalState: MotionDriver.State by mutableStateOf(MotionDriver.State.MinValue)

    fun update(gestureContext: GestureContext) {
        this.gestureContext = gestureContext
    }

    override fun Placeable.PlacementScope.driverOffset(): Offset {
        val driverCoordinates = requireNotNull(driverCoordinates) { "" }
        val childCoordinates = requireNotNull(coordinates) { "" }
        return driverCoordinates.localPositionOf(childCoordinates)
    }

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val placeable = measurable.measure(constraints)

        if (isLookingAhead) {
            // In the lookahead pass, we capture the target height of the layout.
            // This is assumed to be the max value that the layout will animate to.
            lookAheadHeight = placeable.height
        } else {
            verticalState =
                when (placeable.height) {
                    0 -> MotionDriver.State.MinValue
                    lookAheadHeight -> MotionDriver.State.MaxValue
                    else -> MotionDriver.State.Transition
                }

            animatedValues.fastForEach { it.updateInput(constraints) }
        }

        return layout(width = placeable.width, height = placeable.height) {
            driverCoordinates = coordinates
            placeable.place(IntOffset.Zero)
        }
    }

    override fun animatedApproachMeasurement(
        request: RequestConstraints,
        spec: () -> MotionSpec,
        label: String?,
        stableThreshold: Float,
        debug: Boolean,
    ): MotionDriver.AnimatedApproachMeasurement {
        val animatedApproachMeasurement =
            AnimatedApproachMeasurementImpl(
                request = request,
                gestureContext = gestureContext,
                spec = spec,
                label = label,
                stableThreshold = stableThreshold,
                onDispose = { animatedValues -= this },
            )
        animatedValues += animatedApproachMeasurement

        val debugController = if (debug) currentValueOf(LocalMotionValueDebugController) else null
        coroutineScope.launch {
            val disposableHandle =
                debugController?.register(animatedApproachMeasurement.motionValue)
            try {
                animatedApproachMeasurement.keepRunningWhileObserved()
            } finally {
                disposableHandle?.dispose()
            }
        }

        coroutineScope.launch {
            while (animatedApproachMeasurement.isObserved) {
                withFrameNanos { animatedApproachMeasurement.computeOutput() }
            }
        }

        return animatedApproachMeasurement
    }

    private class AnimatedApproachMeasurementImpl(
        private val request: RequestConstraints,
        gestureContext: GestureContext,
        spec: () -> MotionSpec,
        label: String?,
        stableThreshold: Float,
        private val onDispose: AnimatedApproachMeasurementImpl.() -> Unit,
    ) : MotionDriver.AnimatedApproachMeasurement {
        var isObserved = true
        private var lastInput: Float? = null

        val motionValue: MotionValue =
            MotionValue(
                input = { lastInput ?: 0f },
                gestureContext = gestureContext,
                spec = derivedStateOf(spec)::value,
                label = label,
                stableThreshold = stableThreshold,
            )

        override var inProgress: Boolean by mutableStateOf(false)

        override var value: Float by mutableFloatStateOf(motionValue.output)

        fun updateInput(input: Constraints): Boolean {
            val newInput = request.constraintsToInput(input)
            val isNew = lastInput != newInput
            if (isNew) lastInput = newInput
            return isNew
        }

        fun computeOutput() {
            val currentOutput = motionValue.output
            if (currentOutput.isFinite()) {
                inProgress = value != currentOutput
                value = currentOutput
            }
        }

        override fun dispose() {
            isObserved = false
            onDispose()
        }

        suspend fun keepRunningWhileObserved() = motionValue.keepRunningWhile { isObserved }
    }
}
+0 −125
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].
 */
// TODO(b/410524175) MotionValueNode will be removed in a follow up CL
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(
            input = { currentInputState },
            gestureContext = gestureContext,
            spec = { 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
    }
}
+54 −110

File changed.

Preview size limit exceeded, changes collapsed.

+56 −107

File changed.

Preview size limit exceeded, changes collapsed.

+58 −54
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
@@ -49,10 +50,11 @@ import com.android.compose.animation.scene.SceneTransitionLayout
import com.android.compose.animation.scene.Swipe
import com.android.compose.animation.scene.UserActionResult
import com.android.compose.animation.scene.featureOfElement
import com.android.compose.animation.scene.mechanics.rememberGestureContext
import com.android.compose.animation.scene.rememberMutableSceneTransitionLayoutState
import com.android.compose.animation.scene.transitions
import com.android.mechanics.debug.MotionValueDebuggerState
import com.android.mechanics.debug.motionValueDebugger
import com.android.mechanics.debug.LocalMotionValueDebugController
import com.android.mechanics.debug.MotionValueDebugController
import com.android.mechanics.spec.builder.MotionBuilderContext
import com.android.mechanics.spec.builder.rememberMotionBuilderContext
import com.android.mechanics.testing.FakeMotionSpecBuilderContext
@@ -83,7 +85,7 @@ class VerticalTactileSurfaceRevealModifierTest(val useOverlays: Boolean) :
            createGoldenPathManager("frameworks/libs/systemui/mechanics/compose/tests/goldens")
        )

    private val debugger = MotionValueDebuggerState()
    private val debugger = MotionValueDebugController()

    private fun assertVerticalTactileSurfaceRevealMotion(
        goldenName: String,
@@ -99,11 +101,11 @@ class VerticalTactileSurfaceRevealModifierTest(val useOverlays: Boolean) :
            val boxes = 8
            @Composable
            fun ContentScope.TestContent(modifier: Modifier = Modifier) {
                val contentScope = this
                Box(modifier = modifier.fillMaxSize()) {
                    Column(
                        modifier =
                            Modifier.element(ContainerElement)
                                .motionDriver(rememberGestureContext())
                                .verticalScroll(rememberScrollState())
                                .background(Color.LightGray)
                                .padding(4.dp),
@@ -121,9 +123,7 @@ class VerticalTactileSurfaceRevealModifierTest(val useOverlays: Boolean) :
                                        },
                                    )
                                    .verticalTactileSurfaceReveal(
                                        contentScope = contentScope,
                                        motionBuilderContext = rememberMotionBuilderContext(),
                                        container = ContainerElement,
                                        label = "box$it",
                                    )
                                    .size(50.dp)
@@ -136,6 +136,9 @@ class VerticalTactileSurfaceRevealModifierTest(val useOverlays: Boolean) :
            val motion =
                recordMotion(
                    content = {
                        CompositionLocalProvider(
                            LocalMotionValueDebugController provides debugger
                        ) {
                            state =
                                rememberMutableSceneTransitionLayoutState(
                                    initialScene = gestureControl.startScene,
@@ -155,8 +158,7 @@ class VerticalTactileSurfaceRevealModifierTest(val useOverlays: Boolean) :
                                modifier =
                                    Modifier.background(Color.Yellow)
                                        .size(ContainerSize)
                                    .testTag(STL_TAG)
                                    .motionValueDebugger(debugger),
                                        .testTag(STL_TAG),
                                implicitTestTags = true,
                            ) {
                                scene(
@@ -179,7 +181,9 @@ class VerticalTactileSurfaceRevealModifierTest(val useOverlays: Boolean) :
                                                Swipe.Up to
                                                    UserActionResult.HideOverlay(ExpandedOverlay)
                                            ),
                                    content = { TestContent(Modifier.border(2.dp, Color.Magenta)) },
                                        content = {
                                            TestContent(Modifier.border(2.dp, Color.Magenta))
                                        },
                                    )
                                } else {
                                    scene(
@@ -189,6 +193,7 @@ class VerticalTactileSurfaceRevealModifierTest(val useOverlays: Boolean) :
                                    )
                                }
                            }
                        }
                    },
                    ComposeRecordingSpec(
                        recording = {
@@ -198,8 +203,7 @@ class VerticalTactileSurfaceRevealModifierTest(val useOverlays: Boolean) :
                            )

                            awaitCondition {
                                !state.isTransitioning() &&
                                    debugger.observedMotionValues.all { it.isStable }
                                !state.isTransitioning() && debugger.observed.all { it.isStable }
                            }
                        },
                        timeSeriesCapture = {
Loading