Jetpack Compose: Row with all items same height

Viewed 4434

I'm trying to implement a layout in Compose where the items of a horizontally scrollable Row should all have the same height, so smaller items should adjust to the size of the biggest item in the row. I know about intrinsic size but I just can't get it to work. Also I don't want to assign a fixed height to the Row, as the Row's height should also be the height of its biggest child composable.

This is the simplified layout

@Composable
fun Screen(
    modifier: Modifier = Modifier,
) {
    Row(
        modifier = modifier
            .height(IntrinsicSize.Min)
            .horizontalScroll(state = rememberScrollState()),
        horizontalArrangement = Arrangement.spacedBy(10.dp),
    ) {
        Item(
            text = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy " +
                    "eirmod tempor invidunt ut labore et dolore magna aliquyam"
        )

        Item(
            text = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy " +
                    "eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam " +
                    "voluptua. At"
        )

        Item(
            text = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam"
        )
    }
}

@Composable
private fun Item(
    modifier: Modifier = Modifier,
    text: String,
) {
    Column(
        modifier = modifier.width(200.dp),
        horizontalAlignment = Alignment.End,
        verticalArrangement = Arrangement.SpaceBetween
    ) {
        Column {
            Text("Some static text")

            // Dynamic text
            Text(
                text,
                modifier = Modifier.padding(top = 5.dp)
            )
        }

        // The space between these two composables should be flexible,
        // hence the outer column with Arrangement.SpaceBetween

        Button(
            modifier = Modifier.padding(top = 20.dp),
            onClick = {}
        ) {
            Text("Button")
        }
    }
}

This is the result

what I get

but what I actually want is

what I want

When I apply fillMaxHeight() to Item, the items take up the whole height and all buttons are aligned to the bottom of the screen.

Jetpack Compose version: 1.1.0

5 Answers

I created a solution that is not recommended by Google, but works well for us.

fun Modifier.minimumHeightModifier(state: MinimumHeightState, density: Density) = onSizeChanged { size ->
    val itemHeight = with(density) {
        val height = size.height
        height.toDp()
    }

    if (itemHeight > state.minHeight ?: 0.dp) {
        state.minHeight = itemHeight
    }
}.defaultMinSize(minHeight = state.minHeight ?: Dp.Unspecified)

class MinimumHeightState(minHeight: Dp? = null) {
    var minHeight by mutableStateOf(minHeight)
}

You then configure and apply the modifier to everything you want to have the same minimum height

val density = LocalDensity.current

val minimumHeightState = remember { MinimumHeightState() }
val minimumHeightStateModifier = Modifier.minimumHeightModifier(
    minimumHeightState,
    density
)

