ListItem.kt

/*
 * Copyright 2019 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.compose.material

import androidx.compose.ui.semantics.semantics
import androidx.compose.foundation.Box
import androidx.compose.foundation.ContentGravity
import androidx.compose.foundation.ProvideTextStyle
import androidx.compose.foundation.Text
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.preferredHeightIn
import androidx.compose.foundation.layout.preferredSizeIn
import androidx.compose.foundation.layout.preferredWidthIn
import androidx.compose.foundation.text.FirstBaseline
import androidx.compose.foundation.text.LastBaseline
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.AlignmentLine
import androidx.compose.ui.Layout
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEachIndexed
import kotlin.math.max

/**
 * Material Design implementation of [list items](https://material.io/components/lists).
 *
 * To make this [ListItem] clickable, use [Modifier.clickable].
 * To add a background to the [ListItem], wrap it with a [Surface].
 *
 * This component can be used to achieve the list item templates existing in the spec. For example:
 * - one-line items
 * @sample androidx.compose.material.samples.OneLineListItems
 * - two-line items
 * @sample androidx.compose.material.samples.TwoLineListItems
 * - three-line items
 * @sample androidx.compose.material.samples.ThreeLineListItems
 *
 * @param modifier Modifier to be applied to the list item
 * @param icon The leading supporting visual of the list item
 * @param secondaryText The secondary text of the list item
 * @param singleLineSecondaryText Whether the secondary text is single line
 * @param overlineText The text displayed above the primary text
 * @param trailing The trailing meta text or meta icon of the list item
 * @param text The primary text of the list item
 */
@Composable
fun ListItem(
    modifier: Modifier = Modifier,
    icon: @Composable (() -> Unit)? = null,
    secondaryText: @Composable (() -> Unit)? = null,
    singleLineSecondaryText: Boolean = true,
    overlineText: @Composable (() -> Unit)? = null,
    trailing: @Composable (() -> Unit)? = null,
    text: @Composable () -> Unit
) {
    val emphasisLevels = EmphasisAmbient.current
    val typography = MaterialTheme.typography

    val styledText = applyTextStyle(typography.subtitle1, emphasisLevels.high, text)!!
    val styledSecondaryText = applyTextStyle(typography.body2, emphasisLevels.medium, secondaryText)
    val styledOverlineText = applyTextStyle(typography.overline, emphasisLevels.high, overlineText)
    val styledTrailing = applyTextStyle(typography.caption, emphasisLevels.high, trailing)

    val semanticsModifier = modifier.semantics(mergeAllDescendants = true) {}

    if (styledSecondaryText == null && styledOverlineText == null) {
        OneLine.ListItem(semanticsModifier, icon, styledText, styledTrailing)
    } else if (
        (styledOverlineText == null && singleLineSecondaryText) || styledSecondaryText == null
    ) {
        TwoLine.ListItem(
            semanticsModifier,
            icon,
            styledText,
            styledSecondaryText,
            styledOverlineText,
            styledTrailing
        )
    } else {
        ThreeLine.ListItem(
            semanticsModifier,
            icon,
            styledText,
            styledSecondaryText,
            styledOverlineText,
            styledTrailing
        )
    }
}

private object OneLine {
    // TODO(popam): support wide icons
    // TODO(popam): convert these to sp
    // List item related constants.
    private val MinHeight = 48.dp
    private val MinHeightWithIcon = 56.dp

    // Icon related constants.
    private val IconMinPaddedWidth = 40.dp
    private val IconLeftPadding = 16.dp
    private val IconVerticalPadding = 8.dp

    // Content related constants.
    private val ContentLeftPadding = 16.dp
    private val ContentRightPadding = 16.dp

    // Trailing related constants.
    private val TrailingRightPadding = 16.dp

    @Composable
    fun ListItem(
        modifier: Modifier = Modifier,
        icon: @Composable (() -> Unit)?,
        text: @Composable (() -> Unit),
        trailing: @Composable (() -> Unit)?
    ) {
        val minHeight = if (icon == null) MinHeight else MinHeightWithIcon
        Row(modifier.preferredHeightIn(minHeight = minHeight)) {
            if (icon != null) {
                Box(
                    Modifier.gravity(Alignment.CenterVertically)
                        .preferredWidthIn(minWidth = IconLeftPadding + IconMinPaddedWidth),
                    gravity = ContentGravity.CenterStart,
                    paddingStart = IconLeftPadding,
                    paddingTop = IconVerticalPadding,
                    paddingBottom = IconVerticalPadding,
                    children = icon
                )
            }
            Box(
                Modifier.weight(1f)
                    .gravity(Alignment.CenterVertically)
                    .padding(start = ContentLeftPadding, end = ContentRightPadding),
                gravity = ContentGravity.CenterStart,
                children = text
            )
            if (trailing != null) {
                Box(
                    Modifier.gravity(Alignment.CenterVertically),
                    paddingEnd = TrailingRightPadding,
                    children = trailing
                )
            }
        }
    }
}

