Merge changes from topics "expandable-border", "expandable-interactive-size" into tm-qpr-dev am: 2ea532629c

Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/20745108

Change-Id: I032f37dbe16131d3917e479d943e3e697e333edb
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
Jordan Demeulenaere
2022-12-20 11:11:01 +00:00
committed by Automerger Merge Worker
3 changed files with 345 additions and 43 deletions

View File

@@ -20,13 +20,19 @@ import android.content.Context
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroupOverlay import android.view.ViewGroupOverlay
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background 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.Box
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumTouchTargetEnforcement
import androidx.compose.material3.contentColorFor import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
@@ -34,45 +40,70 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius 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.geometry.Size
import androidx.compose.ui.graphics.Color 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.Shape
import androidx.compose.ui.graphics.drawOutline 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.graphics.drawscope.scale
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewTreeLifecycleOwner import androidx.lifecycle.ViewTreeLifecycleOwner
import androidx.lifecycle.ViewTreeViewModelStoreOwner import androidx.lifecycle.ViewTreeViewModelStoreOwner
import com.android.systemui.animation.Expandable
import com.android.systemui.animation.LaunchAnimator import com.android.systemui.animation.LaunchAnimator
import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt
/** /**
* Create an expandable shape that can launch into an Activity or a Dialog. * 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: * Example:
* ``` * ```
* Expandable( * Expandable(
* color = MaterialTheme.colorScheme.primary, * color = MaterialTheme.colorScheme.primary,
* shape = RoundedCornerShape(16.dp), * shape = RoundedCornerShape(16.dp),
* ) { controller ->
* Row(
* Modifier
* // For activities:
* .clickable { activityStarter.startActivity(intent, controller.forActivity()) }
* *
* // For dialogs: * // For activities:
* .clickable { dialogLaunchAnimator.show(dialog, controller.forDialog()) } * onClick = { expandable ->
* ) { ... } * activityStarter.startActivity(intent, expandable.activityLaunchController())
* },
*
* // For dialogs:
* onClick = { expandable ->
* dialogLaunchAnimator.show(dialog, controller.dialogLaunchController())
* },
* ) {
* ...
* } * }
* ``` * ```
* *
@@ -85,11 +116,14 @@ fun Expandable(
shape: Shape, shape: Shape,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentColor: Color = contentColorFor(color), contentColor: Color = contentColorFor(color),
content: @Composable (ExpandableController) -> Unit, borderStroke: BorderStroke? = null,
onClick: ((Expandable) -> Unit)? = null,
content: @Composable (Expandable) -> Unit,
) { ) {
Expandable( Expandable(
rememberExpandableController(color, shape, contentColor), rememberExpandableController(color, shape, contentColor, borderStroke),
modifier, modifier,
onClick,
content, content,
) )
} }
@@ -118,11 +152,13 @@ fun Expandable(
* @sample com.android.systemui.compose.gallery.ActivityLaunchScreen * @sample com.android.systemui.compose.gallery.ActivityLaunchScreen
* @sample com.android.systemui.compose.gallery.DialogLaunchScreen * @sample com.android.systemui.compose.gallery.DialogLaunchScreen
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Expandable( fun Expandable(
controller: ExpandableController, controller: ExpandableController,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable (ExpandableController) -> Unit, onClick: ((Expandable) -> Unit)? = null,
content: @Composable (Expandable) -> Unit,
) { ) {
val controller = controller as ExpandableControllerImpl val controller = controller as ExpandableControllerImpl
val color = controller.color val color = controller.color
@@ -136,13 +172,23 @@ fun Expandable(
CompositionLocalProvider( CompositionLocalProvider(
LocalContentColor provides contentColor, 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 { var thisExpandableSize by remember { mutableStateOf(Size.Zero) }
derivedStateOf { controller.boundsInComposeViewRoot.value.size }
}
// Make sure we don't read animatorState directly here to avoid recomposition every time the // Make sure we don't read animatorState directly here to avoid recomposition every time the
// state changes (i.e. every frame of the animation). // state changes (i.e. every frame of the animation).
@@ -152,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 { when {
isAnimating -> { isAnimating -> {
// Don't compose the movable content during the animation, as it should be composed only // 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 // once at all times. We make this spacer exactly the same size as this Expandable when
// it is visible. // it is visible.
Spacer( Spacer(
modifier modifier.requiredSize(with(controller.density) { thisExpandableSize.toDpSize() })
.clip(shape)
.requiredSize(with(controller.density) { thisExpandableSize.toDpSize() })
) )
// The content and its animated background in the overlay. We draw it only when we are // The content and its animated background in the overlay. We draw it only when we are
// animating. // animating.
AnimatedContentInOverlay( AnimatedContentInOverlay(
color, color,
thisExpandableSize, controller.boundsInComposeViewRoot.value.size,
controller.animatorState, controller.animatorState,
controller.overlay.value controller.overlay.value
?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."), ?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."),
@@ -181,6 +247,8 @@ fun Expandable(
controller.isDialogShowing.value -> { controller.isDialogShowing.value -> {
Box( Box(
modifier modifier
.onGloballyPositioned { thisExpandableSize = it.boundsInRoot().size }
.then(minInteractiveSizeModifier)
.drawWithContent { /* Don't draw anything when the dialog is shown. */} .drawWithContent { /* Don't draw anything when the dialog is shown. */}
.onGloballyPositioned { .onGloballyPositioned {
controller.boundsInComposeViewRoot.value = it.boundsInRoot() controller.boundsInComposeViewRoot.value = it.boundsInRoot()
@@ -188,11 +256,29 @@ fun Expandable(
) { wrappedContent(controller) } ) { wrappedContent(controller) }
} }
else -> { else -> {
Box( val clickModifier =
modifier.clip(shape).background(color, shape).onGloballyPositioned { if (onClick != null) {
controller.boundsInComposeViewRoot.value = it.boundsInRoot() Modifier.clickable { onClick(controller.expandable) }
} else {
Modifier
} }
) { wrappedContent(controller) }
Box(
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)
}
} }
} }
} }
@@ -204,7 +290,7 @@ private fun AnimatedContentInOverlay(
sizeInOriginalLayout: Size, sizeInOriginalLayout: Size,
animatorState: State<LaunchAnimator.State?>, animatorState: State<LaunchAnimator.State?>,
overlay: ViewGroupOverlay, overlay: ViewGroupOverlay,
controller: ExpandableController, controller: ExpandableControllerImpl,
content: @Composable (ExpandableController) -> Unit, content: @Composable (ExpandableController) -> Unit,
composeViewRoot: View, composeViewRoot: View,
onOverlayComposeViewChanged: (View?) -> Unit, onOverlayComposeViewChanged: (View?) -> Unit,
@@ -254,24 +340,7 @@ private fun AnimatedContentInOverlay(
return@drawWithContent return@drawWithContent
} }
val topRadius = animatorState.topCornerRadius drawBackground(animatorState, color, controller.borderStroke)
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)
}
drawContent() drawContent()
}, },
// We center the content in the expanding container. // We center the content in the expanding container.
@@ -360,3 +429,107 @@ private fun getOverlayViewGroup(context: Context, overlay: ViewGroupOverlay): Vi
overlay.remove(view) overlay.remove(view)
return current as ViewGroup 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))

