API source code for Glimmer Card component

When creating a Glimmer Card component, refer to the following source code in Card.kt:

/*
 * Copyright 2025 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.xr.glimmer

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
import kotlin.math.max

/**
 * Card is a component used to group related information into a single digestible unit. A card can
 * adapt to display a wide range of content, from simple text blurbs to more complex summaries with
 * multiple elements. A card contains text [content], and may also have any combination of [title],
 * [subtitle], [leadingIcon], and [trailingIcon]. If specified, [title] is placed on top of the
 * [subtitle], which is placed on top of the [content]. A card fills the maximum width available by
 * default.
 *
 * This Card is focusable - see the other [Card] overload for a clickable Card.
 *
 * Cards can also be combined with a [TitleChip]. See the documentation for [TitleChip] for more
 * information / sample code.
 *
 * A simple Card with just text:
 *
 * @sample androidx.xr.glimmer.samples.CardSample
 *
 * A Card with a trailing icon:
 *
 * @sample androidx.xr.glimmer.samples.CardWithTrailingIconSample
 *
 * A Card with a title, subtitle, and a leading icon:
 *
 * @sample androidx.xr.glimmer.samples.CardWithTitleAndSubtitleAndLeadingIconSample
 *
 * A card with a title and a header image:
 *
 * @sample androidx.xr.glimmer.samples.CardWithTitleAndHeaderSample
 * @param modifier the [Modifier] to be applied to this card
 * @param title optional title to be placed above [subtitle] and [content], below [header]
 * @param subtitle optional subtitle to be placed above [content], below [title]
 * @param header optional header image to be placed at the top of the card. This image should
 *   typically fill the max width available, for example using
 *   [androidx.compose.ui.layout.ContentScale.FillWidth]. Headers are constrained to a maximum
 *   aspect ratio (1.6) to avoid taking up too much vertical space, so using a modifier such as
 *   [androidx.compose.foundation.layout.fillMaxSize] will result in an image that fills the maximum
 *   aspect ratio.
 * @param leadingIcon optional leading icon to be placed before [content]. This is typically an
 *   [Icon] tinted with [contentColor] by default.
 * @param trailingIcon optional trailing icon to be placed after [content]. This is typically an
 *   [Icon] tinted with [contentColor] by default.
 * @param shape the [Shape] used to clip this card, and also used to draw the background and border
 * @param color background color of this card
 * @param contentColor content color used by components inside [content], [title], [subtitle],
 *   [leadingIcon], and [trailingIcon].
 * @param border the border to draw around this card
 * @param contentPadding the spacing values to apply internally between the container and the
 *   content. Note that there is additional padding applied around the content / text / icons inside
 *   a card, this only affects the outermost content padding.
 * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
 *   emitting Interactions for this card. You can use this to change the card's appearance or
 *   preview the card in different states. Note that if `null` is provided, interactions will still
 *   happen internally.
 * @param content the main content / body text to display inside this card. This is recommended to
 *   be limited to 10 lines of text.
 */
@Composable
public fun Card(
    modifier: Modifier = Modifier,
    title: @Composable (() -> Unit)? = null,
    subtitle: @Composable (() -> Unit)? = null,
    header: @Composable (() -> Unit)? = null,
    leadingIcon: @Composable (() -> Unit)? = null,
    trailingIcon: @Composable (() -> Unit)? = null,
    shape: Shape = CardDefaults.shape,
    color: Color = GlimmerTheme.colors.surface,
    contentColor: Color = calculateContentColor(color),
    border: BorderStroke? = SurfaceDefaults.border(),
    contentPadding: PaddingValues = CardDefaults.contentPadding,
    interactionSource: MutableInteractionSource? = null,
    content: @Composable () -> Unit,
) {
    val internalInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
    CardImpl(
        modifier =
            modifier
                .surface(
                    shape = shape,
                    color = color,
                    contentColor = contentColor,
                    border = border,
                    interactionSource = internalInteractionSource,
                )
                .focusable(interactionSource = internalInteractionSource),
        title = title,
        subtitle = subtitle,
        header = header,
        leadingIcon = leadingIcon,
        trailingIcon = trailingIcon,
        contentPadding = contentPadding,
        content = content,
    )
}

