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
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt
index 8f9a4da..18534f4 100644
--- a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/Expandable.kt
@@ -20,13 +20,19 @@
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.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()) }
*
- * // For dialogs:
- * .clickable { dialogLaunchAnimator.show(dialog, controller.forDialog()) }
- * ) { ... }
+ * // For activities:
+ * onClick = { expandable ->
+ * activityStarter.startActivity(intent, expandable.activityLaunchController())
+ * },
+ *
+ * // For dialogs:
+ * onClick = { expandable ->
+ * dialogLaunchAnimator.show(dialog, controller.dialogLaunchController())
+ * },
+ * ) {
+ * ...
* }
* ```
*
@@ -86,11 +116,14 @@
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 @@
* @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,13 +172,23 @@
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).
@@ -153,22 +198,42 @@
}
}
+ // 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 @@
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 @@
) { wrappedContent(controller) }
}
else -> {
- Box(
- modifier.clip(shape).background(color, shape).onGloballyPositioned {
- controller.boundsInComposeViewRoot.value = it.boundsInRoot()
+ val clickModifier =
+ if (onClick != null) {
+ 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)
+ }
}
}
}
@@ -205,7 +290,7 @@
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 @@
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 @@
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))
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt
index d6db574..f75b3a8 100644
--- a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ExpandableController.kt
@@ -20,6 +20,7 @@
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 @@
color: Color,
shape: Shape,
contentColor: Color = contentColorFor(color),
+ borderStroke: BorderStroke? = null,
): ExpandableController {
val composeViewRoot = LocalView.current
val density = LocalDensity.current
@@ -87,11 +89,20 @@
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 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?>,
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ViewTreeSavedStateRegistryOwner.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ViewTreeSavedStateRegistryOwner.kt
new file mode 100644
index 0000000..79f1cad1
--- /dev/null
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/animation/ViewTreeSavedStateRegistryOwner.kt
@@ -0,0 +1,33 @@
+/*
+ * 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)
+ }
+}
diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/modifiers/FadingBackground.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/modifiers/FadingBackground.kt
new file mode 100644
index 0000000..121bf2c
--- /dev/null
+++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/modifiers/FadingBackground.kt
@@ -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)"
+}