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

Commit 2ea53262 authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Android (Google) Code Review
Browse files

Merge changes from topics "expandable-border", "expandable-interactive-size" into tm-qpr-dev

* changes:
  Introduce the FadingBackground modifier
  Make clickable Expandables have a minimum (interactive) size (1/2)
  Add support for borders in Expandable (1/2)
  Reconcile Expandable.kt on master/tm-qpr-dev-plus-aosp and tm-qpr-dev
parents fd1cfdb4 509c9a2b
Loading
Loading
Loading
Loading
+215 −43
Original line number Diff line number Diff line
@@ -20,13 +20,19 @@ import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroupOverlay
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -34,46 +40,70 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewTreeLifecycleOwner
import androidx.lifecycle.ViewTreeViewModelStoreOwner
import androidx.savedstate.ViewTreeSavedStateRegistryOwner
import com.android.systemui.animation.Expandable
import com.android.systemui.animation.LaunchAnimator
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt

/**
 * Create an expandable shape that can launch into an Activity or a Dialog.
 *
 * If this expandable should be expanded when it is clicked directly, then you should specify a
 * [onClick] handler, which will ensure that this expandable interactive size and background size
 * are consistent with the M3 components (48dp and 40dp respectively).
 *
 * If this expandable should be expanded when a children component is clicked, like a button inside
 * the expandable, then you can use the Expandable parameter passed to the [content] lambda.
 *
 * Example:
 * ```
 *    Expandable(
 *      color = MaterialTheme.colorScheme.primary,
 *      shape = RoundedCornerShape(16.dp),
 *    ) { controller ->
 *      Row(
 *        Modifier
 *
 *      // For activities:
 *          .clickable { activityStarter.startActivity(intent, controller.forActivity()) }
 *      onClick = { expandable ->
 *          activityStarter.startActivity(intent, expandable.activityLaunchController())
 *      },
 *
 *      // For dialogs:
 *          .clickable { dialogLaunchAnimator.show(dialog, controller.forDialog()) }
 *      ) { ... }
 *      onClick = { expandable ->
 *          dialogLaunchAnimator.show(dialog, controller.dialogLaunchController())
 *      },
 *    ) {
 *      ...
 *    }
 * ```
 *
@@ -86,11 +116,14 @@ fun Expandable(
    shape: Shape,
    modifier: Modifier = Modifier,
    contentColor: Color = contentColorFor(color),
    content: @Composable (ExpandableController) -> Unit,
    borderStroke: BorderStroke? = null,
    onClick: ((Expandable) -> Unit)? = null,
    content: @Composable (Expandable) -> Unit,
) {
    Expandable(
        rememberExpandableController(color, shape, contentColor),
        rememberExpandableController(color, shape, contentColor, borderStroke),
        modifier,
        onClick,
        content,
    )
}
@@ -119,11 +152,13 @@ fun Expandable(
 * @sample com.android.systemui.compose.gallery.ActivityLaunchScreen
 * @sample com.android.systemui.compose.gallery.DialogLaunchScreen
 */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Expandable(
    controller: ExpandableController,
    modifier: Modifier = Modifier,
    content: @Composable (ExpandableController) -> Unit,
    onClick: ((Expandable) -> Unit)? = null,
    content: @Composable (Expandable) -> Unit,
) {
    val controller = controller as ExpandableControllerImpl
    val color = controller.color
@@ -137,14 +172,24 @@ fun Expandable(
            CompositionLocalProvider(
                LocalContentColor provides contentColor,
            ) {
                content(controller)
                // We make sure that the content itself (wrapped by the background) is at least
                // 40.dp, which is the same as the M3 buttons. This applies even if onClick is
                // null, to make it easier to write expandables that are sometimes clickable and
                // sometimes not. There shouldn't be any Expandable smaller than 40dp because if
                // the expandable is not clickable directly, then something in its content should
                // be (and with a size >= 40dp).
                val minSize = 40.dp
                Box(
                    Modifier.defaultMinSize(minWidth = minSize, minHeight = minSize),
                    contentAlignment = Alignment.Center,
                ) {
                    content(controller.expandable)
                }
            }

    val thisExpandableSize by remember {
        derivedStateOf { controller.boundsInComposeViewRoot.value.size }
        }

    var thisExpandableSize by remember { mutableStateOf(Size.Zero) }

    // Make sure we don't read animatorState directly here to avoid recomposition every time the
    // state changes (i.e. every frame of the animation).
    val isAnimating by remember {
@@ -153,22 +198,42 @@ fun Expandable(
        }
    }

    // If this expandable is expanded when it's being directly clicked on, let's ensure that it has
    // the minimum interactive size followed by all M3 components (48.dp).
    val minInteractiveSizeModifier =
        if (onClick != null && LocalMinimumTouchTargetEnforcement.current) {
            // TODO(b/242040009): Replace this by Modifier.minimumInteractiveComponentSize() once
            // http://aosp/2305511 is available.
            val minTouchSize = LocalViewConfiguration.current.minimumTouchTargetSize
            Modifier.layout { measurable, constraints ->
                // Copied from androidx.compose.material3.InteractiveComponentSize.kt
                val placeable = measurable.measure(constraints)
                val width = maxOf(placeable.width, minTouchSize.width.roundToPx())
                val height = maxOf(placeable.height, minTouchSize.height.roundToPx())
                layout(width, height) {
                    val centerX = ((width - placeable.width) / 2f).roundToInt()
                    val centerY = ((height - placeable.height) / 2f).roundToInt()
                    placeable.place(centerX, centerY)
                }
            }
        } else {
            Modifier
        }

    when {
        isAnimating -> {
            // Don't compose the movable content during the animation, as it should be composed only
            // once at all times. We make this spacer exactly the same size as this Expandable when
            // it is visible.
            Spacer(
                modifier
                    .clip(shape)
                    .requiredSize(with(controller.density) { thisExpandableSize.toDpSize() })
                modifier.requiredSize(with(controller.density) { thisExpandableSize.toDpSize() })
            )

            // The content and its animated background in the overlay. We draw it only when we are
            // animating.
            AnimatedContentInOverlay(
                color,
                thisExpandableSize,
                controller.boundsInComposeViewRoot.value.size,
                controller.animatorState,
                controller.overlay.value
                    ?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."),
@@ -182,6 +247,8 @@ fun Expandable(
        controller.isDialogShowing.value -> {
            Box(
                modifier
                    .onGloballyPositioned { thisExpandableSize = it.boundsInRoot().size }
                    .then(minInteractiveSizeModifier)
                    .drawWithContent { /* Don't draw anything when the dialog is shown. */}
                    .onGloballyPositioned {
                        controller.boundsInComposeViewRoot.value = it.boundsInRoot()
@@ -189,11 +256,29 @@ fun Expandable(
            ) { wrappedContent(controller) }
        }
        else -> {
            val clickModifier =
                if (onClick != null) {
                    Modifier.clickable { onClick(controller.expandable) }
                } else {
                    Modifier
                }

            Box(
                modifier.clip(shape).background(color, shape).onGloballyPositioned {
                modifier
                    .onGloballyPositioned { thisExpandableSize = it.boundsInRoot().size }
                    .then(minInteractiveSizeModifier)
                    // Note that clip() *must* be above the clickModifier to properly clip the
                    // ripple.
                    .clip(shape)
                    .then(clickModifier)
                    .background(color, shape)
                    .border(controller)
                    .onGloballyPositioned {
                        controller.boundsInComposeViewRoot.value = it.boundsInRoot()
                    },
            ) {
                wrappedContent(controller)
            }
            ) { wrappedContent(controller) }
        }
    }
}
@@ -205,7 +290,7 @@ private fun AnimatedContentInOverlay(
    sizeInOriginalLayout: Size,
    animatorState: State<LaunchAnimator.State?>,
    overlay: ViewGroupOverlay,
    controller: ExpandableController,
    controller: ExpandableControllerImpl,
    content: @Composable (ExpandableController) -> Unit,
    composeViewRoot: View,
    onOverlayComposeViewChanged: (View?) -> Unit,
@@ -255,24 +340,7 @@ private fun AnimatedContentInOverlay(
                                    return@drawWithContent
                                }

                                val topRadius = animatorState.topCornerRadius
                                val bottomRadius = animatorState.bottomCornerRadius
                                if (topRadius == bottomRadius) {
                                    // Shortcut to avoid Outline calculation and allocation.
                                    val cornerRadius = CornerRadius(topRadius)
                                    drawRoundRect(color, cornerRadius = cornerRadius)
                                } else {
                                    val shape =
                                        RoundedCornerShape(
                                            topStart = topRadius,
                                            topEnd = topRadius,
                                            bottomStart = bottomRadius,
                                            bottomEnd = bottomRadius,
                                        )
                                    val outline = shape.createOutline(size, layoutDirection, this)
                                    drawOutline(outline, color = color)
                                }

                                drawBackground(animatorState, color, controller.borderStroke)
                                drawContent()
                            },
                            // We center the content in the expanding container.
@@ -361,3 +429,107 @@ private fun getOverlayViewGroup(context: Context, overlay: ViewGroupOverlay): Vi
    overlay.remove(view)
    return current as ViewGroup
}

private fun Modifier.border(controller: ExpandableControllerImpl): Modifier {
    return if (controller.borderStroke != null) {
        this.border(controller.borderStroke, controller.shape)
    } else {
        this
    }
}

private fun ContentDrawScope.drawBackground(
    animatorState: LaunchAnimator.State,
    color: Color,
    border: BorderStroke?,
) {
    val topRadius = animatorState.topCornerRadius
    val bottomRadius = animatorState.bottomCornerRadius
    if (topRadius == bottomRadius) {
        // Shortcut to avoid Outline calculation and allocation.
        val cornerRadius = CornerRadius(topRadius)

        // Draw the background.
        drawRoundRect(color, cornerRadius = cornerRadius)

        // Draw the border.
        if (border != null) {
            // Copied from androidx.compose.foundation.Border.kt
            val strokeWidth = border.width.toPx()
            val halfStroke = strokeWidth / 2
            val borderStroke = Stroke(strokeWidth)

            drawRoundRect(
                brush = border.brush,
                topLeft = Offset(halfStroke, halfStroke),
                size = Size(size.width - strokeWidth, size.height - strokeWidth),
                cornerRadius = cornerRadius.shrink(halfStroke),
                style = borderStroke
            )
        }
    } else {
        val shape =
            RoundedCornerShape(
                topStart = topRadius,
                topEnd = topRadius,
                bottomStart = bottomRadius,
                bottomEnd = bottomRadius,
            )
        val outline = shape.createOutline(size, layoutDirection, this)

        // Draw the background.
        drawOutline(outline, color = color)

        // Draw the border.
        if (border != null) {
            // Copied from androidx.compose.foundation.Border.kt.
            val strokeWidth = border.width.toPx()
            val path =
                createRoundRectPath(
                    (outline as Outline.Rounded).roundRect,
                    strokeWidth,
                )

            drawPath(path, border.brush)
        }
    }
}

/**
 * Helper method that creates a round rect with the inner region removed by the given stroke width.
 *
 * Copied from androidx.compose.foundation.Border.kt.
 */
private fun createRoundRectPath(
    roundedRect: RoundRect,
    strokeWidth: Float,
): Path {
    return Path().apply {
        addRoundRect(roundedRect)
        val insetPath =
            Path().apply { addRoundRect(createInsetRoundedRect(strokeWidth, roundedRect)) }
        op(this, insetPath, PathOperation.Difference)
    }
}

/* Copied from androidx.compose.foundation.Border.kt. */
private fun createInsetRoundedRect(widthPx: Float, roundedRect: RoundRect) =
    RoundRect(
        left = widthPx,
        top = widthPx,
        right = roundedRect.width - widthPx,
        bottom = roundedRect.height - widthPx,
        topLeftCornerRadius = roundedRect.topLeftCornerRadius.shrink(widthPx),
        topRightCornerRadius = roundedRect.topRightCornerRadius.shrink(widthPx),
        bottomLeftCornerRadius = roundedRect.bottomLeftCornerRadius.shrink(widthPx),
        bottomRightCornerRadius = roundedRect.bottomRightCornerRadius.shrink(widthPx)
    )

/**
 * Helper method to shrink the corner radius by the given value, clamping to 0 if the resultant
 * corner radius would be negative.
 *
 * Copied from androidx.compose.foundation.Border.kt.
 */
private fun CornerRadius.shrink(value: Float): CornerRadius =
    CornerRadius(max(0f, this.x - value), max(0f, this.y - value))
+13 −1
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ import android.view.View
import android.view.ViewGroup
import android.view.ViewGroupOverlay
import android.view.ViewRootImpl
import androidx.compose.foundation.BorderStroke
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -62,6 +63,7 @@ fun rememberExpandableController(
    color: Color,
    shape: Shape,
    contentColor: Color = contentColorFor(color),
    borderStroke: BorderStroke? = null,
): ExpandableController {
    val composeViewRoot = LocalView.current
    val density = LocalDensity.current
@@ -87,11 +89,20 @@ fun rememberExpandableController(
    val isComposed = remember { mutableStateOf(true) }
    DisposableEffect(Unit) { onDispose { isComposed.value = false } }

    return remember(color, contentColor, shape, composeViewRoot, density, layoutDirection) {
    return remember(
        color,
        contentColor,
        shape,
        borderStroke,
        composeViewRoot,
        density,
        layoutDirection,
    ) {
        ExpandableControllerImpl(
            color,
            contentColor,
            shape,
            borderStroke,
            composeViewRoot,
            density,
            animatorState,
@@ -109,6 +120,7 @@ internal class ExpandableControllerImpl(
    internal val color: Color,
    internal val contentColor: Color,
    internal val shape: Shape,
    internal val borderStroke: BorderStroke?,
    internal val composeViewRoot: View,
    internal val density: Density,
    internal val animatorState: MutableState<LaunchAnimator.State?>,
+33 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.systemui.compose.animation

import android.view.View
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.ViewTreeSavedStateRegistryOwner as AndroidXViewTreeSavedStateRegistryOwner

// TODO(b/262222023): Remove this workaround and import the new savedstate libraries in tm-qpr-dev
// instead.
object ViewTreeSavedStateRegistryOwner {
    fun set(view: View, owner: SavedStateRegistryOwner?) {
        AndroidXViewTreeSavedStateRegistryOwner.set(view, owner)
    }

    fun get(view: View): SavedStateRegistryOwner? {
        return AndroidXViewTreeSavedStateRegistryOwner.get(view)
    }
}
+117 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 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.systemui.compose.modifiers

import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.DrawModifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.platform.InspectorValueInfo
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.LayoutDirection

/**
 * Draws a fading [shape] with a solid [color] and [alpha] behind the content.
 *
 * @param color color to paint background with
 * @param alpha alpha of the background
 * @param shape desired shape of the background
 */
fun Modifier.background(
    color: Color,
    alpha: () -> Float,
    shape: Shape = RectangleShape,
) =
    this.then(
        FadingBackground(
            brush = SolidColor(color),
            alpha = alpha,
            shape = shape,
            inspectorInfo =
                debugInspectorInfo {
                    name = "background"
                    value = color
                    properties["color"] = color
                    properties["alpha"] = alpha
                    properties["shape"] = shape
                }
        )
    )

private class FadingBackground
constructor(
    private val brush: Brush,
    private val shape: Shape,
    private val alpha: () -> Float,
    inspectorInfo: InspectorInfo.() -> Unit
) : DrawModifier, InspectorValueInfo(inspectorInfo) {
    // naive cache outline calculation if size is the same
    private var lastSize: Size? = null
    private var lastLayoutDirection: LayoutDirection? = null
    private var lastOutline: Outline? = null

    override fun ContentDrawScope.draw() {
        if (shape === RectangleShape) {
            // shortcut to avoid Outline calculation and allocation
            drawRect()
        } else {
            drawOutline()
        }
        drawContent()
    }

    private fun ContentDrawScope.drawRect() {
        drawRect(brush, alpha = alpha())
    }

    private fun ContentDrawScope.drawOutline() {
        val outline =
            if (size == lastSize && layoutDirection == lastLayoutDirection) {
                lastOutline!!
            } else {
                shape.createOutline(size, layoutDirection, this)
            }
        drawOutline(outline, brush = brush, alpha = alpha())
        lastOutline = outline
        lastSize = size
        lastLayoutDirection = layoutDirection
    }

    override fun hashCode(): Int {
        var result = brush.hashCode()
        result = 31 * result + alpha.hashCode()
        result = 31 * result + shape.hashCode()
        return result
    }

    override fun equals(other: Any?): Boolean {
        val otherModifier = other as? FadingBackground ?: return false
        return brush == otherModifier.brush &&
            alpha == otherModifier.alpha &&
            shape == otherModifier.shape
    }

    override fun toString(): String = "FadingBackground(brush=$brush, alpha = $alpha, shape=$shape)"
}