/**
 * Card is a component used to group related information into a single digestible unit. A card can
 * adapt to display a wide range of content, from simple text blurbs to more complex summaries with
 * multiple elements. A card contains text [content], and may also have any combination of [title],
 * [subtitle], [leadingIcon], and [trailingIcon]. If specified, [title] is placed on top of the
 * [subtitle], which is placed on top of the [content]. A card fills the maximum width available by
 * default.
 *
 * This Card is focusable and clickable - see the other [Card] overload for a Card that is only
 * focusable.
 *
 * Cards can also be combined with a [TitleChip]. See the documentation for [TitleChip] for more
 * information / sample code.
 *
 * A simple clickable Card with just text:
 *
 * @sample androidx.xr.glimmer.samples.ClickableCardSample
 *
 * A clickable Card with a trailing icon:
 *
 * @sample androidx.xr.glimmer.samples.ClickableCardWithTrailingIconSample
 *
 * A clickable Card with a title, subtitle, and a leading icon:
 *
 * @sample androidx.xr.glimmer.samples.ClickableCardWithTitleAndSubtitleAndLeadingIconSample
 *
 * A clickable Card with a title and a header image:
 *
 * @sample androidx.xr.glimmer.samples.ClickableCardWithTitleAndHeaderSample
 * @param onClick called when this card item is clicked
 * @param modifier the [Modifier] to be applied to this card
 * @param title optional title to be placed above [subtitle] and [content], below [header]
 * @param subtitle optional subtitle to be placed above [content], below [title]
 * @param header optional header image to be placed at the top of the card. This image should
 *   typically fill the max width available, for example using
 *   [androidx.compose.ui.layout.ContentScale.FillWidth]. Headers are constrained to a maximum
 *   aspect ratio (1.6) to avoid taking up too much vertical space, so using a modifier such as
 *   [androidx.compose.foundation.layout.fillMaxSize] will result in an image that fills the maximum
 *   aspect ratio.
 * @param leadingIcon optional leading icon to be placed before [content]. This is typically an
 *   [Icon] tinted with [contentColor] by default.
 * @param trailingIcon optional trailing icon to be placed after [content]. This is typically an
 *   [Icon] tinted with [contentColor] by default.
 * @param shape the [Shape] used to clip this card, and also used to draw the background and border
 * @param color background color of this card
 * @param contentColor content color used by components inside [content], [title], [subtitle],
 *   [leadingIcon], and [trailingIcon].
 * @param border the border to draw around this card
 * @param contentPadding the spacing values to apply internally between the container and the
 *   content. Note that there is additional padding applied around the content / text / icons inside
 *   a card, this only affects the outermost content padding.
 * @param interactionSource an optional hoisted [MutableInteractionSource] for observing and
 *   emitting Interactions for this card. You can use this to change the card's appearance or
 *   preview the card in different states. Note that if `null` is provided, interactions will still
 *   happen internally.
 * @param content the main content / body text to display inside this card. This is recommended to
 *   be limited to 10 lines of text.
 */
@Composable
public fun Card(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    title: @Composable (() -> Unit)? = null,
    subtitle: @Composable (() -> Unit)? = null,
    header: @Composable (() -> Unit)? = null,
    leadingIcon: @Composable (() -> Unit)? = null,
    trailingIcon: @Composable (() -> Unit)? = null,
    shape: Shape = CardDefaults.shape,
    color: Color = GlimmerTheme.colors.surface,
    contentColor: Color = calculateContentColor(color),
    border: BorderStroke? = SurfaceDefaults.border(),
    contentPadding: PaddingValues = CardDefaults.contentPadding,
    interactionSource: MutableInteractionSource? = null,
    content: @Composable () -> Unit,
) {
    val internalInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
    CardImpl(
        modifier =
            modifier
                .surface(
                    shape = shape,
                    color = color,
                    contentColor = contentColor,
                    border = border,
                    interactionSource = internalInteractionSource,
                )
                .clickable(interactionSource = internalInteractionSource, onClick = onClick),
        title = title,
        subtitle = subtitle,
        header = header,
        leadingIcon = leadingIcon,
        trailingIcon = trailingIcon,
        contentPadding = contentPadding,
        content = content,
    )
}

