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