View File

@@ -20,6 +20,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroupOverlay import android.view.ViewGroupOverlay
import android.view.ViewRootImpl import android.view.ViewRootImpl
import androidx.compose.foundation.BorderStroke
import androidx.compose.material3.contentColorFor import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -62,6 +63,7 @@ fun rememberExpandableController(
color: Color, color: Color,
shape: Shape, shape: Shape,
contentColor: Color = contentColorFor(color), contentColor: Color = contentColorFor(color),
borderStroke: BorderStroke? = null,
): ExpandableController { ): ExpandableController {
val composeViewRoot = LocalView.current val composeViewRoot = LocalView.current
val density = LocalDensity.current val density = LocalDensity.current
@@ -87,11 +89,20 @@ fun rememberExpandableController(
val isComposed = remember { mutableStateOf(true) } val isComposed = remember { mutableStateOf(true) }
DisposableEffect(Unit) { onDispose { isComposed.value = false } } DisposableEffect(Unit) { onDispose { isComposed.value = false } }
return remember(color, contentColor, shape, composeViewRoot, density, layoutDirection) { return remember(
color,
contentColor,
shape,
borderStroke,
composeViewRoot,
density,
layoutDirection,
) {
ExpandableControllerImpl( ExpandableControllerImpl(
color, color,
contentColor, contentColor,
shape, shape,
borderStroke,
composeViewRoot, composeViewRoot,
density, density,
animatorState, animatorState,
@@ -109,6 +120,7 @@ internal class ExpandableControllerImpl(
internal val color: Color, internal val color: Color,
internal val contentColor: Color, internal val contentColor: Color,
internal val shape: Shape, internal val shape: Shape,
internal val borderStroke: BorderStroke?,
internal val composeViewRoot: View, internal val composeViewRoot: View,
internal val density: Density, internal val density: Density,
internal val animatorState: MutableState<LaunchAnimator.State?>, internal val animatorState: MutableState<LaunchAnimator.State?>,

View File

@@ -0,0 +1,117 @@
/*
* 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)"
}