1 /*
2 * Copyright (C) 2025 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.mechanics.behavior
18
19 import androidx.compose.ui.Modifier
20 import androidx.compose.ui.draw.drawWithCache
21 import androidx.compose.ui.geometry.CornerRadius
22 import androidx.compose.ui.geometry.Offset
23 import androidx.compose.ui.geometry.Rect
24 import androidx.compose.ui.geometry.Size
25 import androidx.compose.ui.graphics.Color
26 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
27 import androidx.compose.ui.graphics.drawscope.clipRect
28 import androidx.compose.ui.graphics.layer.GraphicsLayer
29 import androidx.compose.ui.graphics.layer.drawLayer
30 import androidx.compose.ui.node.DrawModifierNode
31 import androidx.compose.ui.node.ModifierNodeElement
32 import androidx.compose.ui.node.requireGraphicsContext
33 import androidx.compose.ui.util.fastCoerceAtLeast
34 import androidx.compose.ui.util.fastCoerceIn
35 import androidx.compose.ui.util.lerp
36 import kotlin.math.min
37
38 /**
39 * Draws the background of a vertically container, and applies clipping to it.
40 *
41 * Intended to be used with a [VerticalExpandContainerSpec] motion.
42 */
verticalExpandContainerBackgroundnull43 fun Modifier.verticalExpandContainerBackground(
44 backgroundColor: Color,
45 spec: VerticalExpandContainerSpec,
46 ): Modifier =
47 this.then(
48 if (spec.isFloating) {
49 Modifier.verticalFloatingExpandContainerBackground(backgroundColor, spec)
50 } else {
51 Modifier.verticalEdgeExpandContainerBackground(backgroundColor, spec)
52 }
53 )
54
55 /**
56 * Draws the background of an floating container, and applies clipping to it.
57 *
58 * Intended to be used with a [VerticalExpandContainerSpec] motion.
59 */
verticalFloatingExpandContainerBackgroundnull60 internal fun Modifier.verticalFloatingExpandContainerBackground(
61 backgroundColor: Color,
62 spec: VerticalExpandContainerSpec,
63 ): Modifier =
64 this.drawWithCache {
65 val targetRadiusPx = spec.radius.toPx()
66 val currentRadiusPx = min(targetRadiusPx, min(size.width, size.height) / 2f)
67 val horizontalInset = targetRadiusPx - currentRadiusPx
68 val shapeTopLeft = Offset(horizontalInset, 0f)
69 val shapeSize = Size(size.width - (horizontalInset * 2f), size.height)
70
71 val layer =
72 obtainGraphicsLayer().apply {
73 clip = true
74 setRoundRectOutline(shapeTopLeft, shapeSize, cornerRadius = currentRadiusPx)
75
76 record { drawContent() }
77 }
78
79 onDrawWithContent {
80 drawRoundRect(
81 color = backgroundColor,
82 topLeft = shapeTopLeft,
83 size = shapeSize,
84 cornerRadius = CornerRadius(currentRadiusPx),
85 )
86
87 drawLayer(layer)
88 }
89 }
90
91 /**
92 * Draws the background of an edge container, and applies clipping to it.
93 *
94 * Intended to be used with a [VerticalExpandContainerSpec] motion.
95 */
verticalEdgeExpandContainerBackgroundnull96 internal fun Modifier.verticalEdgeExpandContainerBackground(
97 backgroundColor: Color,
98 spec: VerticalExpandContainerSpec,
99 ): Modifier = this.then(EdgeContainerExpansionBackgroundElement(backgroundColor, spec))
100
101 internal class EdgeContainerExpansionBackgroundNode(
102 var backgroundColor: Color,
103 var spec: VerticalExpandContainerSpec,
104 ) : Modifier.Node(), DrawModifierNode {
105
106 private var graphicsLayer: GraphicsLayer? = null
107 private var lastOutlineSize = Size.Zero
108
109 fun invalidateOutline() {
110 lastOutlineSize = Size.Zero
111 }
112
113 override fun onAttach() {
114 graphicsLayer = requireGraphicsContext().createGraphicsLayer().apply { clip = true }
115 }
116
117 override fun onDetach() {
118 requireGraphicsContext().releaseGraphicsLayer(checkNotNull(graphicsLayer))
119 }
120
121 override fun ContentDrawScope.draw() {
122 val height = size.height
123
124 // The width is growing between visibleHeight and detachHeight
125 val visibleHeight = spec.visibleHeight.toPx()
126 val widthFraction =
127 ((height - visibleHeight) / (spec.detachHeight.toPx() - visibleHeight)).fastCoerceIn(
128 0f,
129 1f,
130 )
131 val width = size.width - lerp(spec.widthOffset.toPx(), 0f, widthFraction)
132 val horizontalInset = (size.width - width) / 2f
133
134 // The radius is growing at the beginning of the transition
135 val radius = height.fastCoerceIn(spec.minRadius.toPx(), spec.radius.toPx())
136
137 // Draw (at most) the bottom half of the rounded corner rectangle, aligned to the bottom.
138 val upperHeight = height - radius
139
140 // The rounded rect is drawn at 2x the radius height, to avoid smaller corner radii.
141 // The clipRect limits this to the relevant part (-1 to avoid a hairline gap being visible
142 // between this and the fill below.
143 clipRect(top = (upperHeight - 1).fastCoerceAtLeast(0f)) {
144 drawRoundRect(
145 color = backgroundColor,
146 cornerRadius = CornerRadius(radius),
147 size = Size(width, radius * 2f),
148 topLeft = Offset(horizontalInset, size.height - radius * 2f),
149 )
150 }
151
152 if (upperHeight > 0) {
153 // Fill the space above the bottom shape.
154 drawRect(
155 color = backgroundColor,
156 topLeft = Offset(horizontalInset, 0f),
157 size = Size(width, upperHeight),
158 )
159 }
160
161 // Draw the node's content in a separate layer.
162 val graphicsLayer = checkNotNull(graphicsLayer)
163 graphicsLayer.record { this@draw.drawContent() }
164
165 if (size != lastOutlineSize) {
166 // The clip outline is a rounded corner shape matching the bottom of the shape.
167 // At the top, the rounded corner shape extends by radiusPx above top.
168 // This clipping thus would not prevent the containers content to overdraw at the top,
169 // however this is off-screen anyways.
170 val top = min(-radius, height - radius * 2f)
171
172 val rect = Rect(left = horizontalInset, top = top, right = width, bottom = height)
173 graphicsLayer.setRoundRectOutline(rect.topLeft, rect.size, radius)
174 lastOutlineSize = size
175 }
176
177 this.drawLayer(graphicsLayer)
178 }
179 }
180
181 private data class EdgeContainerExpansionBackgroundElement(
182 val backgroundColor: Color,
183 val spec: VerticalExpandContainerSpec,
184 ) : ModifierNodeElement<EdgeContainerExpansionBackgroundNode>() {
createnull185 override fun create(): EdgeContainerExpansionBackgroundNode =
186 EdgeContainerExpansionBackgroundNode(backgroundColor, spec)
187
188 override fun update(node: EdgeContainerExpansionBackgroundNode) {
189 node.backgroundColor = backgroundColor
190 if (node.spec != spec) {
191 node.spec = spec
192 node.invalidateOutline()
193 }
194 }
195 }
196