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