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 * 
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