xref: /aosp_15_r20/frameworks/base/packages/SystemUI/src/com/android/systemui/grid/ui/compose/SpannedGrids.kt (revision d57664e9bc4670b3ecf6748a746a57c557b6bc9e)
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