private object TwoLine {
    // List item related constants.
    private val MinHeight = 64.dp
    private val MinHeightWithIcon = 72.dp

    // Icon related constants.
    private val IconMinPaddedWidth = 40.dp
    private val IconLeftPadding = 16.dp
    private val IconVerticalPadding = 16.dp

    // Content related constants.
    private val ContentLeftPadding = 16.dp
    private val ContentRightPadding = 16.dp
    private val OverlineBaselineOffset = 24.dp
    private val OverlineToPrimaryBaselineOffset = 20.dp
    private val PrimaryBaselineOffsetNoIcon = 28.dp
    private val PrimaryBaselineOffsetWithIcon = 32.dp
    private val PrimaryToSecondaryBaselineOffsetNoIcon = 20.dp
    private val PrimaryToSecondaryBaselineOffsetWithIcon = 20.dp

    // Trailing related constants.
    private val TrailingRightPadding = 16.dp

    @Composable
    fun ListItem(
        modifier: Modifier = Modifier,
        icon: @Composable (() -> Unit)?,
        text: @Composable (() -> Unit),
        secondaryText: @Composable (() -> Unit)?,
        overlineText: @Composable (() -> Unit)?,
        trailing: @Composable (() -> Unit)?
    ) {
        val minHeight = if (icon == null) MinHeight else MinHeightWithIcon
        Row(modifier.preferredHeightIn(minHeight = minHeight)) {
            val columnModifier = Modifier.weight(1f)
                .padding(start = ContentLeftPadding, end = ContentRightPadding)

            if (icon != null) {
                Box(
                    Modifier.preferredSizeIn(
                        minWidth = IconLeftPadding + IconMinPaddedWidth,
                        minHeight = minHeight
                    ),
                    gravity = ContentGravity.TopStart,
                    paddingStart = IconLeftPadding,
                    paddingTop = IconVerticalPadding,
                    paddingBottom = IconVerticalPadding,
                    children = icon
                )
            }

            if (overlineText != null) {
                BaselinesOffsetColumn(
                    listOf(OverlineBaselineOffset, OverlineToPrimaryBaselineOffset),
                    columnModifier
                ) {
                    overlineText()
                    text()
                }
            } else {
                BaselinesOffsetColumn(
                    listOf(
                        if (icon != null) {
                            PrimaryBaselineOffsetWithIcon
                        } else {
                            PrimaryBaselineOffsetNoIcon
                        },
                        if (icon != null) {
                            PrimaryToSecondaryBaselineOffsetWithIcon
                        } else {
                            PrimaryToSecondaryBaselineOffsetNoIcon
                        }
                    ),
                    columnModifier
                ) {
                    text()
                    secondaryText!!()
                }
            }
            if (trailing != null) {
                OffsetToBaselineOrCenter(
                    if (icon != null) {
                        PrimaryBaselineOffsetWithIcon
                    } else {
                        PrimaryBaselineOffsetNoIcon
                    }
                ) {
                    Box(
                        // TODO(popam): find way to center and wrap content without minHeight
                        Modifier.preferredHeightIn(minHeight = minHeight)
                            .padding(end = TrailingRightPadding),
                        gravity = ContentGravity.Center,
                        children = trailing
                    )
                }
            }
        }
    }
}

private object ThreeLine {
    // List item related constants.
    private val MinHeight = 88.dp

    // Icon related constants.
    private val IconMinPaddedWidth = 40.dp
    private val IconLeftPadding = 16.dp
    private val IconThreeLineVerticalPadding = 16.dp

    // Content related constants.
    private val ContentLeftPadding = 16.dp
    private val ContentRightPadding = 16.dp
    private val ThreeLineBaselineFirstOffset = 28.dp
    private val ThreeLineBaselineSecondOffset = 20.dp
    private val ThreeLineBaselineThirdOffset = 20.dp
    private val ThreeLineTrailingTopPadding = 16.dp

