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 package com.android.systemui.grid.ui.compose
17
18 import androidx.collection.IntIntPair
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.key
24 import androidx.compose.runtime.remember
25 import androidx.compose.ui.Modifier
26 import androidx.compose.ui.layout.Layout
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 androidx.compose.ui.util.fastForEachIndexed
37 import androidx.compose.ui.util.fastMapIndexed
38 import kotlin.math.max
39
40 /**
41 * Horizontal (non lazy) grid that supports [spans] for its elements.
42 *
43 * The elements will be laid down vertically first, and then by columns. So assuming LTR layout, it
44 * will be (for a span list `[2, 1, 2, 1, 1, 1, 1, 1]` and 4 rows):
45 * ```
46 * 0 2 5
47 * 0 2 6
48 * 1 3 7
49 * 4
50 * ```
51 *
52 * where repeated numbers show larger span. If an element doesn't fit in a column due to its span,
53 * it will start a new column.
54 *
55 * Elements in [spans] must be in the interval `[1, rows]` ([rows] > 0), and the composables are
56 * associated with the corresponding span based on their index.
57 *
58 * Due to the fact that elements are seen as a linear list that's laid out in a grid, the semantics
59 * represent the collection as a list of elements.
60 */
61 @Composable
62 fun HorizontalSpannedGrid(
63 rows: Int,
64 columnSpacing: Dp,
65 rowSpacing: Dp,
66 spans: List<Int>,
67 modifier: Modifier = Modifier,
68 keys: (spanIndex: Int) -> Any = { it },
69 composables:
70 @Composable
71 BoxScope.(
72 spanIndex: Int, row: Int, isFirstInColumn: Boolean, isLastInColumn: Boolean,
73 ) -> Unit,
74 ) {
75 SpannedGrid(
76 primarySpaces = rows,
77 crossAxisSpacing = rowSpacing,
78 mainAxisSpacing = columnSpacing,
79 spans = spans,
80 isVertical = false,
81 modifier = modifier,
82 keys = keys,
83 composables = composables,
84 )
85 }
86
87 /**
88 * Vertical (non lazy) grid that supports [spans] for its elements.
89 *
90 * The elements will be laid down horizontally first, and then by rows. So assuming LTR layout, it
91 * will be (for a span list `[2, 1, 2, 1, 1, 1, 1, 1]` and 4 columns):
92 * ```
93 * 0 0 1
94 * 2 2 3 4
95 * 5 6 7
96 * ```
97 *
98 * where repeated numbers show larger span. If an element doesn't fit in a row due to its span, it
99 * will start a new row.
100 *
101 * Elements in [spans] must be in the interval `[1, columns]` ([columns] > 0), and the composables
102 * are associated with the corresponding span based on their index.
103 *
104 * Due to the fact that elements are seen as a linear list that's laid out in a grid, the semantics
105 * represent the collection as a list of elements.
106 */
107 @Composable
VerticalSpannedGridnull108 fun VerticalSpannedGrid(
109 columns: Int,
110 columnSpacing: Dp,
111 rowSpacing: Dp,
112 spans: List<Int>,
113 modifier: Modifier = Modifier,
114 keys: (spanIndex: Int) -> Any = { it },
115 composables:
116 @Composable
117 BoxScope.(spanIndex: Int, column: Int, isFirstInRow: Boolean, isLastInRow: Boolean) -> Unit,
118 ) {
119 SpannedGrid(
120 primarySpaces = columns,
121 crossAxisSpacing = columnSpacing,
122 mainAxisSpacing = rowSpacing,
123 spans = spans,
124 isVertical = true,
125 modifier = modifier,
126 keys = keys,
127 composables = composables,
128 )
129 }
130
131 @Composable
SpannedGridnull132 private fun SpannedGrid(
133 primarySpaces: Int,
134 crossAxisSpacing: Dp,
135 mainAxisSpacing: Dp,
136 spans: List<Int>,
137 isVertical: Boolean,
138 modifier: Modifier = Modifier,
139 keys: (spanIndex: Int) -> Any = { it },
140 composables:
141 @Composable
142 BoxScope.(spanIndex: Int, secondaryAxis: Int, isFirst: Boolean, isLast: Boolean) -> Unit,
143 ) {
144 val crossAxisArrangement = Arrangement.spacedBy(crossAxisSpacing)
indexnull145 spans.forEachIndexed { index, span ->
146 check(span in 1..primarySpaces) {
147 "Span out of bounds. Span at index $index has value of $span which is outside of the " +
148 "expected rance of [1, $primarySpaces]"
149 }
150 }
151 if (isVertical) {
<lambda>null152 check(crossAxisSpacing >= 0.dp) { "Negative columnSpacing $crossAxisSpacing" }
<lambda>null153 check(mainAxisSpacing >= 0.dp) { "Negative rowSpacing $mainAxisSpacing" }
154 } else {
<lambda>null155 check(mainAxisSpacing >= 0.dp) { "Negative columnSpacing $mainAxisSpacing" }
<lambda>null156 check(crossAxisSpacing >= 0.dp) { "Negative rowSpacing $crossAxisSpacing" }
157 }
158 // List of primary axis index to secondary axis index
159 // This is keyed to the size of the spans list for performance reasons as we don't expect the
160 // spans value to change outside of edit mode.
<lambda>null161 val positions = remember(spans.size) { Array(spans.size) { IntIntPair(0, 0) } }
162 val totalMainAxisGroups =
<lambda>null163 remember(primarySpaces, spans) {
164 var mainAxisGroup = 0
165 var currentSlot = 0
166 spans.fastForEachIndexed { index, span ->
167 if (currentSlot + span > primarySpaces) {
168 currentSlot = 0
169 mainAxisGroup += 1
170 }
171 positions[index] = IntIntPair(mainAxisGroup, currentSlot)
172 currentSlot += span
173 }
174 mainAxisGroup + 1
175 }
<lambda>null176 val slotPositionsAndSizesCache = remember {
177 object {
178 var sizes = IntArray(0)
179 var positions = IntArray(0)
180 }
181 }
182 Layout(
<lambda>null183 {
184 (0 until spans.size).map { spanIndex ->
185 key(keys(spanIndex)) {
186 Box(
187 Modifier.semantics {
188 collectionItemInfo =
189 if (isVertical) {
190 CollectionItemInfo(spanIndex, 1, 0, 1)
191 } else {
192 CollectionItemInfo(0, 1, spanIndex, 1)
193 }
194 }
195 ) {
196 val position = positions[spanIndex]
197 composables(
198 spanIndex,
199 position.second,
200 position.second == 0,
201 positions.getOrNull(spanIndex + 1)?.first != position.first,
202 )
203 }
204 }
205 }
206 },
<lambda>null207 modifier.semantics { collectionInfo = CollectionInfo(spans.size, 1) },
measurablesnull208 ) { measurables, constraints ->
209 check(measurables.size == spans.size)
210 val crossAxisSize = if (isVertical) constraints.maxWidth else constraints.maxHeight
211 check(crossAxisSize != Constraints.Infinity) { "Width must be constrained" }
212 if (slotPositionsAndSizesCache.sizes.size != primarySpaces) {
213 slotPositionsAndSizesCache.sizes = IntArray(primarySpaces)
214 slotPositionsAndSizesCache.positions = IntArray(primarySpaces)
215 }
216 calculateCellsCrossAxisSize(
217 crossAxisSize,
218 primarySpaces,
219 crossAxisSpacing.roundToPx(),
220 slotPositionsAndSizesCache.sizes,
221 )
222 val cellSizesInCrossAxis = slotPositionsAndSizesCache.sizes
223 // with is needed because of the double receiver (Density, Arrangement).
224 with(crossAxisArrangement) {
225 arrange(
226 crossAxisSize,
227 slotPositionsAndSizesCache.sizes,
228 LayoutDirection.Ltr,
229 slotPositionsAndSizesCache.positions,
230 )
231 }
232 val startPositions = slotPositionsAndSizesCache.positions
233 val mainAxisSpacingPx = mainAxisSpacing.roundToPx()
234 val mainAxisTotalGaps = (totalMainAxisGroups - 1) * mainAxisSpacingPx
235 val mainAxisMaxSize = if (isVertical) constraints.maxHeight else constraints.maxWidth
236 val mainAxisElementConstraint =
237 if (mainAxisMaxSize == Constraints.Infinity) {
238 Constraints.Infinity
239 } else {
240 max(0, (mainAxisMaxSize - mainAxisTotalGaps) / totalMainAxisGroups)
241 }
242
243 var mainAxisTotalSize = mainAxisTotalGaps
244 var currentMainAxis = 0
245 var currentMainAxisMax = 0
246 val placeables =
247 measurables.fastMapIndexed { index, measurable ->
248 val span = spans[index]
249 val position = positions[index]
250 val crossAxisConstraint =
251 calculateWidth(cellSizesInCrossAxis, startPositions, position.second, span)
252
253 measurable
254 .measure(
255 makeConstraint(isVertical, mainAxisElementConstraint, crossAxisConstraint)
256 )
257 .also {
258 val placeableSize = if (isVertical) it.height else it.width
259 if (position.first != currentMainAxis) {
260 // New row -- Add the max size to the total and reset the max
261 mainAxisTotalSize += currentMainAxisMax
262 currentMainAxisMax = placeableSize
263 currentMainAxis = position.first
264 } else {
265 currentMainAxisMax = max(currentMainAxisMax, placeableSize)
266 }
267 }
268 }
269 mainAxisTotalSize += currentMainAxisMax
270
271 val height = if (isVertical) mainAxisTotalSize else crossAxisSize
272 val width = if (isVertical) crossAxisSize else mainAxisTotalSize
273
274 layout(width, height) {
275 var previousMainAxis = 0
276 var currentMainAxisPosition = 0
277 var currentMainAxisMax = 0
278 placeables.forEachIndexed { index, placeable ->
279 val slot = positions[index].second
280 val mainAxisSize = if (isVertical) placeable.height else placeable.width
281
282 if (positions[index].first != previousMainAxis) {
283 // Move up a row + padding
284 currentMainAxisPosition += currentMainAxisMax + mainAxisSpacingPx
285 currentMainAxisMax = mainAxisSize
286 previousMainAxis = positions[index].first
287 } else {
288 currentMainAxisMax = max(currentMainAxisMax, mainAxisSize)
289 }
290
291 val x =
292 if (isVertical) {
293 startPositions[slot]
294 } else {
295 currentMainAxisPosition
296 }
297 val y =
298 if (isVertical) {
299 currentMainAxisPosition
300 } else {
301 startPositions[slot]
302 }
303 placeable.placeRelative(x, y)
304 }
305 }
306 }
307 }
308
makeConstraintnull309 fun makeConstraint(isVertical: Boolean, mainAxisSize: Int, crossAxisSize: Int): Constraints {
310 return if (isVertical) {
311 Constraints(maxHeight = mainAxisSize, minWidth = crossAxisSize, maxWidth = crossAxisSize)
312 } else {
313 Constraints(maxWidth = mainAxisSize, minHeight = crossAxisSize, maxHeight = crossAxisSize)
314 }
315 }
316
calculateWidthnull317 private fun calculateWidth(sizes: IntArray, positions: IntArray, startSlot: Int, span: Int): Int {
318 val crossAxisSize =
319 if (span == 1) {
320 sizes[startSlot]
321 } else {
322 val endSlot = startSlot + span - 1
323 positions[endSlot] + sizes[endSlot] - positions[startSlot]
324 }
325 .coerceAtLeast(0)
326 return crossAxisSize
327 }
328
calculateCellsCrossAxisSizenull329 private fun calculateCellsCrossAxisSize(
330 gridSize: Int,
331 slotCount: Int,
332 spacingPx: Int,
333 outArray: IntArray,
334 ) {
335 check(outArray.size == slotCount)
336 val gridSizeWithoutSpacing = gridSize - spacingPx * (slotCount - 1)
337 val slotSize = gridSizeWithoutSpacing / slotCount
338 val remainingPixels = gridSizeWithoutSpacing % slotCount
339 outArray.indices.forEach { index ->
340 outArray[index] = slotSize + if (index < remainingPixels) 1 else 0
341 }
342 }
343