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
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 5267f79..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
@@ -23,12 +23,16 @@
 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.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.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 @@
     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 @@
  * @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 @@
             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 @@
         }
     }
 
+    // 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 @@
         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 @@
             ) { 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)
+            }
         }
     }
 }