• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * 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.communal.ui.compose
18 
19 import android.content.res.Configuration
20 import androidx.compose.foundation.OverscrollEffect
21 import androidx.compose.foundation.gestures.FlingBehavior
22 import androidx.compose.foundation.gestures.ScrollableDefaults
23 import androidx.compose.foundation.layout.Arrangement
24 import androidx.compose.foundation.layout.BoxWithConstraints
25 import androidx.compose.foundation.layout.PaddingValues
26 import androidx.compose.foundation.layout.calculateEndPadding
27 import androidx.compose.foundation.layout.calculateStartPadding
28 import androidx.compose.foundation.layout.fillMaxSize
29 import androidx.compose.foundation.lazy.grid.GridCells
30 import androidx.compose.foundation.lazy.grid.LazyGridScope
31 import androidx.compose.foundation.lazy.grid.LazyGridState
32 import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
33 import androidx.compose.foundation.lazy.grid.rememberLazyGridState
34 import androidx.compose.foundation.rememberOverscrollEffect
35 import androidx.compose.runtime.Composable
36 import androidx.compose.runtime.remember
37 import androidx.compose.ui.Modifier
38 import androidx.compose.ui.geometry.Offset
39 import androidx.compose.ui.graphics.toComposeRect
40 import androidx.compose.ui.platform.LocalConfiguration
41 import androidx.compose.ui.platform.LocalContext
42 import androidx.compose.ui.platform.LocalDensity
43 import androidx.compose.ui.platform.LocalLayoutDirection
44 import androidx.compose.ui.unit.Dp
45 import androidx.compose.ui.unit.DpSize
46 import androidx.compose.ui.unit.IntSize
47 import androidx.compose.ui.unit.coerceAtMost
48 import androidx.compose.ui.unit.dp
49 import androidx.compose.ui.unit.times
50 import androidx.window.layout.WindowMetricsCalculator
51 import com.android.systemui.communal.util.WindowSizeUtils.COMPACT_HEIGHT
52 import com.android.systemui.communal.util.WindowSizeUtils.COMPACT_WIDTH
53 
54 /**
55  * Renders a responsive [LazyHorizontalGrid] with dynamic columns and rows. Each cell will maintain
56  * the specified aspect ratio, but is otherwise resizeable in order to best fill the available
57  * space.
58  */
59 @Composable
ResponsiveLazyHorizontalGridnull60 fun ResponsiveLazyHorizontalGrid(
61     cellAspectRatio: Float,
62     modifier: Modifier = Modifier,
63     state: LazyGridState = rememberLazyGridState(),
64     setContentOffset: (offset: Offset) -> Unit = {},
65     minContentPadding: PaddingValues = PaddingValues(0.dp),
66     minHorizontalArrangement: Dp = 0.dp,
67     minVerticalArrangement: Dp = 0.dp,
68     flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
69     userScrollEnabled: Boolean = true,
70     overscrollEffect: OverscrollEffect? = rememberOverscrollEffect(),
71     content: LazyGridScope.(sizeInfo: SizeInfo) -> Unit,
72 ) {
<lambda>null73     check(cellAspectRatio > 0f) { "Aspect ratio must be greater than 0, but was $cellAspectRatio" }
<lambda>null74     check(minHorizontalArrangement.value >= 0f && minVerticalArrangement.value >= 0f) {
75         "Horizontal and vertical arrangements must be non-negative, but were " +
76             "$minHorizontalArrangement and $minVerticalArrangement, respectively."
77     }
<lambda>null78     BoxWithConstraints(modifier) {
79         val gridSize = rememberGridSize()
80         val layoutDirection = LocalLayoutDirection.current
81         val density = LocalDensity.current
82 
83         val minStartPadding = minContentPadding.calculateStartPadding(layoutDirection)
84         val minEndPadding = minContentPadding.calculateEndPadding(layoutDirection)
85         val minTopPadding = minContentPadding.calculateTopPadding()
86         val minBottomPadding = minContentPadding.calculateBottomPadding()
87         val minHorizontalPadding = minStartPadding + minEndPadding
88         val minVerticalPadding = minTopPadding + minBottomPadding
89 
90         // Determine the maximum allowed cell width and height based on the available width and
91         // height, and the desired number of columns and rows.
92         val maxCellWidth =
93             calculateCellSize(
94                 availableSpace = maxWidth,
95                 padding = minHorizontalPadding,
96                 numCells = gridSize.width,
97                 cellSpacing = minHorizontalArrangement,
98             )
99         val maxCellHeight =
100             calculateCellSize(
101                 availableSpace = maxHeight,
102                 padding = minVerticalPadding,
103                 numCells = gridSize.height,
104                 cellSpacing = minVerticalArrangement,
105             )
106 
107         // Constrain the max size to the desired aspect ratio.
108         val finalSize =
109             calculateClosestSize(
110                 maxWidth = maxCellWidth,
111                 maxHeight = maxCellHeight,
112                 aspectRatio = cellAspectRatio,
113             )
114 
115         // Determine how much space in each dimension we've used up, and how much we have left as
116         // extra space. Distribute the extra space evenly along the content padding.
117         val usedWidth =
118             calculateUsedSpace(
119                     cellSize = finalSize.width,
120                     numCells = gridSize.width,
121                     padding = minHorizontalPadding,
122                     cellSpacing = minHorizontalArrangement,
123                 )
124                 .coerceAtMost(maxWidth)
125         val usedHeight =
126             calculateUsedSpace(
127                     cellSize = finalSize.height,
128                     numCells = gridSize.height,
129                     padding = minVerticalPadding,
130                     cellSpacing = minVerticalArrangement,
131                 )
132                 .coerceAtMost(maxHeight)
133         val extraWidth = maxWidth - usedWidth
134         val extraHeight = maxHeight - usedHeight
135 
136         // If there is a single column or single row, distribute extra space evenly across the grid.
137         // Otherwise, distribute it along the content padding to center the content.
138         val distributeHorizontalSpaceAlongGutters = gridSize.height == 1 || gridSize.width == 1
139         val evenlyDistributedWidth =
140             if (distributeHorizontalSpaceAlongGutters) {
141                 extraWidth / (gridSize.width + 1)
142             } else {
143                 extraWidth / 2
144             }
145 
146         val finalStartPadding = minStartPadding + evenlyDistributedWidth
147         val finalEndPadding = minEndPadding + evenlyDistributedWidth
148         val finalTopPadding = minTopPadding + extraHeight / 2
149 
150         val finalContentPadding =
151             PaddingValues(
152                 start = finalStartPadding,
153                 end = finalEndPadding,
154                 top = finalTopPadding,
155                 bottom = minBottomPadding + extraHeight / 2,
156             )
157 
158         with(density) { setContentOffset(Offset(finalStartPadding.toPx(), finalTopPadding.toPx())) }
159 
160         val horizontalArrangement =
161             if (distributeHorizontalSpaceAlongGutters) {
162                 minHorizontalArrangement + evenlyDistributedWidth
163             } else {
164                 minHorizontalArrangement
165             }
166 
167         LazyHorizontalGrid(
168             rows = GridCells.Fixed(gridSize.height),
169             modifier = Modifier.fillMaxSize(),
170             state = state,
171             contentPadding = finalContentPadding,
172             horizontalArrangement = Arrangement.spacedBy(horizontalArrangement),
173             verticalArrangement = Arrangement.spacedBy(minVerticalArrangement),
174             flingBehavior = flingBehavior,
175             userScrollEnabled = userScrollEnabled,
176             overscrollEffect = overscrollEffect,
177         ) {
178             content(
179                 SizeInfo(
180                     cellSize = finalSize,
181                     contentPadding = finalContentPadding,
182                     verticalArrangement = minVerticalArrangement,
183                     maxHeight = maxHeight,
184                     gridSize = gridSize,
185                 )
186             )
187         }
188     }
189 }
190 
calculateCellSizenull191 private fun calculateCellSize(availableSpace: Dp, padding: Dp, numCells: Int, cellSpacing: Dp): Dp =
192     (availableSpace - padding - cellSpacing * (numCells - 1)) / numCells
193 
194 private fun calculateUsedSpace(cellSize: Dp, numCells: Int, padding: Dp, cellSpacing: Dp): Dp =
195     cellSize * numCells + padding + (numCells - 1) * cellSpacing
196 
197 private fun calculateClosestSize(maxWidth: Dp, maxHeight: Dp, aspectRatio: Float): DpSize {
198     return if (maxWidth / maxHeight > aspectRatio) {
199         // Target is too wide, shrink width
200         DpSize(maxHeight * aspectRatio, maxHeight)
201     } else {
202         // Target is too tall, shrink height
203         DpSize(maxWidth, maxWidth / aspectRatio)
204     }
205 }
206 
207 /**
208  * Provides size info of the responsive grid, since the size is dynamic.
209  *
210  * @property cellSize The size of each cell in the grid.
211  * @property verticalArrangement The space between rows in the grid.
212  * @property gridSize The size of the grid, in cell units.
213  * @property availableHeight The maximum height an item in the grid may occupy.
214  * @property contentPadding The padding around the content of the grid.
215  */
216 data class SizeInfo(
217     val cellSize: DpSize,
218     val verticalArrangement: Dp,
219     val gridSize: IntSize,
220     val contentPadding: PaddingValues,
221     private val maxHeight: Dp,
222 ) {
223     val availableHeight: Dp
224         get() =
225             maxHeight -
226                 contentPadding.calculateBottomPadding() -
227                 contentPadding.calculateTopPadding()
228 
229     /** Calculates the height in dp of a certain number of rows. */
calculateHeightnull230     fun calculateHeight(numRows: Int): Dp {
231         return numRows * cellSize.height + (numRows - 1) * verticalArrangement
232     }
233 }
234 
235 @Composable
rememberGridSizenull236 private fun rememberGridSize(): IntSize {
237     val configuration = LocalConfiguration.current
238     val orientation = configuration.orientation
239     val screenSize = calculateWindowSize()
240 
241     return remember(orientation, screenSize) {
242         if (orientation == Configuration.ORIENTATION_PORTRAIT) {
243             IntSize(
244                 width = calculateNumCellsWidth(screenSize.width),
245                 height = calculateNumCellsHeight(screenSize.height),
246             )
247         } else {
248             // In landscape we invert the rows/columns to ensure we match the same area as portrait.
249             // This keeps the number of elements in the grid consistent when changing orientation.
250             IntSize(
251                 width = calculateNumCellsHeight(screenSize.width),
252                 height = calculateNumCellsWidth(screenSize.height),
253             )
254         }
255     }
256 }
257 
258 @Composable
calculateWindowSizenull259 fun calculateWindowSize(): DpSize {
260     // Observe view configuration changes and recalculate the size class on each change.
261     LocalConfiguration.current
262     val density = LocalDensity.current
263     val context = LocalContext.current
264     val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(context)
265     return with(density) { metrics.bounds.toComposeRect().size.toDpSize() }
266 }
267 
calculateNumCellsWidthnull268 private fun calculateNumCellsWidth(width: Dp) =
269     when {
270         width >= 900.dp -> 3
271         width >= COMPACT_WIDTH -> 2
272         else -> 1
273     }
274 
calculateNumCellsHeightnull275 private fun calculateNumCellsHeight(height: Dp) =
276     when {
277         height >= 1000.dp -> 3
278         height >= COMPACT_HEIGHT -> 2
279         else -> 1
280     }
281