/**
 * Card is a component used to group related information into a single digestible unit. A card can
 * adapt to display a wide range of content, from simple text blurbs to more complex summaries with
 * multiple elements. A card contains text [content], and may also have any combination of [title],
 * [subtitle], [leadingIcon], and [trailingIcon]. If specified, [title] is placed on top of the
 * [subtitle], which is placed on top of the [content]. A card fills the maximum width available by
 * default.
 *
 * This Card contains an [action] that is placed on the center of the bottom edge of the card. The
 * action should be a [Button], and represents the action that will be performed when this card is
 * interacted with. The main card itself is not focusable - the [action] takes the focus instead.
 *
 * For more documentation and samples of the other card parameters, see the other card overload
 * without an action.
 *
 * @sample androidx.xr.glimmer.samples.CardWithTitleAndActionSample
 * @param action the action for this card. This should be a [Button], and represents the action
 *   performed when a user interacts with this card. The action is placed overlapping the bottom
 *   edge of the card.
 * @param modifier the [Modifier] to be applied to the outer layout containing the card and action
 * @param title optional title to be placed above [subtitle] and [content], below [header]
 * @param subtitle optional subtitle to be placed above [content], below [title]
 * @param header optional header image to be placed at the top of the card. This image should
 *   typically fill the max width available, for example using
 *   [androidx.compose.ui.layout.ContentScale.FillWidth]. Headers are constrained to a maximum
 *   aspect ratio (1.6) to avoid taking up too much vertical space, so using a modifier such as
 *   [androidx.compose.foundation.layout.fillMaxSize] will result in an image that fills the maximum
 *   aspect ratio.
 * @param leadingIcon optional leading icon to be placed before [content]. This is typically an
 *   [Icon] tinted with [contentColor] by default.
 * @param trailingIcon optional trailing icon to be placed after [content]. This is typically an
 *   [Icon] tinted with [contentColor] by default.
 * @param shape the [Shape] used to clip this card, and also used to draw the background and border
 * @param color background color of this card
 * @param contentColor content color used by components inside [content], [title], [subtitle],
 *   [leadingIcon], and [trailingIcon].
 * @param border the border to draw around this card
 * @param contentPadding the spacing values to apply internally between the container and the
 *   content. Note that there is additional padding applied around the content / text / icons inside
 *   a card, this only affects the outermost content padding.
 * @param content the main content / body text to display inside this card. This is recommended to
 *   be limited to 10 lines of text.
 */
@Composable
public fun Card(
    action: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    title: @Composable (() -> Unit)? = null,
    subtitle: @Composable (() -> Unit)? = null,
    header: @Composable (() -> Unit)? = null,
    leadingIcon: @Composable (() -> Unit)? = null,
    trailingIcon: @Composable (() -> Unit)? = null,
    shape: Shape = CardDefaults.shape,
    color: Color = GlimmerTheme.colors.surface,
    contentColor: Color = calculateContentColor(color),
    border: BorderStroke? = SurfaceDefaults.border(),
    contentPadding: PaddingValues = CardDefaults.contentPadding,
    content: @Composable () -> Unit,
) {
    // b/436852852 - in a list the button won't be focused until it crosses the focus line.
    ActionCardLayout(modifier, action) {
        CardImpl(
            modifier =
                Modifier.surface(
                    shape = shape,
                    color = color,
                    contentColor = contentColor,
                    border = border,
                ),
            title = title,
            subtitle = subtitle,
            header = header,
            leadingIcon = leadingIcon,
            trailingIcon = trailingIcon,
            contentPadding = contentPadding,
            content = content,
        )
    }
}

