xref: /aosp_15_r20/external/accompanist/adaptive/src/main/java/com/google/accompanist/adaptive/FoldAwareColumn.kt (revision fa44fe6ae8e729aa3cfe5c03eedbbf98fb44e2c6)
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