These were all in a LazyRow

    itemsIndexed(items = carouselModel.subviews, key = { _, item -> item.id }) { _, item ->
        when (item) {
            is BasicCard -> {
                FooCard(
                    modifier = minimumHeightStateModifier,
                    section = item,
                    onAction = onAction
                )
            }
            is BarCard -> {
                BarVerticalCard(
                    modifier = minimumHeightStateModifier,
                    section = item,
                    onAction = onAction
                )
            }

A discussion about the solution on the kotlin slack can be found here: https://kotlinlang.slack.com/archives/CJLTWPH7S/p1649956718414129

Implenting such a feature to set height of each element is possible with SubComposeLayout which lets you to remeasure your composables based on new metrics such as sibling with max width or height.

You can check description of SubComposeLayout here, or my answer to have Columns with equal widths here.

@Composable
fun SubcomposeRow(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit = {},
) {

    SubcomposeLayout(modifier = modifier) { constraints ->

        var recompositionIndex = 0

        var placeables: List<Placeable> = subcompose(recompositionIndex++, content).map {
            it.measure(constraints)
        }

        placeables.forEachIndexed() { index: Int, placeable: Placeable ->
            println("Index: $index, placeable width: ${placeable.width}, height: ${placeable.height}")
        }

        var rowSize =
            placeables.fold(IntSize.Zero) { currentMax: IntSize, placeable: Placeable ->
                IntSize(
                    width = currentMax.width + placeable.width,
                    height = maxOf(currentMax.height, placeable.height)
                )
            }


        // Remeasure every element using height of longest item as minHeight of Constraint
        if (!placeables.isNullOrEmpty() && placeables.size > 1) {
            placeables = subcompose(recompositionIndex, content).map { measurable: Measurable ->
                measurable.measure(
                    Constraints(
                        minHeight = rowSize.height,
                        maxHeight = constraints.maxHeight
                    )
                )
            }

            rowSize =
                placeables.fold(IntSize.Zero) { currentMax: IntSize, placeable: Placeable ->
                    IntSize(
                        width = currentMax.width + placeable.width,
                        height = maxOf(currentMax.height, placeable.height)
                    )
                }
        }

        layout(rowSize.width, rowSize.height) {
            var xPos = 0
            placeables.forEach { placeable: Placeable ->
                placeable.placeRelative(xPos, 0)
                xPos += placeable.width
            }

        }
    }
}

Constraints in second measurement is important since we want each composable to have max height

                Constraints(
                    minHeight = rowSize.height,
                    maxHeight = constraints.maxHeight
                )

Usage

@Composable
fun Screen(
    modifier: Modifier = Modifier,
) {
    SubcomposeRow(
        modifier = modifier
            .background(Color.LightGray)
            .horizontalScroll(state = rememberScrollState()),
    ) {
        Item(
            text = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy " +
                    "eirmod tempor invidunt ut labore et dolore magna aliquyam"
        )

        Item(
            text = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy " +
                    "eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam " +
                    "voluptua. At"
        )

        Item(
            text = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam"
        )
    }
}

@Composable
private fun Item(
    modifier: Modifier = Modifier,
    text: String,
) {
    Column(
        modifier = modifier
            .width(200.dp)
            .background(Color.Red),
        horizontalAlignment = Alignment.End,
        verticalArrangement = Arrangement.SpaceBetween
    ) {
        Column(modifier = Modifier.background(Color.Yellow)) {
            Text("Some static text")

            // Dynamic text
            Text(
                text,
                modifier = Modifier.padding(top = 5.dp)
            )
        }

        // The space between these two composables should be flexible,
        // hence the outer column with Arrangement.SpaceBetween

        Button(
            modifier = Modifier
                .padding(top = 20.dp),
            onClick = {}
        ) {
            Text("Button")
        }
    }
}

Result

enter image description here

This method is only applicable if you have a small list, as Row() loads all items at once (not lazily)

As of Compose v1.3.0 Lazy lists don't support modifier.height(intrinsicSize = IntrinsicSize.Max)

So for now we have to use Row with modifier = modifier.height(intrinsicSize = IntrinsicSize.Max)

When writing RowItem add Spacer(modifier = Modifier.weight(1f)) to fill empty space

@Composable
fun RowItem(modifier: Modifier = Modifier) {
Surface(
    modifier = modifier,
    color = Color.Gray,
    shape = RoundedCornerShape(12.dp)
) {
    Column(
        verticalArrangement = Arrangement.spacedBy(12.dp),
        modifier = Modifier.padding(16.dp)
    ) {
        repeat((1..4).random()) {
            Text(
                text = "item $it",
                color = Color.White,
                modifier = Modifier.padding(
                    horizontal = 16.dp,
                    vertical = 4.dp
                )
            )
        }

        Spacer(modifier = Modifier.weight(1f)) // this is required to push below composables to bottom

        Button(onClick = {
        }) { Text(text = "Button") }
    }
}}

Make sure to add horizontalScroll(rememberScrollState()) to make Row scrollable and height(intrinsicSize = IntrinsicSize.Max) to make the height of all cards to the tallest item.

@Composable
fun ScrollableRow() {
Row(
    Modifier
        .horizontalScroll(rememberScrollState()) // this makes it scrollable
        .height(intrinsicSize = IntrinsicSize.Max) // this make height of all cards to the tallest card.
        .padding(horizontal = 16.dp),
    content = {
        repeat(4) {
            RowItem(modifier = Modifier.fillMaxHeight())
        }
    },
    horizontalArrangement = Arrangement.spacedBy(16.dp),
)}

Result:

The following example produces the behavior you're looking for. It utilizes intrinsic sizes.

@Preview
@Composable
fun PreviewTest() {
    Row(Modifier.height(IntrinsicSize.Min)) {
        Box(
            modifier = Modifier
                .background(Color.Red)
                .size(size = 80.dp)
        )
        Box(
            modifier = Modifier
                .background(Color.Green)
                .defaultMinSize(minWidth = 80.dp, minHeight = 40.dp)
                .fillMaxHeight()
        )
        Box(
            modifier = Modifier
                .background(Color.Blue)
                .defaultMinSize(minWidth = 80.dp, minHeight = 40.dp)
                .fillMaxHeight()
        )
    }
}

enter image description here

The described behaviour is a bug in Compose which I reported on Feb 11, 2022. It was marked as fixed on Aug 13, 2022. However it is yet unknown which Compose version will contain this fix.

Related