@Composable
private fun CardImpl(
    modifier: Modifier,
    title: @Composable (() -> Unit)?,
    subtitle: @Composable (() -> Unit)?,
    header: @Composable (() -> Unit)?,
    leadingIcon: @Composable (() -> Unit)?,
    trailingIcon: @Composable (() -> Unit)?,
    contentPadding: PaddingValues,
    content: @Composable () -> Unit,
) {
    val iconSize = GlimmerTheme.iconSizes.large
    val typography = GlimmerTheme.typography
    val componentSpacingValues = GlimmerTheme.componentSpacingValues
    val innerPadding = componentSpacingValues.small
    val iconSpacing = componentSpacingValues.medium

    Column(
        modifier = modifier.defaultMinSize(minHeight = MinimumHeight).padding(contentPadding),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center,
    ) {
        header?.let {
            Box(
                Modifier.constrainHeightToAspectRatio(HeaderMaximumAspectRatio).clip(HeaderShape),
                contentAlignment = Alignment.Center,
            ) {
                it()
            }
        }
        Row(
            modifier = Modifier.fillMaxWidth().padding(innerPadding),
            verticalAlignment = CenterVertically,
        ) {
            if (leadingIcon != null) {
                Box(
                    modifier = Modifier.align(Alignment.Top).padding(end = iconSpacing),
                    contentAlignment = Alignment.TopStart,
                ) {
                    CompositionLocalProvider(LocalIconSize provides iconSize, content = leadingIcon)
                }
            }
            Column(
                Modifier.weight(1f),
                verticalArrangement = Arrangement.spacedBy(TextVerticalSpacing),
            ) {
                if (title != null) {
                    CompositionLocalProvider(
                        LocalTextStyle provides typography.bodyMedium,
                        content = title,
                    )
                }

                if (subtitle != null) {
                    CompositionLocalProvider(
                        LocalTextStyle provides typography.caption,
                        content = subtitle,
                    )
                }

                CompositionLocalProvider(
                    LocalTextStyle provides typography.bodySmall,
                    content = content,
                )
            }
            if (trailingIcon != null) {
                Box(
                    modifier = Modifier.align(Alignment.Top).padding(start = iconSpacing),
                    contentAlignment = Alignment.TopEnd,
                ) {
                    CompositionLocalProvider(
                        LocalIconSize provides iconSize,
                        content = trailingIcon,
                    )
                }
            }
        }
    }
}

@Composable
private fun ActionCardLayout(
    modifier: Modifier,
    action: @Composable () -> Unit,
    card: @Composable () -> Unit,
) {
    Layout(contents = listOf(action, card), modifier = modifier) { measurables, constraints ->
        val actionMeasurables = measurables[0]
        val cardMeasurables = measurables[1]

        var actionMaxWidth = 0
        var actionMaxHeight = 0
        var cardMaxWidth = 0
        var cardMaxHeight = 0

        val actionPlaceables =
            actionMeasurables.fastMap {
                // Measure the action with relaxed constraints
                val placeable = it.measure(constraints.copyMaxDimensions())
                actionMaxWidth = max(actionMaxWidth, placeable.width)
                actionMaxHeight = max(actionMaxHeight, placeable.height)
                placeable
            }

        val actionInset = ActionInset.roundToPx()

        // The card is allowed to take up the total height - the height of the overall layout taken
        // up by the action
        val heightTakenUpByAction = (actionMaxHeight - actionInset).coerceAtLeast(0)

        // Shrink the height constraints, to account for the action button
        val cardMinHeightConstraints =
            (constraints.minHeight - heightTakenUpByAction).coerceAtLeast(0)
        val cardMaxHeightConstraints =
            if (constraints.hasBoundedHeight) {
                (constraints.maxHeight - heightTakenUpByAction).coerceAtLeast(0)
            } else {
                constraints.maxHeight
            }
        val cardConstraints =
            constraints.copy(
                minHeight = cardMinHeightConstraints,
                maxHeight = cardMaxHeightConstraints,
            )

        val cardPlaceables =
            cardMeasurables.fastMap {
                val placeable = it.measure(cardConstraints)
                cardMaxWidth = max(cardMaxWidth, placeable.width)
                cardMaxHeight = max(cardMaxHeight, placeable.height)
                placeable
            }

        val layoutWidth = maxOf(actionMaxWidth, cardMaxWidth)
        val layoutHeight = heightTakenUpByAction + cardMaxHeight

        layout(layoutWidth, layoutHeight) {
            cardPlaceables.fastForEach {
                // Horizontally center in the overall space
                val x = (layoutWidth - it.width) / 2
                it.placeRelative(x, 0)
            }

            actionPlaceables.fastForEach {
                // Horizontally center in the overall space
                val x = (layoutWidth - it.width) / 2
                val y = cardMaxHeight - actionInset
                it.placeRelative(x, y)
            }
        }
    }
}

