1 /*
<lambda>null2 * Copyright 2023 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 * https://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.google.accompanist.adaptive
18
19 import android.util.Range
20 import androidx.annotation.VisibleForTesting
21 import androidx.compose.foundation.layout.Arrangement
22 import androidx.compose.foundation.layout.Column
23 import androidx.compose.foundation.layout.PaddingValues
24 import androidx.compose.runtime.Composable
25 import androidx.compose.runtime.remember
26 import androidx.compose.ui.Alignment
27 import androidx.compose.ui.ExperimentalComposeUiApi
28 import androidx.compose.ui.Modifier
29 import androidx.compose.ui.geometry.Offset
30 import androidx.compose.ui.geometry.Rect
31 import androidx.compose.ui.layout.IntrinsicMeasurable
32 import androidx.compose.ui.layout.IntrinsicMeasureScope
33 import androidx.compose.ui.layout.Layout
34 import androidx.compose.ui.layout.LayoutCoordinates
35 import androidx.compose.ui.layout.Measurable
36 import androidx.compose.ui.layout.MeasurePolicy
37 import androidx.compose.ui.layout.MeasureResult
38 import androidx.compose.ui.layout.MeasureScope
39 import androidx.compose.ui.layout.ParentDataModifier
40 import androidx.compose.ui.layout.Placeable
41 import androidx.compose.ui.layout.boundsInRoot
42 import androidx.compose.ui.layout.boundsInWindow
43 import androidx.compose.ui.layout.findRootCoordinates
44 import androidx.compose.ui.platform.InspectorInfo
45 import androidx.compose.ui.platform.InspectorValueInfo
46 import androidx.compose.ui.unit.Constraints
47 import androidx.compose.ui.unit.Density
48 import androidx.compose.ui.unit.Dp
49 import androidx.compose.ui.unit.LayoutDirection
50 import androidx.window.layout.DisplayFeature
51 import androidx.window.layout.FoldingFeature
52 import kotlin.math.roundToInt
53
54 /**
55 * A simplified version of [Column] that places children in a fold-aware manner.
56 *
57 * The layout starts placing children from the top of the available space. If there is a horizontal
58 * [separating](https://developer.android.com/reference/kotlin/androidx/window/layout/FoldingFeature#isSeparating())
59 * fold present in the window, then the layout will check to see if any children overlap the fold.
60 * If a child would overlap the fold in its current position, then the layout will increase its
61 * y coordinate so that the child is now placed below the fold, and any subsequent children will
62 * also be placed below the fold.
63 *
64 *
65 * @param displayFeatures a list of display features the device currently has
66 * @param modifier an optional modifier for the layout
67 * @param foldPadding the optional padding to add around a fold
68 * @param horizontalAlignment the horizontal alignment of the layout's children.
69 */
70 @Composable
71 public fun FoldAwareColumn(
72 displayFeatures: List<DisplayFeature>,
73 modifier: Modifier = Modifier,
74 foldPadding: PaddingValues = PaddingValues(),
75 horizontalAlignment: Alignment.Horizontal = Alignment.Start,
76 content: @Composable FoldAwareColumnScope.() -> Unit,
77 ) {
78 Layout(
79 modifier = modifier,
80 measurePolicy = foldAwareColumnMeasurePolicy(
81 verticalArrangement = Arrangement.Top,
82 horizontalAlignment = horizontalAlignment,
83 fold = {
84 // Extract folding feature if horizontal and separating
85 displayFeatures.find {
86 it is FoldingFeature && it.orientation == FoldingFeature.Orientation.HORIZONTAL &&
87 it.isSeparating
88 } as FoldingFeature?
89 },
90 foldPadding = foldPadding,
91 ),
92 content = { FoldAwareColumnScopeInstance.content() }
93 )
94 }
95
96 /**
97 * FoldAwareColumn version of [rowColumnMeasurePolicy] that uses [FoldAwareColumnMeasurementHelper.foldAwarePlaceHelper]
98 * method instead of [RowColumnMeasurementHelper.placeHelper]
99 */
100 // TODO: change from internal to private once metalava issue is solved https://issuetracker.google.com/issues/271539608
101 @Composable
foldAwareColumnMeasurePolicynull102 internal fun foldAwareColumnMeasurePolicy(
103 verticalArrangement: Arrangement.Vertical,
104 horizontalAlignment: Alignment.Horizontal,
105 fold: () -> FoldingFeature?,
106 foldPadding: PaddingValues
107 ) = remember(verticalArrangement, horizontalAlignment, fold, foldPadding) {
108
109 val orientation = LayoutOrientation.Vertical
110 val arrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit =
111 { totalSize, size, _, density, outPosition ->
112 with(verticalArrangement) { density.arrange(totalSize, size, outPosition) }
113 }
114 val arrangementSpacing = verticalArrangement.spacing
115 val crossAxisAlignment = CrossAxisAlignment.horizontal(horizontalAlignment)
116 val crossAxisSize = SizeMode.Wrap
117
118 object : MeasurePolicy {
119 override fun MeasureScope.measure(
120 measurables: List<Measurable>,
121 constraints: Constraints
122 ): MeasureResult {
123 val placeables = arrayOfNulls<Placeable?>(measurables.size)
124 val rowColumnMeasureHelper =
125 FoldAwareColumnMeasurementHelper(
126 orientation,
127 arrangement,
128 arrangementSpacing,
129 crossAxisSize,
130 crossAxisAlignment,
131 measurables,
132 placeables
133 )
134
135 val measureResult = rowColumnMeasureHelper
136 .measureWithoutPlacing(
137 this,
138 constraints, 0, measurables.size
139 )
140
141 val layoutWidth: Int
142 val layoutHeight: Int
143 if (orientation == LayoutOrientation.Horizontal) {
144 layoutWidth = measureResult.mainAxisSize
145 layoutHeight = measureResult.crossAxisSize
146 } else {
147 layoutWidth = measureResult.crossAxisSize
148 layoutHeight = measureResult.mainAxisSize
149 }
150
151 // Calculate fold bounds in pixels (including any added fold padding)
152 val foldBoundsPx = with(density) {
153 val topPaddingPx = foldPadding.calculateTopPadding().roundToPx()
154 val bottomPaddingPx = foldPadding.calculateBottomPadding().roundToPx()
155
156 fold()?.bounds?.let {
157 Rect(
158 left = it.left.toFloat(),
159 top = it.top.toFloat() - topPaddingPx,
160 right = it.right.toFloat(),
161 bottom = it.bottom.toFloat() + bottomPaddingPx
162 )
163 }
164 }
165
166 // We only know how much padding is added inside the placement scope, so just add fold height
167 // and height of the largest child when laying out to cover the maximum possible height
168 val heightPadding = foldBoundsPx?.let { bounds ->
169 val largestChildHeight = rowColumnMeasureHelper.placeables.maxOfOrNull {
170 if ((it?.parentData as? RowColumnParentData)?.ignoreFold == true) {
171 0
172 } else {
173 it?.height ?: 0
174 }
175 } ?: 0
176 bounds.height.roundToInt() + largestChildHeight
177 } ?: 0
178 val paddedLayoutHeight = layoutHeight + heightPadding
179
180 return layout(layoutWidth, paddedLayoutHeight) {
181 rowColumnMeasureHelper.foldAwarePlaceHelper(
182 this,
183 measureResult,
184 0,
185 layoutDirection,
186 foldBoundsPx
187 )
188 }
189 }
190
191 override fun IntrinsicMeasureScope.minIntrinsicWidth(
192 measurables: List<IntrinsicMeasurable>,
193 height: Int
194 ) = MinIntrinsicWidthMeasureBlock(orientation)(
195 measurables,
196 height,
197 arrangementSpacing.roundToPx()
198 )
199
200 override fun IntrinsicMeasureScope.minIntrinsicHeight(
201 measurables: List<IntrinsicMeasurable>,
202 width: Int
203 ) = MinIntrinsicHeightMeasureBlock(orientation)(
204 measurables,
205 width,
206 arrangementSpacing.roundToPx()
207 )
208
209 override fun IntrinsicMeasureScope.maxIntrinsicWidth(
210 measurables: List<IntrinsicMeasurable>,
211 height: Int
212 ) = MaxIntrinsicWidthMeasureBlock(orientation)(
213 measurables,
214 height,
215 arrangementSpacing.roundToPx()
216 )
217
218 override fun IntrinsicMeasureScope.maxIntrinsicHeight(
219 measurables: List<IntrinsicMeasurable>,
220 width: Int
221 ) = MaxIntrinsicHeightMeasureBlock(orientation)(
222 measurables,
223 width,
224 arrangementSpacing.roundToPx()
225 )
226 }
227 }
228
229 /**
230 * Inherits from [RowColumnMeasurementHelper] to place children in a fold-aware manner
231 */
232 private class FoldAwareColumnMeasurementHelper(
233 orientation: LayoutOrientation,
234 arrangement: (Int, IntArray, LayoutDirection, Density, IntArray) -> Unit,
235 arrangementSpacing: Dp,
236 crossAxisSize: SizeMode,
237 crossAxisAlignment: CrossAxisAlignment,
238 measurables: List<Measurable>,
239 placeables: Array<Placeable?>
240 ) : RowColumnMeasurementHelper(
241 orientation,
242 arrangement,
243 arrangementSpacing,
244 crossAxisSize,
245 crossAxisAlignment,
246 measurables,
247 placeables
248 ) {
249 /**
250 * Copy of [placeHelper] that has been modified for FoldAwareColumn implementation
251 */
252 @OptIn(ExperimentalComposeUiApi::class)
foldAwarePlaceHelpernull253 fun foldAwarePlaceHelper(
254 placeableScope: Placeable.PlacementScope,
255 measureResult: RowColumnMeasureHelperResult,
256 crossAxisOffset: Int,
257 layoutDirection: LayoutDirection,
258 foldBoundsPx: Rect?
259 ) {
260 with(placeableScope) {
261 val layoutBounds = coordinates!!.trueBoundsInWindow()
262
263 var placeableY = 0
264
265 for (i in measureResult.startIndex until measureResult.endIndex) {
266 val placeable = placeables[i]!!
267 val mainAxisPositions = measureResult.mainAxisPositions
268 val crossAxisPosition = getCrossAxisPosition(
269 placeable,
270 (measurables[i].parentData as? RowColumnParentData),
271 measureResult.crossAxisSize,
272 layoutDirection,
273 measureResult.beforeCrossAxisAlignmentLine
274 ) + crossAxisOffset
275 if (orientation == LayoutOrientation.Horizontal) {
276 placeable.place(
277 mainAxisPositions[i - measureResult.startIndex],
278 crossAxisPosition
279 )
280 } else {
281 val relativeBounds = Rect(
282 left = 0f,
283 top = placeableY.toFloat(),
284 right = placeable.width.toFloat(),
285 bottom = (placeableY + placeable.height).toFloat()
286 )
287 val absoluteBounds =
288 relativeBounds.translate(layoutBounds.left, layoutBounds.top)
289
290 // If placeable overlaps fold, push placeable below
291 if (foldBoundsPx?.overlapsVertically(absoluteBounds) == true &&
292 (placeable.parentData as? RowColumnParentData)?.ignoreFold != true
293 ) {
294 placeableY = (foldBoundsPx.bottom - layoutBounds.top).toInt()
295 }
296
297 placeable.place(crossAxisPosition, placeableY)
298
299 placeableY += placeable.height
300 }
301 }
302 }
303 }
304 }
305
306 /**
307 * Copy of original [LayoutCoordinates.boundsInWindow], but without the nonzero dimension check.
308 *
309 * Instead of returning [Rect.Zero] for a layout with zero width/height, this method will still
310 * return a Rect with the layout's bounds.
311 */
312 @VisibleForTesting
trueBoundsInWindownull313 internal fun LayoutCoordinates.trueBoundsInWindow(): Rect {
314 val root = findRootCoordinates()
315 val bounds = boundsInRoot()
316 val rootWidth = root.size.width.toFloat()
317 val rootHeight = root.size.height.toFloat()
318
319 val boundsLeft = bounds.left.coerceIn(0f, rootWidth)
320 val boundsTop = bounds.top.coerceIn(0f, rootHeight)
321 val boundsRight = bounds.right.coerceIn(0f, rootWidth)
322 val boundsBottom = bounds.bottom.coerceIn(0f, rootHeight)
323
324 val topLeft = root.localToWindow(Offset(boundsLeft, boundsTop))
325 val topRight = root.localToWindow(Offset(boundsRight, boundsTop))
326 val bottomRight = root.localToWindow(Offset(boundsRight, boundsBottom))
327 val bottomLeft = root.localToWindow(Offset(boundsLeft, boundsBottom))
328
329 val left = minOf(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x)
330 val top = minOf(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y)
331 val right = maxOf(topLeft.x, topRight.x, bottomLeft.x, bottomRight.x)
332 val bottom = maxOf(topLeft.y, topRight.y, bottomLeft.y, bottomRight.y)
333
334 return Rect(left, top, right, bottom)
335 }
336
337 /**
338 * Checks if the vertical ranges of the two Rects overlap (inclusive)
339 */
Rectnull340 private fun Rect.overlapsVertically(other: Rect): Boolean {
341 val verticalRange = Range(top, bottom)
342 val otherVerticalRange = Range(other.top, other.bottom)
343 return verticalRange.overlaps(otherVerticalRange)
344 }
345
346 /**
347 * Inclusive check to see if the given float ranges overlap
348 */
overlapsnull349 private fun Range<Float>.overlaps(other: Range<Float>): Boolean {
350 return (lower >= other.lower && lower <= other.upper) || (upper >= other.lower && upper <= other.upper)
351 }
352
353 /**
354 * Copy of [RowColumnParentData] that has been modified to include the new ignoreFold field.
355 */
356 internal data class RowColumnParentData(
357 var weight: Float = 0f,
358 var fill: Boolean = true,
359 var crossAxisAlignment: CrossAxisAlignment? = null,
360 var ignoreFold: Boolean = false
361 )
362
363 internal class IgnoreFoldModifier(
364 inspectorInfo: InspectorInfo.() -> Unit
365 ) : ParentDataModifier, InspectorValueInfo(inspectorInfo) {
modifyParentDatanull366 override fun Density.modifyParentData(parentData: Any?) =
367 ((parentData as? RowColumnParentData) ?: RowColumnParentData()).also {
368 it.ignoreFold = true
369 }
370
equalsnull371 override fun equals(other: Any?): Boolean {
372 if (this === other) return true
373 return other is IgnoreFoldModifier
374 }
375
hashCodenull376 override fun hashCode(): Int {
377 return 0
378 }
379
toStringnull380 override fun toString(): String =
381 "IgnoreFoldModifier(ignoreFold=true)"
382 }
383