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

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

Snap for 14055258 from 4a4d840f to 25Q4-release

Change-Id: Ida4585d585069c9ee9813e36a2174fc10d49933a
parents 0ce4ca80 4a4d840f
Loading
Loading
Loading
Loading
+43 −34
Original line number Diff line number Diff line
@@ -38,7 +38,6 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import platform.test.motion.compose.MonotonicClockTestScope
import platform.test.motion.compose.runMonotonicClockTest

/** Benchmark, which will execute on an Android device. Previous results: go/mm-microbenchmarks */
@RunWith(Parameterized::class)
@@ -47,7 +46,9 @@ class MotionValueCollectionBenchmark(private val instanceCount: Int) {
    companion object {
        @JvmStatic
        @Parameterized.Parameters(name = "instanceCount={0}")
        fun instanceCount() = listOf(100)
        fun instanceCount() = listOf(1, 100)

        val DefaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f)
    }

    @get:Rule val benchmarkRule = BenchmarkRule()
@@ -117,69 +118,77 @@ class MotionValueCollectionBenchmark(private val instanceCount: Int) {
        testScheduler.advanceTimeBy(16)
    }

    private fun MonotonicClockTestScope.measureOscillatingInput(
        fixture: TestFixture,
        stepSize: Float = 1f,
    ) {
        var step = stepSize
        benchmarkRule.measureRepeated {
            val lastInput = fixture.gestureContext.dragOffset
            if (lastInput <= .5f) step = stepSize else if (lastInput >= 9.5f) step = -stepSize
            fixture.gestureContext.dragOffset = lastInput + step
            nextFrame()
        }
    }

    @Test
    fun noChange() = runMonotonicClockTest {
        val fixture = testFixture()

        benchmarkRule.measureRepeated {
            fixture.gestureContext.dragOffset += 0f
            nextFrame()
        }
        measureOscillatingInput(fixture, stepSize = 0f)
    }

    @Test
    fun changeInput() = runMonotonicClockTest {
        val fixture = testFixture()

        benchmarkRule.measureRepeated {
            fixture.gestureContext.dragOffset += 1f
            nextFrame()
        measureOscillatingInput(fixture)
    }

    @Test
    fun changeInput_sameOutput() = runMonotonicClockTest {
        val spec = MotionSpec(directionalMotionSpec(Mapping.Zero))

        val fixture = testFixture(initialInput = 4f) { spec }
        measureOscillatingInput(fixture)
    }

    @Test
    fun animateOutput() = runMonotonicClockTest {
    fun changeSegment_noDiscontinuity() = runMonotonicClockTest {
        val spec =
            MotionSpec(
                directionalMotionSpec(
                    defaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f),
                    initialMapping = Mapping.Zero,
                ) {
                    fixedValue(breakpoint = 5f, value = 1f)
                directionalMotionSpec(DefaultSpring, Mapping.Zero) {
                    mapping(breakpoint = 5f, mapping = Mapping.Zero)
                }
            )

        val fixture = testFixture(initialInput = 4f) { spec }
        var stepSize = 1f
        measureOscillatingInput(fixture)
    }

        benchmarkRule.measureRepeated {
            val lastInput = fixture.gestureContext.dragOffset
            if (lastInput <= .5f) stepSize = 1f else if (lastInput >= 9.5f) stepSize = -1f
            fixture.gestureContext.dragOffset = lastInput + stepSize
            nextFrame()
    @Test
    fun animateOutput() = runMonotonicClockTest {
        val spec =
            MotionSpec(
                directionalMotionSpec(DefaultSpring, Mapping.Zero) {
                    fixedValue(breakpoint = 5f, value = 1f)
                }
            )

        val fixture = testFixture(initialInput = 4f) { spec }
        measureOscillatingInput(fixture)
    }

    @Test
    fun animateWithGuarantee() = runMonotonicClockTest {
        val spec =
            MotionSpec(
                directionalMotionSpec(
                    defaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f),
                    initialMapping = Mapping.Zero,
                ) {
                directionalMotionSpec(DefaultSpring, Mapping.Zero) {
                    fixedValue(breakpoint = 5f, value = 1f, guarantee = Guarantee.InputDelta(4f))
                }
            )

        val fixture = testFixture { spec }
        var stepSize = 1f

        benchmarkRule.measureRepeated {
            val lastInput = fixture.gestureContext.dragOffset
            if (lastInput <= .5f) stepSize = 1f else if (lastInput >= 9.5f) stepSize = -1f
            fixture.gestureContext.dragOffset = lastInput + stepSize
            nextFrame()
        }
        measureOscillatingInput(fixture)
    }
}
+29 −163
Original line number Diff line number Diff line
@@ -16,12 +16,10 @@

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
@@ -34,19 +32,15 @@ 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.ManagedMotionValue
import com.android.mechanics.MotionValueCollection
import com.android.mechanics.spec.MotionSpec
import kotlinx.coroutines.DisposableHandle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

private const val TRAVERSAL_NODE_KEY = "MotionDriverNode"
@@ -66,13 +60,10 @@ internal fun DelegatableNode.findMotionDriver(): MotionDriver {
/**
 * 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.
 * A `MotionDriver` is attached to a layout node using the [motionDriver] modifier. Descendant nodes
 * can then find this driver to create animations whose target values are derived from the driver's
 * layout `Constraints`. This allows for coordinated animations within a component tree that react
 * to a parent's size changes, such as expanding or collapsing.
 */
internal interface MotionDriver {
    /** The [GestureContext] associated with this motion. */
@@ -103,62 +94,16 @@ internal interface MotionDriver {
    fun Placeable.PlacementScope.driverOffset(): Offset

    /**
     * Creates and registers a [AnimatedApproachMeasurement] that animates based on layout
     * constraints.
     * Creates and registers a [ManagedMotionValue] that animates based on layout constraints.
     *
     * The returned value will automatically update its output whenever the `MotionDriver`'s layout
     * constraints change.
     * The value will automatically update its output whenever the `MotionDriver`'s `maxHeight`
     * constraint changes.
     *
     * @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.
     * @return A [ManagedMotionValue] that provides the animated output.
     */
    interface AnimatedApproachMeasurement : DisposableHandle {
        val inProgress: Boolean

        val value: Float
    }
    fun maxHeightDriven(spec: () -> MotionSpec, label: String? = null): ManagedMotionValue
}

/**
@@ -176,10 +121,11 @@ fun Modifier.motionDriver(gestureContext: GestureContext, label: String? = null)

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

    override fun update(node: MotionDriverNode) {
        node.update(gestureContext = gestureContext)
        check(node.gestureContext == gestureContext) { "Cannot change the gestureContext" }
    }

    override fun InspectorInfo.inspectableProperties() {
@@ -188,26 +134,31 @@ private data class MotionDriverElement(val gestureContext: GestureContext, val l
    }
}

private class MotionDriverNode(override var gestureContext: GestureContext) :
private class MotionDriverNode(override val gestureContext: GestureContext, label: String?) :
    Modifier.Node(),
    TraversableNode,
    LayoutModifierNode,
    MotionDriver,
    CompositionLocalConsumerModifierNode {
    private val animatedValues = mutableListOf<AnimatedApproachMeasurementImpl>()
    override val traverseKey: Any = TRAVERSAL_NODE_KEY
    override var verticalState: MotionDriver.State by mutableStateOf(MotionDriver.State.MinValue)

    private var driverCoordinates: LayoutCoordinates? = null
    private var lookAheadHeight: Int = 0
    private var input by mutableFloatStateOf(0f)
    private val motionValues = MotionValueCollection(::input, gestureContext, label = label)

    override val traverseKey: Any = TRAVERSAL_NODE_KEY
    override var verticalState: MotionDriver.State by mutableStateOf(MotionDriver.State.MinValue)
    override fun onAttach() {
        coroutineScope.launch(Dispatchers.Main.immediate) { motionValues.keepRunning() }
    }

    fun update(gestureContext: GestureContext) {
        this.gestureContext = gestureContext
    override fun maxHeightDriven(spec: () -> MotionSpec, label: String?): ManagedMotionValue {
        return motionValues.create(spec, label)
    }

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

@@ -229,7 +180,7 @@ private class MotionDriverNode(override var gestureContext: GestureContext) :
                    else -> MotionDriver.State.Transition
                }

            animatedValues.fastForEach { it.updateInput(constraints) }
            input = constraints.maxHeight.toFloat()
        }

        return layout(width = placeable.width, height = placeable.height) {
@@ -237,89 +188,4 @@ private class MotionDriverNode(override var gestureContext: GestureContext) :
            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 }
    }
}
+55 −43
Original line number Diff line number Diff line
@@ -34,6 +34,8 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastCoerceAtLeast
import com.android.mechanics.ManagedMotionValue
import com.android.mechanics.debug.DebugMotionValueNode
import com.android.mechanics.effects.FixedValue
import com.android.mechanics.spec.Mapping
import com.android.mechanics.spec.MotionSpec
@@ -51,88 +53,79 @@ fun Modifier.verticalFadeContentReveal(
    motionBuilderContext: MotionBuilderContext,
    deltaY: Float = 0f,
    label: String? = null,
    debug: Boolean = false,
): Modifier =
    this then
        FadeContentRevealElement(
            motionBuilderContext = motionBuilderContext,
            deltaY = deltaY,
            label = label,
            debug = debug,
        )

private data class FadeContentRevealElement(
    val motionBuilderContext: MotionBuilderContext,
    val deltaY: Float,
    val label: String?,
    val debug: Boolean,
) : ModifierNodeElement<FadeContentRevealNode>() {
    override fun create(): FadeContentRevealNode =
        FadeContentRevealNode(
            motionBuilderContext = motionBuilderContext,
            deltaY = deltaY,
            label = label,
            debug = debug,
        )

    override fun update(node: FadeContentRevealNode) {
        node.update(motionBuilderContext = motionBuilderContext, deltaY = deltaY)
        check(node.deltaY == deltaY) { "Cannot update deltaY from ${node.deltaY} to $deltaY" }
        node.update(motionBuilderContext = motionBuilderContext)
    }

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

private class FadeContentRevealNode(
    private var motionBuilderContext: MotionBuilderContext,
    deltaY: Float,
    val deltaY: Float,
    private val label: String?,
    private val debug: Boolean,
) : DelegatingNode(), ApproachLayoutModifierNode {
    private var lookAheadHeight by mutableFloatStateOf(0f)
    private var layoutOffsetY by mutableFloatStateOf(0f)
    private var deltaY: Float by mutableFloatStateOf(deltaY)
    // These properties are calculated during the lookahead pass (`lookAheadMeasure`) to
    // orchestrate the reveal animation. They are guaranteed to be updated before `approachMeasure`
    // is called.
    private var lookAheadHeight by mutableFloatStateOf(Float.NaN)
    private var layoutOffsetY by mutableFloatStateOf(Float.NaN)
    // Created lazily upon first lookahead and disposed in `onDetach`.
    private var revealAlpha: ManagedMotionValue? = null

    private lateinit var animatedApproachMeasurement: MotionDriver.AnimatedApproachMeasurement
    /**
     * The [MotionDriver] that controls the parent's motion, used to determine the reveal
     * animation's progress.
     *
     * It is initialized in `onAttach` and is safe to use in all subsequent measure passes.
     */
    private lateinit var motionDriver: MotionDriver

    fun update(motionBuilderContext: MotionBuilderContext, deltaY: Float) {
        this.motionBuilderContext = motionBuilderContext
        this.deltaY = deltaY
    }

    override fun onAttach() {
        motionDriver = findMotionDriver()
        animatedApproachMeasurement =
            motionDriver.animatedApproachMeasurement(
                request = MotionDriver.RequestConstraints.MaxHeight,
                spec = derivedStateOf(::spec)::value,
                label = "FadeContentReveal(${label.orEmpty()})",
                debug = debug,
            )
    }

    override fun onDetach() {
        animatedApproachMeasurement.dispose()
    fun update(motionBuilderContext: MotionBuilderContext) {
        this.motionBuilderContext = motionBuilderContext
    }

    private fun spec(): MotionSpec {
        if (lookAheadHeight == 0f) {
            // We cannot compute specs for height 0.
            return motionBuilderContext.fixedEffectsValueSpec(0f)
    override fun onDetach() {
        revealAlpha?.dispose()
    }

    private fun spec(): MotionSpec {
        return when (motionDriver.verticalState) {
            MotionDriver.State.MinValue -> {
                motionBuilderContext.fixedEffectsValueSpec(0f)
            }
            MotionDriver.State.Transition -> {
                motionBuilderContext.effectsMotionSpec(Mapping.Zero) {
                    after(layoutOffsetY + lookAheadHeight + deltaY, FixedValue.One)
                    after(layoutOffsetY + lookAheadHeight, FixedValue.One)
                }
            }
            MotionDriver.State.MaxValue -> {
@@ -145,20 +138,41 @@ private class FadeContentRevealNode(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val placeable = measurable.measure(constraints)
        if (isLookingAhead) {
            lookAheadHeight = placeable.height.toFloat()
        return if (isLookingAhead) {
            lookAheadMeasure(measurable, constraints)
        } else {
            measurable.measure(constraints).run { layout(width, height) { place(IntOffset.Zero) } }
        }
    }

    private fun MeasureScope.lookAheadMeasure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val placeable = measurable.measure(constraints)
        val targetHeight = placeable.height.toFloat()
        lookAheadHeight = targetHeight
        return layout(placeable.width, placeable.height) {
            if (isLookingAhead) {
                layoutOffsetY = with(motionDriver) { driverOffset() }.y
            layoutOffsetY = with(motionDriver) { driverOffset() }.y + deltaY

            if (revealAlpha == null) {
                val maxHeightDriven =
                    motionDriver.maxHeightDriven(
                        spec = derivedStateOf(::spec)::value,
                        label = "FadeContentReveal(${label.orEmpty()})",
                    )
                revealAlpha = maxHeightDriven
                delegate(DebugMotionValueNode(maxHeightDriven))
            }

            placeable.place(IntOffset.Zero)
        }
    }

    override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean {
        return animatedApproachMeasurement.inProgress
        val revealAlpha = revealAlpha
        return revealAlpha != null &&
            (motionDriver.verticalState == MotionDriver.State.Transition || !revealAlpha.isStable)
    }

    override fun ApproachMeasureScope.approachMeasure(
@@ -167,14 +181,12 @@ private class FadeContentRevealNode(
    ): MeasureResult {
        return measurable.measure(constraints).run {
            layout(width, height) {
                val revealAlpha = animatedApproachMeasurement.value.fastCoerceAtLeast(0f)
                if (revealAlpha < 1f) {
                placeWithLayer(IntOffset.Zero) {
                    val revealAlpha = checkNotNull(revealAlpha).output.fastCoerceAtLeast(0f)
                    if (revealAlpha < 1f) {
                        alpha = revealAlpha
                        compositingStrategy = CompositingStrategy.ModulateAlpha
                    }
                } else {
                    place(IntOffset.Zero)
                }
            }
        }
+95 −55

File changed.

Preview size limit exceeded, changes collapsed.

+76 −141

File changed.

Preview size limit exceeded, changes collapsed.

Loading