From 7c353ae99a9d08e7f80ec790ede848d5574a57d0 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Fri, 21 Oct 2022 14:02:39 +0200 Subject: [PATCH 1/3] Add support for borders in Expandable (1/2) This CL adds support for borders in Expandable. See b/230830644#comment24 for a video. Bug: 230830644 Test: Manual Change-Id: If9ce29bac72b725eb01b1839a982a2ce17efc41a --- .../systemui/compose/animation/Expandable.kt | 149 +++++++++++++++--- .../compose/animation/ExpandableController.kt | 14 +- 2 files changed, 139 insertions(+), 24 deletions(-) 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 0abdeca31898c..5267f79482d6b 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,7 +20,9 @@ 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.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -41,10 +43,17 @@ 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.onGloballyPositioned @@ -53,7 +62,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Density import androidx.lifecycle.ViewTreeLifecycleOwner import androidx.lifecycle.ViewTreeViewModelStoreOwner +import com.android.systemui.animation.Expandable import com.android.systemui.animation.LaunchAnimator +import kotlin.math.max import kotlin.math.min /** @@ -85,10 +96,11 @@ fun Expandable( shape: Shape, modifier: Modifier = Modifier, contentColor: Color = contentColorFor(color), + borderStroke: BorderStroke? = null, content: @Composable (ExpandableController) -> Unit, ) { Expandable( - rememberExpandableController(color, shape, contentColor), + rememberExpandableController(color, shape, contentColor, borderStroke), modifier, content, ) @@ -189,9 +201,13 @@ fun Expandable( } else -> { Box( - modifier.clip(shape).background(color, shape).onGloballyPositioned { - controller.boundsInComposeViewRoot.value = it.boundsInRoot() - } + modifier + .clip(shape) + .background(color, shape) + .border(controller) + .onGloballyPositioned { + controller.boundsInComposeViewRoot.value = it.boundsInRoot() + } ) { wrappedContent(controller) } } } @@ -204,7 +220,7 @@ private fun AnimatedContentInOverlay( sizeInOriginalLayout: Size, animatorState: State, overlay: ViewGroupOverlay, - controller: ExpandableController, + controller: ExpandableControllerImpl, content: @Composable (ExpandableController) -> Unit, composeViewRoot: View, onOverlayComposeViewChanged: (View?) -> Unit, @@ -254,24 +270,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. @@ -360,3 +359,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, From b85fe2b4a0b178c885bdb4e1d3d193c5d29e01b4 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Tue, 13 Dec 2022 13:23:12 +0100 Subject: [PATCH 2/3] Make clickable Expandables have a minimum (interactive) size (1/2) This CL improves Expandable by making sure that expandables that expand when clicked directly have a minimum size of 40dp and minimum interactive size (touchable area) of 48dp. This is consistent with the M3 components like buttons. Bug: 230830644 Test: Manual Change-Id: Iada048498ff4655406968d7dac5d13c948efaea1 --- .../systemui/compose/animation/Expandable.kt | 110 ++++++++++++++---- 1 file changed, 90 insertions(+), 20 deletions(-) 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 5267f79482d6b..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 @@ -23,12 +23,16 @@ 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 @@ -36,8 +40,10 @@ 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 @@ -56,34 +62,48 @@ 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 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()) + * }, + * ) { + * ... * } * ``` * @@ -97,11 +117,13 @@ fun Expandable( modifier: Modifier = Modifier, contentColor: Color = contentColorFor(color), borderStroke: BorderStroke? = null, - content: @Composable (ExpandableController) -> Unit, + onClick: ((Expandable) -> Unit)? = null, + content: @Composable (Expandable) -> Unit, ) { Expandable( rememberExpandableController(color, shape, contentColor, borderStroke), modifier, + onClick, content, ) } @@ -130,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 @@ -148,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). @@ -164,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."), @@ -193,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() @@ -200,15 +256,29 @@ fun Expandable( ) { wrappedContent(controller) } } else -> { + val clickModifier = + if (onClick != null) { + Modifier.clickable { onClick(controller.expandable) } + } else { + Modifier + } + 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) } + }, + ) { + wrappedContent(controller) + } } } } From 509c9a2b6a2e242054e477b831da96edb477c4a9 Mon Sep 17 00:00:00 2001 From: Jordan Demeulenaere Date: Thu, 15 Dec 2022 11:27:39 +0100 Subject: [PATCH 3/3] Introduce the FadingBackground modifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This CL introduces an overload of Modifier.background which takes a `alpha: () -> Float` parameter to set the alpha of the background. This should be used when the alpha of the background is animated, to avoid recompositions during an animation. The content of FadingBackground.kt was mostly copy pasted from Background.kt [1]. [1] http://cs/androidx-platform-dev/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Background.kt Bug: 242040009 Test: Manual Change-Id: I3296f7bc7382e2afe77a088cfdbefbfae134051d --- .../compose/modifiers/FadingBackground.kt | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packages/SystemUI/compose/core/src/com/android/systemui/compose/modifiers/FadingBackground.kt 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)" +}