1 /*
<lambda>null2 * Copyright (C) 2024 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.systemui.grid.ui.compose
18
19 import androidx.compose.foundation.layout.Arrangement
20 import androidx.compose.foundation.layout.Box
21 import androidx.compose.foundation.layout.BoxScope
22 import androidx.compose.runtime.Composable
23 import androidx.compose.runtime.remember
24 import androidx.compose.ui.Modifier
25 import androidx.compose.ui.layout.Layout
26 import androidx.compose.ui.layout.Placeable
27 import androidx.compose.ui.semantics.CollectionInfo
28 import androidx.compose.ui.semantics.CollectionItemInfo
29 import androidx.compose.ui.semantics.collectionInfo
30 import androidx.compose.ui.semantics.collectionItemInfo
31 import androidx.compose.ui.semantics.semantics
32 import androidx.compose.ui.unit.Constraints
33 import androidx.compose.ui.unit.Dp
34 import androidx.compose.ui.unit.LayoutDirection
35 import androidx.compose.ui.unit.dp
36 import kotlin.math.max
37
38 /**
39 * Horizontal (non lazy) grid that supports [spans] for its elements.
40 *
41 * The elements will be laid down vertically first, and then by columns. So assuming LTR layout, it
42 * will be (for a span list `[2, 1, 2, 1, 1, 1, 1, 1]` and 4 rows):
43 * ```
44 * 0 2 5
45 * 0 2 6
46 * 1 3 7
47 * 4
48 * ```
49 *
50 * where repeated numbers show larger span. If an element doesn't fit in a column due to its span,
51 * it will start a new column.
52 *
53 * Elements in [spans] must be in the interval `[1, rows]` ([rows] > 0), and the composables are
54 * associated with the corresponding span based on their index.
55 *
56 * Due to the fact that elements are seen as a linear list that's laid out in a grid, the semantics
57 * represent the collection as a list of elements.
58 */
59 @Composable
60 fun HorizontalSpannedGrid(
61 rows: Int,
62 columnSpacing: Dp,
63 rowSpacing: Dp,
64 spans: List<Int>,
65 modifier: Modifier = Modifier,
66 composables: @Composable BoxScope.(spanIndex: Int) -> Unit,
67 ) {
68 SpannedGrid(
69 primarySpaces = rows,
70 crossAxisSpacing = rowSpacing,
71 mainAxisSpacing = columnSpacing,
72 spans = spans,
73 isVertical = false,
74 modifier = modifier,
75 composables = composables,
76 )
77 }
78
79 /**
80 * Horizontal (non lazy) grid that supports [spans] for its elements.
81 *
82 * The elements will be laid down horizontally first, and then by rows. So assuming LTR layout, it
83 * will be (for a span list `[2, 1, 2, 1, 1, 1, 1, 1]` and 4 columns):
84 * ```
85 * 0 0 1
86 * 2 2 3 4
87 * 5 6 7
88 * ```
89 *
90 * where repeated numbers show larger span. If an element doesn't fit in a row due to its span, it
91 * will start a new row.
92 *
93 * Elements in [spans] must be in the interval `[1, columns]` ([columns] > 0), and the composables
94 * are associated with the corresponding span based on their index.
95 *
96 * Due to the fact that elements are seen as a linear list that's laid out in a grid, the semantics
97 * represent the collection as a list of elements.
98 */
99 @Composable
VerticalSpannedGridnull100 fun VerticalSpannedGrid(
101 columns: Int,
102 columnSpacing: Dp,
103 rowSpacing: Dp,
104 spans: List<Int>,
105 modifier: Modifier = Modifier,
106 composables: @Composable BoxScope.(spanIndex: Int) -> Unit,
107 ) {
108 SpannedGrid(
109 primarySpaces = columns,
110 crossAxisSpacing = columnSpacing,
111 mainAxisSpacing = rowSpacing,
112 spans = spans,
113 isVertical = true,
114 modifier = modifier,
115 composables = composables,
116 )
117 }
118
119 @Composable
SpannedGridnull120 private fun SpannedGrid(
121 primarySpaces: Int,
122 crossAxisSpacing: Dp,
123 mainAxisSpacing: Dp,
124 spans: List<Int>,
125 isVertical: Boolean,
126 modifier: Modifier = Modifier,
127 composables: @Composable BoxScope.(spanIndex: Int) -> Unit,
128 ) {
129 val crossAxisArrangement = Arrangement.spacedBy(crossAxisSpacing)
130 spans.forEachIndexed { index, span ->
131 check(span in 1..primarySpaces) {
132 "Span out of bounds. Span at index $index has value of $span which is outside of the " +
133 "expected rance of [1, $primarySpaces]"
134 }
135 }
136
137 if (isVertical) {
138 check(crossAxisSpacing >= 0.dp) { "Negative columnSpacing $crossAxisSpacing" }
139 check(mainAxisSpacing >= 0.dp) { "Negative rowSpacing $mainAxisSpacing" }
140 } else {
141 check(mainAxisSpacing >= 0.dp) { "Negative columnSpacing $mainAxisSpacing" }
142 check(crossAxisSpacing >= 0.dp) { "Negative rowSpacing $crossAxisSpacing" }
143 }
144
145 val totalMainAxisGroups: Int =
146 remember(primarySpaces, spans) {
147 var currentAccumulated = 0
148 var groups = 1
149 spans.forEach { span ->
150 if (currentAccumulated + span <= primarySpaces) {
151 currentAccumulated += span
152 } else {
153 groups += 1
154 currentAccumulated = span
155 }
156 }
157 groups
158 }
159
160 val slotPositionsAndSizesCache = remember {
161 object {
162 var sizes = IntArray(0)
163 var positions = IntArray(0)
164 }
165 }
166
167 Layout(
168 {
169 (0 until spans.size).map { spanIndex ->
170 Box(
171 Modifier.semantics {
172 collectionItemInfo =
173 if (isVertical) {
174 CollectionItemInfo(spanIndex, 1, 0, 1)
175 } else {
176 CollectionItemInfo(0, 1, spanIndex, 1)
177 }
178 }
179 ) {
180 composables(spanIndex)
181 }
182 }
183 },
184 modifier.semantics { collectionInfo = CollectionInfo(spans.size, 1) },
185 ) { measurables, constraints ->
186 check(measurables.size == spans.size)
187 val crossAxisSize = if (isVertical) constraints.maxWidth else constraints.maxHeight
188 check(crossAxisSize != Constraints.Infinity) { "Width must be constrained" }
189 if (slotPositionsAndSizesCache.sizes.size != primarySpaces) {
190 slotPositionsAndSizesCache.sizes = IntArray(primarySpaces)
191 slotPositionsAndSizesCache.positions = IntArray(primarySpaces)
192 }
193 calculateCellsCrossAxisSize(
194 crossAxisSize,
195 primarySpaces,
196 crossAxisSpacing.roundToPx(),
197 slotPositionsAndSizesCache.sizes,
198 )
199 val cellSizesInCrossAxis = slotPositionsAndSizesCache.sizes
200
201 // with is needed because of the double receiver (Density, Arrangement).
202 with(crossAxisArrangement) {
203 arrange(
204 crossAxisSize,
205 slotPositionsAndSizesCache.sizes,
206 LayoutDirection.Ltr,
207 slotPositionsAndSizesCache.positions,
208 )
209 }
210 val startPositions = slotPositionsAndSizesCache.positions
211
212 val mainAxisSpacingPx = mainAxisSpacing.roundToPx()
213 val mainAxisTotalGaps = (totalMainAxisGroups - 1) * mainAxisSpacingPx
214 val mainAxisSize = if (isVertical) constraints.maxHeight else constraints.maxWidth
215 val mainAxisElementConstraint =
216 if (mainAxisSize == Constraints.Infinity) {
217 Constraints.Infinity
218 } else {
219 max(0, (mainAxisSize - mainAxisTotalGaps) / totalMainAxisGroups)
220 }
221
222 val mainAxisSizes = IntArray(totalMainAxisGroups) { 0 }
223
224 var currentSlot = 0
225 var mainAxisGroup = 0
226 val placeables =
227 measurables.mapIndexed { index, measurable ->
228 val span = spans[index]
229 if (currentSlot + span > primarySpaces) {
230 currentSlot = 0
231 mainAxisGroup += 1
232 }
233 val crossAxisConstraint =
234 calculateWidth(cellSizesInCrossAxis, startPositions, currentSlot, span)
235 PlaceResult(
236 measurable.measure(
237 makeConstraint(
238 isVertical,
239 mainAxisElementConstraint,
240 crossAxisConstraint,
241 )
242 ),
243 currentSlot,
244 mainAxisGroup,
245 )
246 .also {
247 currentSlot += span
248 mainAxisSizes[mainAxisGroup] =
249 max(
250 mainAxisSizes[mainAxisGroup],
251 if (isVertical) it.placeable.height else it.placeable.width,
252 )
253 }
254 }
255
256 val mainAxisTotalSize = mainAxisTotalGaps + mainAxisSizes.sum()
257 val mainAxisStartingPoints =
258 mainAxisSizes.runningFold(0) { acc, value -> acc + value + mainAxisSpacingPx }
259 val height = if (isVertical) mainAxisTotalSize else crossAxisSize
260 val width = if (isVertical) crossAxisSize else mainAxisTotalSize
261
262 layout(width, height) {
263 placeables.forEach { (placeable, slot, mainAxisGroup) ->
264 val x =
265 if (isVertical) {
266 startPositions[slot]
267 } else {
268 mainAxisStartingPoints[mainAxisGroup]
269 }
270 val y =
271 if (isVertical) {
272 mainAxisStartingPoints[mainAxisGroup]
273 } else {
274 startPositions[slot]
275 }
276 placeable.placeRelative(x, y)
277 }
278 }
279 }
280 }
281
makeConstraintnull282 fun makeConstraint(isVertical: Boolean, mainAxisSize: Int, crossAxisSize: Int): Constraints {
283 return if (isVertical) {
284 Constraints(maxHeight = mainAxisSize, minWidth = crossAxisSize, maxWidth = crossAxisSize)
285 } else {
286 Constraints(maxWidth = mainAxisSize, minHeight = crossAxisSize, maxHeight = crossAxisSize)
287 }
288 }
289
calculateWidthnull290 private fun calculateWidth(sizes: IntArray, positions: IntArray, startSlot: Int, span: Int): Int {
291 val crossAxisSize =
292 if (span == 1) {
293 sizes[startSlot]
294 } else {
295 val endSlot = startSlot + span - 1
296 positions[endSlot] + sizes[endSlot] - positions[startSlot]
297 }
298 .coerceAtLeast(0)
299 return crossAxisSize
300 }
301
calculateCellsCrossAxisSizenull302 private fun calculateCellsCrossAxisSize(
303 gridSize: Int,
304 slotCount: Int,
305 spacingPx: Int,
306 outArray: IntArray,
307 ) {
308 check(outArray.size == slotCount)
309 val gridSizeWithoutSpacing = gridSize - spacingPx * (slotCount - 1)
310 val slotSize = gridSizeWithoutSpacing / slotCount
311 val remainingPixels = gridSizeWithoutSpacing % slotCount
312 outArray.indices.forEach { index ->
313 outArray[index] = slotSize + if (index < remainingPixels) 1 else 0
314 }
315 }
316
317 private data class PlaceResult(
318 val placeable: Placeable,
319 val slotIndex: Int,
320 val mainAxisGroup: Int,
321 )
322