/**
 * Constrains the content's height to a maximum aspect ratio, based on the maximum width.
 *
 * This modifier is similar to [androidx.compose.foundation.layout.aspectRatio], but it only
 * enforces a maximum size, allowing the content to be smaller than the bounds defined by the aspect
 * ratio. It also only constrains the height based on the width, it does not constrain the width
 * based on the height.
 *
 * @param widthToHeightRatio the maximum aspect ratio allowed for the height. This is defined as the
 *   ratio of width / height
 */
private fun Modifier.constrainHeightToAspectRatio(widthToHeightRatio: Float): Modifier {
    require(widthToHeightRatio > 0) { "Ratio must be positive" }
    return this.layout { measurable, constraints ->
        // We only want to constrain height, based on width. If width is unbounded and there is a
        // bounded height, we don't want to constrain the width based on height. So do nothing if
        // we don't have a constrained width
        if (!constraints.hasBoundedWidth) {
            val placeable = measurable.measure(constraints)
            return@layout layout(placeable.width, placeable.height) {
                placeable.placeRelative(0, 0)
            }
        }

        val height =
            (constraints.maxWidth / widthToHeightRatio)
                .toInt()
                // Handle the case where the width is more than ratio times larger than available
                // height
                .coerceAtMost(constraints.maxHeight)

        val newConstraints =
            constraints.copy(
                // Relax minimum height to let the content be smaller than constraints.minHeight if
                // the aspect ratio results in a height smaller than min height
                minHeight = 0,
                maxHeight = height,
            )

        val placeable = measurable.measure(newConstraints)

        // We relaxed the constraints earlier, but we still need to respect the incoming constraints
        // ourselves.
        val layoutHeight = placeable.height.coerceIn(constraints.minHeight, constraints.maxHeight)

        layout(placeable.width, layoutHeight) {
            // Center the content within the final layout height if needed
            val y = (layoutHeight - placeable.height) / 2
            placeable.placeRelative(0, y)
        }
    }
}

/** Default values used for [Card] */
public object CardDefaults {
    /**
     * Default content padding used for a [Card]
     *
     * This affects the outermost content padding applied around header images and the content
     * container. Note that there is additional padding applied around the content / text / icons
     * inside a card, this only represents the outer padding for the entire content.
     */
    public val contentPadding: PaddingValues
        @Composable get() = PaddingValues(GlimmerTheme.componentSpacingValues.medium)

    /** The default shape of [Card], which determines its corner radius. */
    public val shape: Shape
        @Composable get() = GlimmerTheme.shapes.medium
}

/** Default minimum height for a [Card] */
private val MinimumHeight = 80.dp

/** Spacing between title / subtitle / body text */
private val TextVerticalSpacing = 3.dp

/** Shape used to clip the header image */
private val HeaderShape = RoundedCornerShape(24.dp)

/**
 * Width / height aspect ratio for header images, to prevent the images from taking up too much
 * vertical space
 */
private const val HeaderMaximumAspectRatio = 1.6f

/** How far the action button is inset from the underlying card's edge */
private val ActionInset = 16.dp