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