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 8f9a4dae67bd8..18534f43ae871 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.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()) } * - * // 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 @@ 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,13 +172,23 @@ 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). @@ -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 -> { - 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 @@ private fun AnimatedContentInOverlay( sizeInOriginalLayout: Size, animatorState: 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)) 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 d6db574a34ae7..f75b3a8a61c42 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.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, 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 0000000000000..79f1cad1eb936 --- /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 0000000000000..121bf2cf8a053 --- /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)" +}