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

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

MM: MotionValueCollection in MotionDriver, read from graphicLayer 1/2

Revert submission 35460170-revert-35413718-mvc_in_md-ZYUFOMHIOR

Reason for revert: Fix the compilation issue

Reverted changes: /q/submissionid:35460170-revert-35413718-mvc_in_md-ZYUFOMHIOR

This commit refactors `MotionDriver` and its clients to simplify the API
and improve performance.

The key changes are:
- `AnimatedApproachMeasurement` is removed and replaced with direct use
  of `ManagedMotionValue`.
- `MotionDriver` now directly manages a `MotionValueCollection` to
  create and manage `ManagedMotionValue` instances.
- `VerticalTactileSurfaceRevealModifier` and
  `VerticalFadeContentRevealModifier` are updated to use the new
  `MotionDriver` API.
- `VerticalTactileSurfaceRevealModifier` now handles its own clipping
  and alpha animation for better control over the reveal effect.
- The `RevealOnThreshold` effect now includes a `cornerMaxSize`
  parameter to control the corner radius of the revealed surface.

Test: Manually tested in the demo app
Bug: 392535471
Flag: com.android.systemui.scene_container
Change-Id: Ie05630af93c1978c995c963c857268a55544592b
parent eb3f8615
Loading
Loading
Loading
Loading
+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 }
    }
}
+34 −32
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,28 +53,24 @@ 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) {
@@ -83,7 +81,6 @@ private data class FadeContentRevealElement(
        name = "fadeContentReveal"
        properties["deltaY"] = deltaY
        properties["label"] = label
        properties["debug"] = debug
    }
}

