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

Commit 0d1979ec authored by Mike Schneider's avatar Mike Schneider Committed by Android (Google) Code Review
Browse files

Merge "Implement verticalContainerReveal according to spec" into main

parents 550c9145 ca93864f
Loading
Loading
Loading
Loading
+141 −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.behavior

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.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.requireGraphicsContext
import androidx.compose.ui.util.fastCoerceAtLeast
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.lerp
import kotlin.math.min

/**
 * Draws the background of an edge container, and applies clipping to it.
 *
 * Intended to be used with a [EdgeContainerExpansionSpec] motion.
 */
fun Modifier.edgeContainerExpansionBackground(
    backgroundColor: Color,
    spec: EdgeContainerExpansionSpec,
): Modifier = this.then(EdgeContainerExpansionBackgroundElement(backgroundColor, spec))

internal class EdgeContainerExpansionBackgroundNode(
    var backgroundColor: Color,
    var spec: EdgeContainerExpansionSpec,
) : Modifier.Node(), DrawModifierNode {

    private var graphicsLayer: GraphicsLayer? = null
    private var lastOutlineSize = Size.Zero

    fun invalidateOutline() {
        lastOutlineSize = Size.Zero
    }

    override fun onAttach() {
        graphicsLayer = requireGraphicsContext().createGraphicsLayer().apply { clip = true }
    }

    override fun onDetach() {
        requireGraphicsContext().releaseGraphicsLayer(checkNotNull(graphicsLayer))
    }

    override fun ContentDrawScope.draw() {
        val height = size.height

        // The width is growing between visibleHeight and detachHeight
        val visibleHeight = spec.visibleHeight.toPx()
        val widthFraction =
            ((height - visibleHeight) / (spec.detachHeight.toPx() - visibleHeight)).fastCoerceIn(
                0f,
                1f,
            )
        val width = size.width - lerp(spec.widthOffset.toPx(), 0f, widthFraction)
        val horizontalInset = (size.width - width) / 2f

        // The radius is growing at the beginning of the transition
        val radius = height.fastCoerceIn(spec.minRadius.toPx(), spec.radius.toPx())

        // Draw (at most) the bottom half of the rounded corner rectangle, aligned to the bottom.
        val upperHeight = height - radius

        // The rounded rect is drawn at 2x the radius height, to avoid smaller corner radii.
        // The clipRect limits this to the relevant part (-1 to avoid a hairline gap being visible
        // between this and the fill below.
        clipRect(top = (upperHeight - 1).fastCoerceAtLeast(0f)) {
            drawRoundRect(
                color = backgroundColor,
                cornerRadius = CornerRadius(radius),
                size = Size(width, radius * 2f),
                topLeft = Offset(horizontalInset, size.height - radius * 2f),
            )
        }

        if (upperHeight > 0) {
            // Fill the space above the bottom shape.
            drawRect(
                color = backgroundColor,
                topLeft = Offset(horizontalInset, 0f),
                size = Size(width, upperHeight),
            )
        }

        // Draw the node's content in a separate layer.
        val graphicsLayer = checkNotNull(graphicsLayer)
        graphicsLayer.record { this@draw.drawContent() }

        if (size != lastOutlineSize) {
            // The clip outline is a rounded corner shape matching the bottom of the shape.
            // At the top, the rounded corner shape extends by radiusPx above top.
            // This clipping thus would not prevent the containers content to overdraw at the top,
            // however this is off-screen anyways.
            val top = min(-radius, height - radius * 2f)

            val rect = Rect(left = horizontalInset, top = top, right = width, bottom = height)
            graphicsLayer.setRoundRectOutline(rect.topLeft, rect.size, radius)
            lastOutlineSize = size
        }

        this.drawLayer(graphicsLayer)
    }
}

private data class EdgeContainerExpansionBackgroundElement(
    val backgroundColor: Color,
    val spec: EdgeContainerExpansionSpec,
) : ModifierNodeElement<EdgeContainerExpansionBackgroundNode>() {
    override fun create(): EdgeContainerExpansionBackgroundNode =
        EdgeContainerExpansionBackgroundNode(backgroundColor, spec)

    override fun update(node: EdgeContainerExpansionBackgroundNode) {
        node.backgroundColor = backgroundColor
        if (node.spec != spec) {
            node.spec = spec
            node.invalidateOutline()
        }
    }
}
+157 −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.
 */

@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)

package com.android.mechanics.behavior

import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MotionScheme
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.lerp
import com.android.mechanics.spec.Breakpoint
import com.android.mechanics.spec.BreakpointKey
import com.android.mechanics.spec.DirectionalMotionSpec
import com.android.mechanics.spec.InputDirection
import com.android.mechanics.spec.Mapping
import com.android.mechanics.spec.MotionSpec
import com.android.mechanics.spec.OnChangeSegmentHandler
import com.android.mechanics.spec.SegmentData
import com.android.mechanics.spec.SegmentKey
import com.android.mechanics.spec.builder
import com.android.mechanics.spec.reverseBuilder
import com.android.mechanics.spring.SpringParameters