    // Trailing related constants.
    private val TrailingRightPadding = 16.dp

    @Composable
    fun ListItem(
        modifier: Modifier = Modifier,
        icon: @Composable (() -> Unit)?,
        text: @Composable (() -> Unit),
        secondaryText: @Composable (() -> Unit),
        overlineText: @Composable (() -> Unit)?,
        trailing: @Composable (() -> Unit)?
    ) {
        Row(modifier.preferredHeightIn(minHeight = MinHeight)) {
            if (icon != null) {
                val minSize = IconLeftPadding + IconMinPaddedWidth
                Box(
                    Modifier.preferredSizeIn(minWidth = minSize, minHeight = minSize),
                    gravity = ContentGravity.CenterStart,
                    paddingStart = IconLeftPadding,
                    paddingTop = IconThreeLineVerticalPadding,
                    paddingBottom = IconThreeLineVerticalPadding,
                    children = icon
                )
            }
            BaselinesOffsetColumn(
                listOf(
                    ThreeLineBaselineFirstOffset,
                    ThreeLineBaselineSecondOffset,
                    ThreeLineBaselineThirdOffset
                ),
                Modifier.weight(1f)
                    .padding(start = ContentLeftPadding, end = ContentRightPadding)
            ) {
                if (overlineText != null) overlineText()
                text()
                secondaryText()
            }
            if (trailing != null) {
                OffsetToBaselineOrCenter(
                    ThreeLineBaselineFirstOffset - ThreeLineTrailingTopPadding,
                    Modifier.padding(top = ThreeLineTrailingTopPadding, end = TrailingRightPadding),
                    trailing
                )
            }
        }
    }
}

/**
 * Layout that expects [Text] children, and positions them with specific offsets between the
 * top of the layout and the first text, as well as the last baseline and first baseline
 * for subsequent pairs of texts.
 */
// TODO(popam): consider making this a layout composable in `foundation-layout`.
@Composable
private fun BaselinesOffsetColumn(
    offsets: List<Dp>,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(content, modifier) { measurables, constraints ->
        val childConstraints = constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity)
        val placeables = measurables.map { it.measure(childConstraints) }

        val containerWidth = placeables.fold(0) { maxWidth, placeable ->
            max(maxWidth, placeable.width)
        }
        val y = Array(placeables.size) { 0 }
        var containerHeight = 0
        placeables.fastForEachIndexed { index, placeable ->
            val toPreviousBaseline = if (index > 0) {
                placeables[index - 1].height - placeables[index - 1][LastBaseline]
            } else 0
            val topPadding = max(
                0,
                offsets[index].toIntPx() - placeable[FirstBaseline] - toPreviousBaseline
            )
            y[index] = topPadding + containerHeight
            containerHeight += topPadding + placeable.height
        }

        layout(containerWidth, containerHeight) {
            placeables.fastForEachIndexed { index, placeable ->
                placeable.placeRelative(0, y[index])
            }
        }
    }
}

/**
 * Layout that takes a child and adds the necessary padding such that the first baseline of the
 * child is at a specific offset from the top of the container. If the child does not have
 * a first baseline, the layout will match the minHeight constraint and will center the
 * child.
 */
// TODO(popam): support fallback alignment in AlignmentLineOffset, and use that here.
@Composable
private fun OffsetToBaselineOrCenter(
    offset: Dp,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(content, modifier) { measurables, constraints ->
        val placeable = measurables[0].measure(constraints.copy(minHeight = 0))
        val baseline = placeable[FirstBaseline]
        val y: Int
        val containerHeight: Int
        if (baseline != AlignmentLine.Unspecified) {
            y = offset.toIntPx() - baseline
            containerHeight = max(constraints.minHeight, y + placeable.height)
        } else {
            containerHeight = max(constraints.minHeight, placeable.height)
            y = Alignment.Center
                .align(IntSize(0, containerHeight - placeable.height)).y
        }
        layout(placeable.width, containerHeight) {
            placeable.placeRelative(0, y)
        }
    }
}

private fun applyTextStyle(
    textStyle: TextStyle,
    emphasis: Emphasis,
    icon: @Composable (() -> Unit)?
): @Composable (() -> Unit)? {
    if (icon == null) return null
    return {
        ProvideEmphasis(emphasis) {
            ProvideTextStyle(textStyle, icon)
        }
    }
}