1 /*
<lambda>null2  * Copyright 2019 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 androidx.compose.material
18 
19 import androidx.compose.foundation.layout.Box
20 import androidx.compose.foundation.layout.Row
21 import androidx.compose.foundation.layout.heightIn
22 import androidx.compose.foundation.layout.padding
23 import androidx.compose.foundation.layout.sizeIn
24 import androidx.compose.foundation.layout.widthIn
25 import androidx.compose.runtime.Composable
26 import androidx.compose.runtime.CompositionLocalProvider
27 import androidx.compose.ui.Alignment
28 import androidx.compose.ui.Modifier
29 import androidx.compose.ui.layout.AlignmentLine
30 import androidx.compose.ui.layout.FirstBaseline
31 import androidx.compose.ui.layout.LastBaseline
32 import androidx.compose.ui.layout.Layout
33 import androidx.compose.ui.semantics.semantics
34 import androidx.compose.ui.text.TextStyle
35 import androidx.compose.ui.text.style.LineHeightStyle
36 import androidx.compose.ui.unit.Constraints
37 import androidx.compose.ui.unit.Dp
38 import androidx.compose.ui.unit.IntSize
39 import androidx.compose.ui.unit.dp
40 import androidx.compose.ui.util.fastFold
41 import androidx.compose.ui.util.fastForEachIndexed
42 import androidx.compose.ui.util.fastMap
43 import kotlin.math.max
44 
45 /**
46  * [Material Design list](https://material.io/components/lists)
47  *
48  * Lists are continuous, vertical indexes of text or images.
49  *
50  * ![Lists
51  * image](https://developer.android.com/images/reference/androidx/compose/material/lists.png)
52  *
53  * To make this [ListItem] clickable, use [Modifier.clickable]. To add a background to the
54  * [ListItem], wrap it with a [Surface].
55  *
56  * This component can be used to achieve the list item templates existing in the spec. For example:
57  * - one-line items
58  *
59  * @sample androidx.compose.material.samples.OneLineListItems
60  * - two-line items
61  *
62  * @sample androidx.compose.material.samples.TwoLineListItems
63  * - three-line items
64  *
65  * @sample androidx.compose.material.samples.ThreeLineListItems
66  *
67  * You can combine this component with a checkbox or switch as in the following examples:
68  *
69  * @sample androidx.compose.material.samples.ClickableListItems
70  * @param modifier Modifier to be applied to the list item
71  * @param icon The leading supporting visual of the list item
72  * @param secondaryText The secondary text of the list item
73  * @param singleLineSecondaryText Whether the secondary text is single line
74  * @param overlineText The text displayed above the primary text
75  * @param trailing The trailing meta text, icon, switch or checkbox
76  * @param text The primary text of the list item
77  */
78 @Composable
79 @ExperimentalMaterialApi
80 fun ListItem(
81     modifier: Modifier = Modifier,
82     icon: @Composable (() -> Unit)? = null,
83     secondaryText: @Composable (() -> Unit)? = null,
84     singleLineSecondaryText: Boolean = true,
85     overlineText: @Composable (() -> Unit)? = null,
86     trailing: @Composable (() -> Unit)? = null,
87     text: @Composable () -> Unit
88 ) {
89     val typography = MaterialTheme.typography
90 
91     val styledText = applyTextStyle(typography.subtitle1, ContentAlpha.high, text)!!
92     val styledSecondaryText = applyTextStyle(typography.body2, ContentAlpha.medium, secondaryText)
93     val styledOverlineText = applyTextStyle(typography.overline, ContentAlpha.high, overlineText)
94     val styledTrailing = applyTextStyle(typography.caption, ContentAlpha.high, trailing)
95 
96     val semanticsModifier = modifier.semantics(mergeDescendants = true) {}
97 
98     if (styledSecondaryText == null && styledOverlineText == null) {
99         OneLine.ListItem(semanticsModifier, icon, styledText, styledTrailing)
100     } else if (
101         (styledOverlineText == null && singleLineSecondaryText) || styledSecondaryText == null
102     ) {
103         TwoLine.ListItem(
104             semanticsModifier,
105             icon,
106             styledText,
107             styledSecondaryText,
108             styledOverlineText,
109             styledTrailing
110         )
111     } else {
112         ThreeLine.ListItem(
113             semanticsModifier,
114             icon,
115             styledText,
116             styledSecondaryText,
117             styledOverlineText,
118             styledTrailing
119         )
120     }
121 }
122 
123 private object OneLine {
124     // TODO(popam): support wide icons
125     // TODO(popam): convert these to sp
126     // List item related defaults.
127     private val MinHeight = 48.dp
128     private val MinHeightWithIcon = 56.dp
129 
130     // Icon related defaults.
131     private val IconMinPaddedWidth = 40.dp
132     private val IconLeftPadding = 16.dp
133     private val IconVerticalPadding = 8.dp
134 
135     // Content related defaults.
136     private val ContentLeftPadding = 16.dp
137     private val ContentRightPadding = 16.dp
138 
139     // Trailing related defaults.
140     private val TrailingRightPadding = 16.dp
141 
142     @Composable
ListItemnull143     fun ListItem(
144         modifier: Modifier = Modifier,
145         icon: @Composable (() -> Unit)?,
146         text: @Composable (() -> Unit),
147         trailing: @Composable (() -> Unit)?
148     ) {
149         val minHeight = if (icon == null) MinHeight else MinHeightWithIcon
150         Row(modifier.heightIn(min = minHeight)) {
151             if (icon != null) {
152                 Box(
153                     Modifier.align(Alignment.CenterVertically)
154                         .widthIn(min = IconLeftPadding + IconMinPaddedWidth)
155                         .padding(
156                             start = IconLeftPadding,
157                             top = IconVerticalPadding,
158                             bottom = IconVerticalPadding
159                         ),
160                     contentAlignment = Alignment.CenterStart
161                 ) {
162                     icon()
163                 }
164             }
165             Box(
166                 Modifier.weight(1f)
167                     .align(Alignment.CenterVertically)
168                     .padding(start = ContentLeftPadding, end = ContentRightPadding),
169                 contentAlignment = Alignment.CenterStart
170             ) {
171                 text()
172             }
173             if (trailing != null) {
174                 Box(
175                     Modifier.align(Alignment.CenterVertically).padding(end = TrailingRightPadding)
176                 ) {
177                     trailing()
178                 }
179             }
180         }
181     }
182 }
183 
184 private object TwoLine {
185     // List item related defaults.
186     private val MinHeight = 64.dp
187     private val MinHeightWithIcon = 72.dp
188 
189     // Icon related defaults.
190     private val IconMinPaddedWidth = 40.dp
191     private val IconLeftPadding = 16.dp
192     private val IconVerticalPadding = 16.dp
193 
194     // Content related defaults.
195     private val ContentLeftPadding = 16.dp
196     private val ContentRightPadding = 16.dp
197     private val OverlineBaselineOffset = 24.dp
198     private val OverlineToPrimaryBaselineOffset = 20.dp
199     private val PrimaryBaselineOffsetNoIcon = 28.dp
200     private val PrimaryBaselineOffsetWithIcon = 32.dp
201     private val PrimaryToSecondaryBaselineOffsetNoIcon = 20.dp
202     private val PrimaryToSecondaryBaselineOffsetWithIcon = 20.dp
203 
204     // Trailing related defaults.
205     private val TrailingRightPadding = 16.dp
206 
207     @Composable
ListItemnull208     fun ListItem(
209         modifier: Modifier = Modifier,
210         icon: @Composable (() -> Unit)?,
211         text: @Composable (() -> Unit),
212         secondaryText: @Composable (() -> Unit)?,
213         overlineText: @Composable (() -> Unit)?,
214         trailing: @Composable (() -> Unit)?
215     ) {
216         val minHeight = if (icon == null) MinHeight else MinHeightWithIcon
217         Row(modifier.heightIn(min = minHeight)) {
218             val columnModifier =
219                 Modifier.weight(1f).padding(start = ContentLeftPadding, end = ContentRightPadding)
220 
221             if (icon != null) {
222                 Box(
223                     Modifier.sizeIn(
224                             minWidth = IconLeftPadding + IconMinPaddedWidth,
225                             minHeight = minHeight
226                         )
227                         .padding(
228                             start = IconLeftPadding,
229                             top = IconVerticalPadding,
230                             bottom = IconVerticalPadding
231                         ),
232                     contentAlignment = Alignment.TopStart
233                 ) {
234                     icon()
235                 }
236             }
237 
238             if (overlineText != null) {
239                 BaselinesOffsetColumn(
240                     listOf(OverlineBaselineOffset, OverlineToPrimaryBaselineOffset),
241                     columnModifier
242                 ) {
243                     overlineText()
244                     text()
245                 }
246             } else {
247                 BaselinesOffsetColumn(
248                     listOf(
249                         if (icon != null) {
250                             PrimaryBaselineOffsetWithIcon
251                         } else {
252                             PrimaryBaselineOffsetNoIcon
253                         },
254                         if (icon != null) {
255                             PrimaryToSecondaryBaselineOffsetWithIcon
256                         } else {
257                             PrimaryToSecondaryBaselineOffsetNoIcon
258                         }
259                     ),
260                     columnModifier
261                 ) {
262                     text()
263                     secondaryText!!()
264                 }
265             }
266             if (trailing != null) {
267                 OffsetToBaselineOrCenter(
268                     if (icon != null) {
269                         PrimaryBaselineOffsetWithIcon
270                     } else {
271                         PrimaryBaselineOffsetNoIcon
272                     }
273                 ) {
274                     Box(
275                         // TODO(popam): find way to center and wrap content without minHeight
276                         Modifier.heightIn(min = minHeight).padding(end = TrailingRightPadding),
277                         contentAlignment = Alignment.Center
278                     ) {
279                         trailing()
280                     }
281                 }
282             }
283         }
284     }
285 }
286 
287 private object ThreeLine {
288     // List item related defaults.
289     private val MinHeight = 88.dp
290 
291     // Icon related defaults.
292     private val IconMinPaddedWidth = 40.dp
293     private val IconLeftPadding = 16.dp
294     private val IconThreeLineVerticalPadding = 16.dp
295 
296     // Content related defaults.
297     private val ContentLeftPadding = 16.dp
298     private val ContentRightPadding = 16.dp
299     private val ThreeLineBaselineFirstOffset = 28.dp
300     private val ThreeLineBaselineSecondOffset = 20.dp
301     private val ThreeLineBaselineThirdOffset = 20.dp
302     private val ThreeLineTrailingTopPadding = 16.dp
303 
304     // Trailing related defaults.
305     private val TrailingRightPadding = 16.dp
306 
307     @Composable
ListItemnull308     fun ListItem(
309         modifier: Modifier = Modifier,
310         icon: @Composable (() -> Unit)?,
311         text: @Composable (() -> Unit),
312         secondaryText: @Composable (() -> Unit),
313         overlineText: @Composable (() -> Unit)?,
314         trailing: @Composable (() -> Unit)?
315     ) {
316         Row(modifier.heightIn(min = MinHeight)) {
317             if (icon != null) {
318                 val minSize = IconLeftPadding + IconMinPaddedWidth
319                 Box(
320                     Modifier.sizeIn(minWidth = minSize, minHeight = minSize)
321                         .padding(
322                             start = IconLeftPadding,
323                             top = IconThreeLineVerticalPadding,
324                             bottom = IconThreeLineVerticalPadding
325                         ),
326                     contentAlignment = Alignment.CenterStart
327                 ) {
328                     icon()
329                 }
330             }
331             BaselinesOffsetColumn(
332                 listOf(
333                     ThreeLineBaselineFirstOffset,
334                     ThreeLineBaselineSecondOffset,
335                     ThreeLineBaselineThirdOffset
336                 ),
337                 Modifier.weight(1f).padding(start = ContentLeftPadding, end = ContentRightPadding)
338             ) {
339                 if (overlineText != null) overlineText()
340                 text()
341                 secondaryText()
342             }
343             if (trailing != null) {
344                 OffsetToBaselineOrCenter(
345                     ThreeLineBaselineFirstOffset - ThreeLineTrailingTopPadding,
346                     Modifier.padding(top = ThreeLineTrailingTopPadding, end = TrailingRightPadding),
347                     trailing
348                 )
349             }
350         }
351     }
352 }
353 
354 /**
355  * Layout that expects [Text] children, and positions them with specific offsets between the top of
356  * the layout and the first text, as well as the last baseline and first baseline for subsequent
357  * pairs of texts.
358  */
359 // TODO(popam): consider making this a layout composable in `foundation-layout`.
360 @Composable
BaselinesOffsetColumnnull361 private fun BaselinesOffsetColumn(
362     offsets: List<Dp>,
363     modifier: Modifier = Modifier,
364     content: @Composable () -> Unit
365 ) {
366     Layout(content, modifier) { measurables, constraints ->
367         val childConstraints = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
368         val placeables = measurables.fastMap { it.measure(childConstraints) }
369 
370         val containerWidth =
371             placeables.fastFold(0) { maxWidth, placeable -> max(maxWidth, placeable.width) }
372         val y = IntArray(placeables.size)
373         var containerHeight = 0
374         placeables.fastForEachIndexed { index, placeable ->
375             val toPreviousBaseline =
376                 if (index > 0) {
377                     placeables[index - 1].height - placeables[index - 1][LastBaseline]
378                 } else 0
379             val topPadding =
380                 max(0, offsets[index].roundToPx() - placeable[FirstBaseline] - toPreviousBaseline)
381             y[index] = topPadding + containerHeight
382             containerHeight += topPadding + placeable.height
383         }
384 
385         layout(containerWidth, containerHeight) {
386             placeables.fastForEachIndexed { index, placeable ->
387                 placeable.placeRelative(0, y[index])
388             }
389         }
390     }
391 }
392 
393 /**
394  * Layout that takes a child and adds the necessary padding such that the first baseline of the
395  * child is at a specific offset from the top of the container. If the child does not have a first
396  * baseline, the layout will match the minHeight constraint and will center the child.
397  */
398 // TODO(popam): support fallback alignment in AlignmentLineOffset, and use that here.
399 @Composable
OffsetToBaselineOrCenternull400 private fun OffsetToBaselineOrCenter(
401     offset: Dp,
402     modifier: Modifier = Modifier,
403     content: @Composable () -> Unit
404 ) {
405     Layout(content, modifier) { measurables, constraints ->
406         val placeable = measurables[0].measure(constraints.copy(minHeight = 0))
407         val baseline = placeable[FirstBaseline]
408         val y: Int
409         val containerHeight: Int
410         if (baseline != AlignmentLine.Unspecified) {
411             y = offset.roundToPx() - baseline
412             containerHeight = max(constraints.minHeight, y + placeable.height)
413         } else {
414             containerHeight = max(constraints.minHeight, placeable.height)
415             y =
416                 Alignment.Center.align(
417                         IntSize.Zero,
418                         IntSize(0, containerHeight - placeable.height),
419                         layoutDirection
420                     )
421                     .y
422         }
423         layout(placeable.width, containerHeight) { placeable.placeRelative(0, y) }
424     }
425 }
426 
applyTextStylenull427 private fun applyTextStyle(
428     textStyle: TextStyle,
429     contentAlpha: Float,
430     icon: @Composable (() -> Unit)?
431 ): @Composable (() -> Unit)? {
432     if (icon == null) return null
433     val lineHeightStyle =
434         LineHeightStyle(
435             alignment = LineHeightStyle.Alignment.Proportional,
436             trim = LineHeightStyle.Trim.Both,
437         )
438     return {
439         CompositionLocalProvider(LocalContentAlpha provides contentAlpha) {
440             ProvideTextStyle(textStyle.copy(lineHeightStyle = lineHeightStyle), icon)
441         }
442     }
443 }
444