@@ -91,14 +88,14 @@ private class FadeContentRevealNode(
    private var motionBuilderContext: MotionBuilderContext,
    deltaY: Float,
    private val label: String?,
    private val debug: Boolean,
) : DelegatingNode(), ApproachLayoutModifierNode {
    private var lookAheadHeight by mutableFloatStateOf(0f)
    private var lookAheadHeight by mutableFloatStateOf(Float.NaN)
    private var layoutOffsetY by mutableFloatStateOf(0f)
    private var deltaY: Float by mutableFloatStateOf(deltaY)

    private lateinit var animatedApproachMeasurement: MotionDriver.AnimatedApproachMeasurement
    private lateinit var motionDriver: MotionDriver
    // Created after the first lookahead measure, guaranteed to be created before first measure
    private var revealAlpha: ManagedMotionValue? = null

    fun update(motionBuilderContext: MotionBuilderContext, deltaY: Float) {
        this.motionBuilderContext = motionBuilderContext
@@ -107,25 +104,13 @@ private class FadeContentRevealNode(

    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()
        revealAlpha?.dispose()
    }

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

        return when (motionDriver.verticalState) {
            MotionDriver.State.MinValue -> {
                motionBuilderContext.fixedEffectsValueSpec(0f)
@@ -144,21 +129,40 @@ private class FadeContentRevealNode(
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        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)
        if (isLookingAhead) {
            lookAheadHeight = placeable.height.toFloat()
        val targetHeight = placeable.height.toFloat()
        lookAheadHeight = targetHeight
        if (revealAlpha == null) {
            val maxHeightDriven =
                motionDriver.maxHeightDriven(
                    spec = derivedStateOf(::spec)::value,
                    label = "FadeContentReveal(${label.orEmpty()})",
                )
            revealAlpha = maxHeightDriven
            delegate(DebugMotionValueNode(maxHeightDriven))
        }
        return layout(placeable.width, placeable.height) {
            if (isLookingAhead) {
            layoutOffsetY = with(motionDriver) { driverOffset() }.y
            }
            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 +171,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)
                }
            }
        }
+71 −38
Original line number Diff line number Diff line
@@ -16,12 +16,19 @@

package com.android.mechanics.compose.modifier

import androidx.compose.foundation.shape.GenericShape
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.GraphicsLayerScope
import androidx.compose.ui.layout.ApproachLayoutModifierNode
import androidx.compose.ui.layout.ApproachMeasureScope
import androidx.compose.ui.layout.Measurable
@@ -35,6 +42,9 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.util.fastCoerceAtLeast
import androidx.compose.ui.util.fastCoerceAtMost
import com.android.mechanics.ManagedMotionValue
import com.android.mechanics.debug.DebugMotionValueNode
import com.android.mechanics.effects.RevealOnThreshold
import com.android.mechanics.spec.Mapping
import com.android.mechanics.spec.MotionSpec
@@ -55,7 +65,6 @@ fun Modifier.verticalTactileSurfaceReveal(
    deltaY: Float = 0f,
    revealOnThreshold: RevealOnThreshold = DefaultRevealOnThreshold,
    label: String? = null,
    debug: Boolean = false,
): Modifier =
    this then
        VerticalTactileSurfaceRevealElement(
@@ -63,7 +72,6 @@ fun Modifier.verticalTactileSurfaceReveal(
            deltaY = deltaY,
            revealOnThreshold = revealOnThreshold,
            label = label,
            debug = debug,
        )

private val DefaultRevealOnThreshold = RevealOnThreshold()
@@ -73,7 +81,6 @@ private data class VerticalTactileSurfaceRevealElement(
    val deltaY: Float,
    val revealOnThreshold: RevealOnThreshold,
    val label: String?,
    val debug: Boolean,
) : ModifierNodeElement<VerticalTactileSurfaceRevealNode>() {
    override fun create(): VerticalTactileSurfaceRevealNode =
        VerticalTactileSurfaceRevealNode(
@@ -81,7 +88,6 @@ private data class VerticalTactileSurfaceRevealElement(
            deltaY = deltaY,
            revealOnThreshold = revealOnThreshold,
            label = label,
            debug = debug,
        )

    override fun update(node: VerticalTactileSurfaceRevealNode) {
@@ -97,7 +103,6 @@ private data class VerticalTactileSurfaceRevealElement(
        properties["deltaY"] = deltaY
        properties["revealOnThreshold"] = revealOnThreshold
        properties["label"] = label
        properties["debug"] = debug
    }
}

@@ -106,14 +111,14 @@ private class VerticalTactileSurfaceRevealNode(
    deltaY: Float,
    private var revealOnThreshold: RevealOnThreshold,
    private val label: String?,
    private val debug: Boolean,
) : DelegatingNode(), ApproachLayoutModifierNode {
    private var lookAheadHeight by mutableFloatStateOf(0f)
    private var lookAheadHeight by mutableFloatStateOf(Float.NaN)
    private var layoutOffsetY by mutableFloatStateOf(0f)
    private var deltaY: Float by mutableFloatStateOf(deltaY)

    private lateinit var animatedApproachMeasurement: MotionDriver.AnimatedApproachMeasurement
    private lateinit var motionDriver: MotionDriver
    // Created after the first lookahead measure, guaranteed to be created before first measure
    private var revealHeight: ManagedMotionValue? = null

    fun update(
        motionBuilderContext: MotionBuilderContext,
@@ -127,25 +132,13 @@ private class VerticalTactileSurfaceRevealNode(

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

    override fun onDetach() {
        animatedApproachMeasurement.dispose()
        revealHeight?.dispose()
    }

    private fun spec(): MotionSpec {
        if (lookAheadHeight == 0f) {
            // We cannot compute specs for height 0.
            return motionBuilderContext.fixedSpatialValueSpec(0f)
        }

        return when (motionDriver.verticalState) {
            MotionDriver.State.MinValue -> {
                motionBuilderContext.fixedSpatialValueSpec(0f)
@@ -168,41 +161,81 @@ private class VerticalTactileSurfaceRevealNode(
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        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)
        if (isLookingAhead) {
            lookAheadHeight = placeable.height.toFloat()
        val targetHeight = placeable.height.toFloat()
        lookAheadHeight = targetHeight
        if (revealHeight == null) {
            val maxHeightDriven =
                motionDriver.maxHeightDriven(
                    spec = derivedStateOf(::spec)::value,
                    label = "TactileSurfaceReveal(${label.orEmpty()})",
                )
            revealHeight = maxHeightDriven
            delegate(DebugMotionValueNode(maxHeightDriven))
        }
        return layout(placeable.width, placeable.height) {
            if (isLookingAhead) {
            layoutOffsetY = with(motionDriver) { driverOffset() }.y
            }
            placeable.place(IntOffset.Zero)
        }
    }

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

    override fun ApproachMeasureScope.approachMeasure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val height = constraints.constrainHeight(animatedApproachMeasurement.value.roundToInt())
        val animatedConstraints = constraints.copy(maxHeight = height)

        return measurable.measure(animatedConstraints).run {
        return measurable.measure(constraints).run {
            layout(width, height) {
                val revealAlpha = (height / revealOnThreshold.minSize.toPx()).fastCoerceAtLeast(0f)
                if (revealAlpha < 1f) {
                placeWithLayer(IntOffset.Zero) {
                        alpha = revealAlpha
                        compositingStrategy = CompositingStrategy.ModulateAlpha
                    val revealHeight =
                        constraints
                            .constrainHeight(checkNotNull(revealHeight).output.roundToInt())
                            .toFloat()

                    if (revealHeight != lookAheadHeight) {
                        approachGraphicsLayer(revealHeight)
                    }
                }
            }
        }
    }

    private fun GraphicsLayerScope.approachGraphicsLayer(revealHeight: Float) {
        translationY = (revealHeight - lookAheadHeight) / 2f
        clip = true
        shape = GenericShape { placeableSize, _ ->
            val rect = Rect(Offset(0f, -translationY), Size(placeableSize.width, revealHeight))
            val cornerMaxSize = revealOnThreshold.cornerMaxSize.toPx()
            if (cornerMaxSize != 0f) {
                val radius = (revealHeight / 2f).fastCoerceAtMost(cornerMaxSize)
                addRoundRect(RoundRect(rect, CornerRadius(radius)))
            } else {
                    place(IntOffset.Zero)
                addRect(rect)
            }
        }
        val fullyVisibleMinHeight = revealOnThreshold.minSize.toPx()
        if (fullyVisibleMinHeight != 0f) {
            val revealAlpha = (revealHeight / fullyVisibleMinHeight).fastCoerceAtLeast(0f)
            if (revealAlpha < 1f) {
                alpha = revealAlpha
                compositingStrategy = CompositingStrategy.ModulateAlpha
            }
        }
    }
+6 −1
Original line number Diff line number Diff line
@@ -26,9 +26,13 @@ import com.android.mechanics.spec.builder.EffectApplyScope
import com.android.mechanics.spec.builder.EffectPlacement

/** An effect that reveals a component when the available space reaches a certain threshold. */
data class RevealOnThreshold(val minSize: Dp = Defaults.MinSize) : Effect.PlaceableBetween {
data class RevealOnThreshold(
    val minSize: Dp = Defaults.MinSize,
    val cornerMaxSize: Dp = Defaults.CornerMaxSize,
) : Effect.PlaceableBetween {
    init {
        require(minSize >= 0.dp)
        require(cornerMaxSize >= 0.dp)
    }

    override fun EffectApplyScope.createSpec(
@@ -52,5 +56,6 @@ data class RevealOnThreshold(val minSize: Dp = Defaults.MinSize) : Effect.Placea

    object Defaults {
        val MinSize: Dp = 8.dp
        val CornerMaxSize: Dp = 32.dp
    }
}