• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.compose.animation
18 
19 import android.content.Context
20 import android.view.View
21 import android.view.ViewGroup
22 import android.view.ViewGroupOverlay
23 import androidx.compose.foundation.BorderStroke
24 import androidx.compose.foundation.border
25 import androidx.compose.foundation.clickable
26 import androidx.compose.foundation.interaction.MutableInteractionSource
27 import androidx.compose.foundation.layout.Box
28 import androidx.compose.foundation.layout.Spacer
29 import androidx.compose.foundation.layout.defaultMinSize
30 import androidx.compose.foundation.layout.fillMaxSize
31 import androidx.compose.foundation.layout.requiredSize
32 import androidx.compose.foundation.shape.RoundedCornerShape
33 import androidx.compose.material3.LocalContentColor
34 import androidx.compose.material3.contentColorFor
35 import androidx.compose.material3.minimumInteractiveComponentSize
36 import androidx.compose.runtime.Composable
37 import androidx.compose.runtime.CompositionLocalProvider
38 import androidx.compose.runtime.DisposableEffect
39 import androidx.compose.runtime.Stable
40 import androidx.compose.runtime.getValue
41 import androidx.compose.runtime.movableContentOf
42 import androidx.compose.runtime.mutableStateOf
43 import androidx.compose.runtime.remember
44 import androidx.compose.runtime.rememberCompositionContext
45 import androidx.compose.runtime.setValue
46 import androidx.compose.ui.Alignment
47 import androidx.compose.ui.Modifier
48 import androidx.compose.ui.draw.clip
49 import androidx.compose.ui.draw.drawWithContent
50 import androidx.compose.ui.geometry.CornerRadius
51 import androidx.compose.ui.geometry.Offset
52 import androidx.compose.ui.geometry.RoundRect
53 import androidx.compose.ui.geometry.Size
54 import androidx.compose.ui.graphics.Color
55 import androidx.compose.ui.graphics.Outline
56 import androidx.compose.ui.graphics.Path
57 import androidx.compose.ui.graphics.PathOperation
58 import androidx.compose.ui.graphics.Shape
59 import androidx.compose.ui.graphics.drawOutline
60 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
61 import androidx.compose.ui.graphics.drawscope.Stroke
62 import androidx.compose.ui.graphics.drawscope.scale
63 import androidx.compose.ui.graphics.drawscope.translate
64 import androidx.compose.ui.graphics.isSpecified
65 import androidx.compose.ui.graphics.layer.GraphicsLayer
66 import androidx.compose.ui.graphics.layer.drawLayer
67 import androidx.compose.ui.graphics.rememberGraphicsLayer
68 import androidx.compose.ui.layout.boundsInRoot
69 import androidx.compose.ui.layout.findRootCoordinates
70 import androidx.compose.ui.layout.layout
71 import androidx.compose.ui.layout.onGloballyPositioned
72 import androidx.compose.ui.layout.onPlaced
73 import androidx.compose.ui.node.DrawModifierNode
74 import androidx.compose.ui.node.ModifierNodeElement
75 import androidx.compose.ui.platform.ComposeView
76 import androidx.compose.ui.platform.LocalContext
77 import androidx.compose.ui.unit.Density
78 import androidx.compose.ui.unit.dp
79 import androidx.lifecycle.findViewTreeLifecycleOwner
80 import androidx.lifecycle.findViewTreeViewModelStoreOwner
81 import androidx.lifecycle.setViewTreeLifecycleOwner
82 import androidx.lifecycle.setViewTreeViewModelStoreOwner
83 import androidx.savedstate.findViewTreeSavedStateRegistryOwner
84 import androidx.savedstate.setViewTreeSavedStateRegistryOwner
85 import com.android.compose.modifiers.animatedBackground
86 import com.android.compose.modifiers.thenIf
87 import com.android.compose.ui.graphics.FullScreenComposeViewInOverlay
88 import com.android.systemui.animation.ComposableControllerFactory
89 import com.android.systemui.animation.Expandable
90 import com.android.systemui.animation.TransitionAnimator
91 import kotlin.math.max
92 import kotlin.math.min
93 
94 /**
95  * Create an expandable shape that can launch into an Activity or a Dialog.
96  *
97  * If this expandable should be expanded when it is clicked directly, then you should specify a
98  * [onClick] handler, which will ensure that this expandable interactive size and background size
99  * are consistent with the M3 components (48dp and 40dp respectively).
100  *
101  * If this expandable should be expanded when a children component is clicked, like a button inside
102  * the expandable, then you can use the Expandable parameter passed to the [content] lambda.
103  *
104  * Example:
105  * ```
106  *    Expandable(
107  *      color = MaterialTheme.colorScheme.primary,
108  *      shape = RoundedCornerShape(16.dp),
109  *
110  *      // For activities:
111  *      onClick = { expandable ->
112  *          activityStarter.startActivity(intent, expandable.activityTransitionController())
113  *      },
114  *
115  *      // For dialogs:
116  *      onClick = { expandable ->
117  *          dialogTransitionAnimator.show(dialog, controller.dialogTransitionController())
118  *      },
119  *    ) {
120  *      ...
121  *    }
122  * ```
123  *
124  * [transitionControllerFactory] must be defined when this [Expandable] is registered for a
125  * long-term launch or return animation, to ensure that animation controllers can be created
126  * correctly.
127  *
128  * @sample com.android.systemui.compose.gallery.ActivityLaunchScreen
129  * @sample com.android.systemui.compose.gallery.DialogLaunchScreen
130  * @param defaultMinSize true if a default minimum size should be enforced even if this Expandable
131  *   isn't currently clickable and false otherwise.
132  */
133 @Composable
134 fun Expandable(
135     color: Color,
136     shape: Shape,
137     modifier: Modifier = Modifier,
138     contentColor: Color = contentColorFor(color),
139     borderStroke: BorderStroke? = null,
140     onClick: ((Expandable) -> Unit)? = null,
141     interactionSource: MutableInteractionSource? = null,
142     // TODO(b/285250939): Default this to true then remove once the Compose QS expandables have
143     // proven that the new implementation is robust.
144     useModifierBasedImplementation: Boolean = false,
145     defaultMinSize: Boolean = true,
146     transitionControllerFactory: ComposableControllerFactory? = null,
147     content: @Composable (Expandable) -> Unit,
148 ) {
149     Expandable(
150         rememberExpandableController(
151             color,
152             shape,
153             contentColor,
154             borderStroke,
155             transitionControllerFactory,
156         ),
157         modifier,
158         onClick,
159         interactionSource,
160         useModifierBasedImplementation,
161         defaultMinSize,
162         content,
163     )
164 }
165 
166 /**
167  * Create an expandable shape that can launch into an Activity or a Dialog.
168  *
169  * This overload can be used in cases where you need to create the [ExpandableController] before
170  * composing this [Expandable], for instance if something outside of this Expandable can trigger a
171  * launch animation
172  *
173  * Example:
174  * ```
175  *    // The controller that you can use to trigger the animations from anywhere.
176  *    val controller =
177  *        rememberExpandableController(
178  *          color = MaterialTheme.colorScheme.primary,
179  *          shape = RoundedCornerShape(16.dp),
180  *        )
181  *
182  *    Expandable(controller) {
183  *       ...
184  *    }
185  * ```
186  *
187  * @sample com.android.systemui.compose.gallery.ActivityLaunchScreen
188  * @sample com.android.systemui.compose.gallery.DialogLaunchScreen
189  * @param defaultMinSize true if a default minimum size should be enforced even if this Expandable
190  *   isn't currently clickable and false otherwise.
191  */
192 @Composable
Expandablenull193 fun Expandable(
194     controller: ExpandableController,
195     modifier: Modifier = Modifier,
196     onClick: ((Expandable) -> Unit)? = null,
197     interactionSource: MutableInteractionSource? = null,
198     // TODO(b/285250939): Default this to true then remove once the Compose QS expandables have
199     // proven that the new implementation is robust.
200     useModifierBasedImplementation: Boolean = false,
201     defaultMinSize: Boolean = true,
202     content: @Composable (Expandable) -> Unit,
203 ) {
204     val controller = controller as ExpandableControllerImpl
205 
206     if (controller.transitionControllerFactory != null) {
207         DisposableEffect(controller.transitionControllerFactory) {
208             // Notify the transition controller factory that the expandable is now available, so it
209             // can move forward with any pending requests.
210             controller.transitionControllerFactory.onCompose(controller.expandable)
211             // Once this composable is gone, the transition controller factory must be notified so
212             // it doesn't accepts requests providing stale content.
213             onDispose { controller.transitionControllerFactory.onDispose() }
214         }
215     }
216 
217     if (useModifierBasedImplementation) {
218         Box(modifier.expandable(controller, onClick, interactionSource)) {
219             WrappedContent(
220                 controller.expandable,
221                 controller.contentColor,
222                 defaultMinSize = defaultMinSize,
223                 content,
224             )
225         }
226         return
227     }
228 
229     val color = controller.color
230     val contentColor = controller.contentColor
231     val shape = controller.shape
232 
233     val wrappedContent =
234         remember(content) {
235             movableContentOf { expandable: Expandable ->
236                 WrappedContent(expandable, contentColor, defaultMinSize = defaultMinSize, content)
237             }
238         }
239 
240     var thisExpandableSize by remember { mutableStateOf(Size.Zero) }
241 
242     /** Set the current element size as this Expandable size. */
243     fun Modifier.updateExpandableSize(): Modifier {
244         return this.onGloballyPositioned { coords ->
245             thisExpandableSize =
246                 coords
247                     .findRootCoordinates()
248                     // Make sure that we report the actual size, and not the visual/clipped one.
249                     .localBoundingBoxOf(coords, clipBounds = false)
250                     .size
251         }
252     }
253 
254     // Make sure we don't read animatorState directly here to avoid recomposition every time the
255     // state changes (i.e. every frame of the animation).
256     val isAnimating = controller.isAnimating
257 
258     // If this expandable is expanded when it's being directly clicked on, let's ensure that it has
259     // the minimum interactive size followed by all M3 components (48.dp).
260     val minInteractiveSizeModifier =
261         if (onClick != null) {
262             Modifier.minimumInteractiveComponentSize()
263         } else {
264             Modifier
265         }
266 
267     when {
268         isAnimating -> {
269             // Don't compose the movable content during the animation, as it should be composed only
270             // once at all times. We make this spacer exactly the same size as this Expandable when
271             // it is visible.
272             Spacer(
273                 modifier.requiredSize(with(controller.density) { thisExpandableSize.toDpSize() })
274             )
275 
276             // The content and its animated background in the overlay. We draw it only when we are
277             // animating.
278             AnimatedContentInOverlay(
279                 color,
280                 controller.boundsInComposeViewRoot.size,
281                 controller.overlay
282                     ?: error("AnimatedContentInOverlay shouldn't be composed with null overlay."),
283                 controller,
284                 wrappedContent,
285                 controller.composeViewRoot,
286                 { controller.currentComposeViewInOverlay = it },
287                 controller.density,
288             )
289         }
290         controller.isDialogShowing -> {
291             Box(
292                 modifier
293                     .updateExpandableSize()
294                     .then(minInteractiveSizeModifier)
295                     .drawWithContent { /* Don't draw anything when the dialog is shown. */ }
296                     .onGloballyPositioned { controller.boundsInComposeViewRoot = it.boundsInRoot() }
297             ) {
298                 wrappedContent(controller.expandable)
299             }
300         }
301         else -> {
302             Box(
303                 modifier
304                     .updateExpandableSize()
305                     .then(minInteractiveSizeModifier)
306                     .then(clickModifier(controller, onClick, interactionSource))
307                     .animatedBackground(color, shape = shape)
308                     .border(controller)
309                     .onGloballyPositioned { controller.boundsInComposeViewRoot = it.boundsInRoot() }
310             ) {
311                 wrappedContent(controller.expandable)
312             }
313         }
314     }
315 }
316 
317 @Composable
WrappedContentnull318 private fun WrappedContent(
319     expandable: Expandable,
320     contentColor: Color,
321     defaultMinSize: Boolean,
322     content: @Composable (Expandable) -> Unit,
323 ) {
324     val minSizeContent =
325         @Composable {
326             if (defaultMinSize) {
327                 // We make sure that the content itself (wrapped by the background) is at
328                 // least 40.dp, which is the same as the M3 buttons. This applies even if
329                 // onClick is null, to make it easier to write expandables that are
330                 // sometimes clickable and sometimes not.
331                 val minSize = 40.dp
332                 Box(
333                     modifier = Modifier.defaultMinSize(minWidth = minSize, minHeight = minSize),
334                     contentAlignment = Alignment.Center,
335                 ) {
336                     content(expandable)
337                 }
338             } else {
339                 content(expandable)
340             }
341         }
342 
343     if (contentColor.isSpecified) {
344         CompositionLocalProvider(LocalContentColor provides contentColor, content = minSizeContent)
345     } else {
346         minSizeContent()
347     }
348 }
349 
350 @Composable
351 @Stable
expandablenull352 private fun Modifier.expandable(
353     controller: ExpandableController,
354     onClick: ((Expandable) -> Unit)? = null,
355     interactionSource: MutableInteractionSource? = null,
356 ): Modifier {
357     val controller = controller as ExpandableControllerImpl
358     val graphicsLayer = rememberGraphicsLayer()
359 
360     val isAnimating = controller.isAnimating
361     if (isAnimating) {
362         FullScreenComposeViewInOverlay(controller.overlay) { view ->
363             Modifier.then(DrawExpandableInOverlayElement(view, controller, graphicsLayer))
364         }
365     }
366 
367     val drawContent = !isAnimating && !controller.isDialogShowing
368     return this.thenIf(onClick != null) { Modifier.minimumInteractiveComponentSize() }
369         .thenIf(drawContent) {
370             Modifier.border(controller)
371                 .then(clickModifier(controller, onClick, interactionSource))
372                 .animatedBackground(controller.color, shape = controller.shape)
373         }
374         .onPlaced { controller.boundsInComposeViewRoot = it.boundsInRoot() }
375         .drawWithContent {
376             graphicsLayer.record { this@drawWithContent.drawContent() }
377 
378             if (drawContent) {
379                 drawLayer(graphicsLayer)
380             }
381         }
382 }
383 
384 private data class DrawExpandableInOverlayElement(
385     private val overlayComposeView: ComposeView,
386     private val controller: ExpandableControllerImpl,
387     private val contentGraphicsLayer: GraphicsLayer,
388 ) : ModifierNodeElement<DrawExpandableInOverlayNode>() {
createnull389     override fun create(): DrawExpandableInOverlayNode {
390         return DrawExpandableInOverlayNode(overlayComposeView, controller, contentGraphicsLayer)
391     }
392 
updatenull393     override fun update(node: DrawExpandableInOverlayNode) {
394         node.update(overlayComposeView, controller, contentGraphicsLayer)
395     }
396 }
397 
398 private class DrawExpandableInOverlayNode(
399     composeView: ComposeView,
400     controller: ExpandableControllerImpl,
401     private var contentGraphicsLayer: GraphicsLayer,
402 ) : Modifier.Node(), DrawModifierNode {
403     private var controller = controller
404         set(value) {
405             resetCurrentNodeInOverlay()
406             field = value
407             setCurrentNodeInOverlay()
408         }
409 
410     private var composeViewLocationOnScreen = composeView.locationOnScreen
411 
updatenull412     fun update(
413         composeView: ComposeView,
414         controller: ExpandableControllerImpl,
415         contentGraphicsLayer: GraphicsLayer,
416     ) {
417         this.controller = controller
418         this.composeViewLocationOnScreen = composeView.locationOnScreen
419         this.contentGraphicsLayer = contentGraphicsLayer
420     }
421 
onAttachnull422     override fun onAttach() {
423         setCurrentNodeInOverlay()
424     }
425 
onDetachnull426     override fun onDetach() {
427         resetCurrentNodeInOverlay()
428     }
429 
setCurrentNodeInOverlaynull430     private fun setCurrentNodeInOverlay() {
431         controller.currentNodeInOverlay = this
432     }
433 
resetCurrentNodeInOverlaynull434     private fun resetCurrentNodeInOverlay() {
435         if (controller.currentNodeInOverlay == this) {
436             controller.currentNodeInOverlay = null
437         }
438     }
439 
drawnull440     override fun ContentDrawScope.draw() {
441         val state = controller.animatorState?.takeIf { it.visible } ?: return
442         val topOffset = state.top.toFloat() - composeViewLocationOnScreen[1]
443         val leftOffset = state.left.toFloat() - composeViewLocationOnScreen[0]
444 
445         translate(top = topOffset, left = leftOffset) {
446             // Background.
447             this@draw.drawBackground(
448                 state,
449                 controller.color(),
450                 controller.borderStroke,
451                 size = Size(state.width.toFloat(), state.height.toFloat()),
452             )
453 
454             // Content, scaled & centered w.r.t. the animated state bounds.
455             val contentSize = controller.boundsInComposeViewRoot.size
456             val contentWidth = contentSize.width
457             val contentHeight = contentSize.height
458             val scale = min(state.width / contentWidth, state.height / contentHeight)
459             scale(scale, pivot = Offset(state.width / 2f, state.height / 2f)) {
460                 translate(
461                     left = (state.width - contentWidth) / 2f,
462                     top = (state.height - contentHeight) / 2f,
463                 ) {
464                     drawLayer(contentGraphicsLayer)
465                 }
466             }
467         }
468     }
469 }
470 
clickModifiernull471 private fun clickModifier(
472     controller: ExpandableControllerImpl,
473     onClick: ((Expandable) -> Unit)?,
474     interactionSource: MutableInteractionSource?,
475 ): Modifier {
476     if (onClick == null) {
477         return Modifier
478     }
479 
480     if (interactionSource != null) {
481         // If the caller provided an interaction source, then that means that they will draw the
482         // click indication themselves.
483         return Modifier.clickable(interactionSource, indication = null) {
484             onClick(controller.expandable)
485         }
486     }
487 
488     // If no interaction source is provided, we draw the default indication (a ripple) and make sure
489     // it's clipped by the expandable shape.
490     return Modifier.clip(controller.shape).clickable { onClick(controller.expandable) }
491 }
492 
493 /** Draw [content] in [overlay] while respecting its screen position given by [animatorState]. */
494 @Composable
AnimatedContentInOverlaynull495 private fun AnimatedContentInOverlay(
496     color: () -> Color,
497     sizeInOriginalLayout: Size,
498     overlay: ViewGroupOverlay,
499     controller: ExpandableControllerImpl,
500     content: @Composable (Expandable) -> Unit,
501     composeViewRoot: View,
502     onOverlayComposeViewChanged: (View?) -> Unit,
503     density: Density,
504 ) {
505     val compositionContext = rememberCompositionContext()
506     val context = LocalContext.current
507 
508     // Create the ComposeView and force its content composition so that the movableContent is
509     // composed exactly once when we start animating.
510     val composeViewInOverlay =
511         remember(context, density) {
512             val startWidth = sizeInOriginalLayout.width
513             val startHeight = sizeInOriginalLayout.height
514             val contentModifier =
515                 Modifier
516                     // Draw the content with the same size as it was at the start of the animation
517                     // so that its content is laid out exactly the same way.
518                     .requiredSize(with(density) { sizeInOriginalLayout.toDpSize() })
519                     .drawWithContent {
520                         val animatorState = controller.animatorState ?: return@drawWithContent
521 
522                         // Scale the content with the background while keeping its aspect ratio.
523                         val widthRatio =
524                             if (startWidth != 0f) {
525                                 animatorState.width.toFloat() / startWidth
526                             } else {
527                                 1f
528                             }
529                         val heightRatio =
530                             if (startHeight != 0f) {
531                                 animatorState.height.toFloat() / startHeight
532                             } else {
533                                 1f
534                             }
535                         val scale = min(widthRatio, heightRatio)
536                         scale(scale) { this@drawWithContent.drawContent() }
537                     }
538 
539             val composeView =
540                 ComposeView(context).apply {
541                     setContent {
542                         Box(
543                             Modifier.fillMaxSize().drawWithContent {
544                                 val animatorState =
545                                     controller.animatorState ?: return@drawWithContent
546                                 if (!animatorState.visible) {
547                                     return@drawWithContent
548                                 }
549 
550                                 drawBackground(animatorState, color(), controller.borderStroke)
551                                 drawContent()
552                             },
553                             // We center the content in the expanding container.
554                             contentAlignment = Alignment.Center,
555                         ) {
556                             Box(contentModifier) { content(controller.expandable) }
557                         }
558                     }
559                 }
560 
561             // Set the owners.
562             val overlayViewGroup = getOverlayViewGroup(context, overlay)
563 
564             overlayViewGroup.setViewTreeLifecycleOwner(composeViewRoot.findViewTreeLifecycleOwner())
565             overlayViewGroup.setViewTreeViewModelStoreOwner(
566                 composeViewRoot.findViewTreeViewModelStoreOwner()
567             )
568             overlayViewGroup.setViewTreeSavedStateRegistryOwner(
569                 composeViewRoot.findViewTreeSavedStateRegistryOwner()
570             )
571 
572             composeView.setParentCompositionContext(compositionContext)
573 
574             composeView
575         }
576 
577     DisposableEffect(overlay, composeViewInOverlay) {
578         // Add the ComposeView to the overlay.
579         overlay.add(composeViewInOverlay)
580 
581         val startState =
582             controller.animatorState
583                 ?: throw IllegalStateException(
584                     "AnimatedContentInOverlay shouldn't be composed with null animatorState."
585                 )
586         measureAndLayoutComposeViewInOverlay(composeViewInOverlay, startState)
587         onOverlayComposeViewChanged(composeViewInOverlay)
588 
589         onDispose {
590             composeViewInOverlay.disposeComposition()
591             overlay.remove(composeViewInOverlay)
592             onOverlayComposeViewChanged(null)
593         }
594     }
595 }
596 
measureAndLayoutComposeViewInOverlaynull597 internal fun measureAndLayoutComposeViewInOverlay(view: View, state: TransitionAnimator.State) {
598     val exactWidth = state.width
599     val exactHeight = state.height
600     view.measure(
601         View.MeasureSpec.makeSafeMeasureSpec(exactWidth, View.MeasureSpec.EXACTLY),
602         View.MeasureSpec.makeSafeMeasureSpec(exactHeight, View.MeasureSpec.EXACTLY),
603     )
604 
605     val parent = view.parent as ViewGroup
606     val parentLocation = parent.locationOnScreen
607     val offsetX = parentLocation[0]
608     val offsetY = parentLocation[1]
609     view.layout(
610         state.left - offsetX,
611         state.top - offsetY,
612         state.right - offsetX,
613         state.bottom - offsetY,
614     )
615 }
616 
617 // TODO(b/230830644): Add hidden API to ViewGroupOverlay to access this ViewGroup directly?
getOverlayViewGroupnull618 private fun getOverlayViewGroup(context: Context, overlay: ViewGroupOverlay): ViewGroup {
619     val view = View(context)
620     overlay.add(view)
621     var current = view.parent
622     while (current.parent != null) {
623         current = current.parent
624     }
625     overlay.remove(view)
626     return current as ViewGroup
627 }
628 
bordernull629 private fun Modifier.border(controller: ExpandableControllerImpl): Modifier {
630     return if (controller.borderStroke != null) {
631         this.border(controller.borderStroke, controller.shape)
632     } else {
633         this
634     }
635 }
636 
drawBackgroundnull637 private fun ContentDrawScope.drawBackground(
638     animatorState: TransitionAnimator.State,
639     color: Color,
640     border: BorderStroke?,
641     size: Size = this.size,
642 ) {
643     val topRadius = animatorState.topCornerRadius
644     val bottomRadius = animatorState.bottomCornerRadius
645     if (topRadius == bottomRadius) {
646         // Shortcut to avoid Outline calculation and allocation.
647         val cornerRadius = CornerRadius(topRadius)
648 
649         // Draw the background.
650         drawRoundRect(color, cornerRadius = cornerRadius, size = size)
651 
652         // Draw the border.
653         if (border != null) {
654             // Copied from androidx.compose.foundation.Border.kt
655             val strokeWidth = border.width.toPx()
656             val halfStroke = strokeWidth / 2
657             val borderStroke = Stroke(strokeWidth)
658 
659             drawRoundRect(
660                 brush = border.brush,
661                 topLeft = Offset(halfStroke, halfStroke),
662                 size = Size(size.width - strokeWidth, size.height - strokeWidth),
663                 cornerRadius = cornerRadius.shrink(halfStroke),
664                 style = borderStroke,
665             )
666         }
667     } else {
668         val shape =
669             RoundedCornerShape(
670                 topStart = topRadius,
671                 topEnd = topRadius,
672                 bottomStart = bottomRadius,
673                 bottomEnd = bottomRadius,
674             )
675         val outline = shape.createOutline(size, layoutDirection, this)
676 
677         // Draw the background.
678         drawOutline(outline, color = color)
679 
680         // Draw the border.
681         if (border != null) {
682             // Copied from androidx.compose.foundation.Border.kt.
683             val strokeWidth = border.width.toPx()
684             val path = createRoundRectPath((outline as Outline.Rounded).roundRect, strokeWidth)
685 
686             drawPath(path, border.brush)
687         }
688     }
689 }
690 
691 /**
692  * Helper method that creates a round rect with the inner region removed by the given stroke width.
693  *
694  * Copied from androidx.compose.foundation.Border.kt.
695  */
createRoundRectPathnull696 private fun createRoundRectPath(roundedRect: RoundRect, strokeWidth: Float): Path {
697     return Path().apply {
698         addRoundRect(roundedRect)
699         val insetPath =
700             Path().apply { addRoundRect(createInsetRoundedRect(strokeWidth, roundedRect)) }
701         op(this, insetPath, PathOperation.Difference)
702     }
703 }
704 
705 /* Copied from androidx.compose.foundation.Border.kt. */
createInsetRoundedRectnull706 private fun createInsetRoundedRect(widthPx: Float, roundedRect: RoundRect) =
707     RoundRect(
708         left = widthPx,
709         top = widthPx,
710         right = roundedRect.width - widthPx,
711         bottom = roundedRect.height - widthPx,
712         topLeftCornerRadius = roundedRect.topLeftCornerRadius.shrink(widthPx),
713         topRightCornerRadius = roundedRect.topRightCornerRadius.shrink(widthPx),
714         bottomLeftCornerRadius = roundedRect.bottomLeftCornerRadius.shrink(widthPx),
715         bottomRightCornerRadius = roundedRect.bottomRightCornerRadius.shrink(widthPx),
716     )
717 
718 /**
719  * Helper method to shrink the corner radius by the given value, clamping to 0 if the resultant
720  * corner radius would be negative.
721  *
722  * Copied from androidx.compose.foundation.Border.kt.
723  */
724 private fun CornerRadius.shrink(value: Float): CornerRadius =
725     CornerRadius(max(0f, this.x - value), max(0f, this.y - value))
726