/** Motion spec for a vertically expandable container. */
class EdgeContainerExpansionSpec(
    val visibleHeight: Dp = Defaults.VisibleHeight,
    val preDetachRatio: Float = Defaults.PreDetachRatio,
    val detachHeight: Dp = Defaults.DetachHeight,
    val attachHeight: Dp = Defaults.AttachHeight,
    val widthOffset: Dp = Defaults.WidthOffset,
    val minRadius: Dp = Defaults.MinRadius,
    val radius: Dp = Defaults.Radius,
    val attachSpring: SpringParameters = Defaults.AttachSpring,
    val detachSpring: SpringParameters = Defaults.DetachSpring,
    val opacitySpring: SpringParameters = Defaults.OpacitySpring,
) {
    fun createHeightSpec(motionScheme: MotionScheme, density: Density): MotionSpec {
        return with(density) {
            val spatialSpring = SpringParameters(motionScheme.defaultSpatialSpec())

            val detachSpec =
                DirectionalMotionSpec.builder(
                        initialMapping = Mapping.Zero,
                        defaultSpring = spatialSpring,
                    )
                    .toBreakpoint(0f, key = Breakpoints.Attach)
                    .continueWith(Mapping.Linear(preDetachRatio))
                    .toBreakpoint(detachHeight.toPx(), key = Breakpoints.Detach)
                    .completeWith(Mapping.Identity, detachSpring)

            val attachSpec =
                DirectionalMotionSpec.reverseBuilder(defaultSpring = spatialSpring)
                    .toBreakpoint(attachHeight.toPx(), key = Breakpoints.Detach)
                    .completeWith(mapping = Mapping.Zero, attachSpring)

            val segmentHandlers =
                mapOf<SegmentKey, OnChangeSegmentHandler>(
                    SegmentKey(Breakpoints.Detach, Breakpoint.maxLimit.key, InputDirection.Min) to
                        { currentSegment, _, newDirection ->
                            if (newDirection != currentSegment.direction) currentSegment else null
                        },
                    SegmentKey(Breakpoints.Attach, Breakpoints.Detach, InputDirection.Max) to
                        { currentSegment: SegmentData, newInput: Float, newDirection: InputDirection
                            ->
                            if (newDirection != currentSegment.direction && newInput >= 0)
                                currentSegment
                            else null
                        },
                )

            MotionSpec(
                maxDirection = detachSpec,
                minDirection = attachSpec,
                segmentHandlers = segmentHandlers,
            )
        }
    }

    fun createWidthSpec(
        intrinsicWidth: Float,
        motionScheme: MotionScheme,
        density: Density,
    ): MotionSpec {
        return with(density) {
            MotionSpec.builder(
                    SpringParameters(motionScheme.defaultSpatialSpec()),
                    initialMapping = { input ->
                        val fraction = (input / detachHeight.toPx()).fastCoerceIn(0f, 1f)
                        intrinsicWidth - lerp(widthOffset.toPx(), 0f, fraction)
                    },
                )
                .complete()
        }
    }

    fun createAlphaSpec(motionScheme: MotionScheme, density: Density): MotionSpec {
        return with(density) {
            val detachSpec =
                DirectionalMotionSpec.builder(
                        SpringParameters(motionScheme.defaultEffectsSpec()),
                        initialMapping = Mapping.Zero,
                    )
                    .toBreakpoint(visibleHeight.toPx())
                    .completeWith(Mapping.One, opacitySpring)

            val attachSpec =
                DirectionalMotionSpec.builder(
                        SpringParameters(motionScheme.defaultEffectsSpec()),
                        initialMapping = Mapping.Zero,
                    )
                    .toBreakpoint(visibleHeight.toPx())
                    .completeWith(Mapping.One, opacitySpring)

            MotionSpec(maxDirection = detachSpec, minDirection = attachSpec)
        }
    }

    companion object {
        object Breakpoints {
            val Attach = BreakpointKey("EdgeContainerExpansion::Attach")
            val Detach = BreakpointKey("EdgeContainerExpansion::Detach")
        }

        object Defaults {
            val VisibleHeight = 24.dp
            val PreDetachRatio = .25f
            val DetachHeight = 80.dp
            val AttachHeight = 40.dp

            val WidthOffset = 28.dp

            val MinRadius = 28.dp
            val Radius = 46.dp

            val AttachSpring = SpringParameters(stiffness = 380f, dampingRatio = 0.9f)
            val DetachSpring = SpringParameters(stiffness = 380f, dampingRatio = 0.9f)
            val OpacitySpring = SpringParameters(stiffness = 1200f, dampingRatio = 0.99f)
        }
    }
}
+49 −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.
 */
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)

package com.android.mechanics.spring

import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.SpringSpec
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable

/** Converts a [SpringSpec] into its [SpringParameters] equivalent. */
fun SpringParameters(springSpec: SpringSpec<out Any>) =
    with(springSpec) { SpringParameters(stiffness, dampingRatio) }

/**
 * Converts a [FiniteAnimationSpec] from the [MotionScheme] into its [SpringParameters] equivalent.
 */
@ExperimentalMaterial3ExpressiveApi
fun SpringParameters(animationSpec: FiniteAnimationSpec<out Any>): SpringParameters {
    check(animationSpec is SpringSpec) {
        "animationSpec is expected to be a SpringSpec, but is $animationSpec"
    }
    return SpringParameters(animationSpec)
}

@Composable
fun defaultSpatialSpring(): SpringParameters {
    return SpringParameters(MaterialTheme.motionScheme.defaultSpatialSpec())
}

@Composable
fun defaultEffectSpring(): SpringParameters {
    return SpringParameters(MaterialTheme.motionScheme.defaultEffectsSpec())
}