Any ideas on the list with fit/wrap width on item in Jetpack compose

Viewed 15847

I need to implement next UI element:

enter image description here

  • Unknown size list of strings
  • Any item should be wrap content.
  • If an item is not fitted to row, he will be in the next row.
  • All list/grid is centered
5 Answers

You can use FlowRow from accompanist-flowlayout to implement this. It renders its children horizontally (like Row) but also wraps them by moving to the new line if they don't fit in the existing line. It also allows to configure horizontal and vertical spacing between items.

To nicely handle very long strings (that will not fit into single line themselves) you can set overflow = TextOverflow.Ellipsis and maxLines = 1 on Text.

@Composable
fun HashTagList(hashTags: List<String>) {
    FlowRow(
        modifier = Modifier.padding(8.dp),
        mainAxisAlignment = MainAxisAlignment.Center,
        mainAxisSize = SizeMode.Expand,
        crossAxisSpacing = 12.dp,
        mainAxisSpacing = 8.dp
    ) {
        hashTags.forEach { hashTag ->
            Text(
                text = hashTag,
                modifier = Modifier
                    .background(
                        color = colorForHashTag(hashTag),
                        shape = RoundedCornerShape(4.dp)
                    )
                    .padding(8.dp),
                overflow = TextOverflow.Ellipsis,
                maxLines = 1
            )
        }
    }
}

enter image description here

Update: For a ready to use solution take a look at Jetpack Compose Flow Layouts from Accompanist library. For a custom solution see the answer below.


Since Compose version 1.0.0-alpha10 FlowRow and FlowColumn are deprecated. I hope there will be some build-in solution in the future, but now you can use a custom layout as suggested by the deprecation note. Here's an example:

@Composable
fun TagRow(tags: Collection<String>) {
    SimpleFlowRow(
        verticalGap = 8.dp,
        horizontalGap = 8.dp,
        alignment = Alignment.CenterHorizontally,
        modifier = Modifier.padding(16.dp)
    ) {
        for (tag in tags) {
            Text(
                text = "#$tag",
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
                modifier = Modifier
                    .background(Color.LightGray, RoundedCornerShape(4.dp))
                    .padding(4.dp)
            )
        }
    }
}

@Composable
fun SimpleFlowRow(
    modifier: Modifier = Modifier,
    alignment: Alignment.Horizontal = Alignment.Start,
    verticalGap: Dp = 0.dp,
    horizontalGap: Dp = 0.dp,
    content: @Composable () -> Unit
) = Layout(content, modifier) { measurables, constraints ->
    val hGapPx = horizontalGap.roundToPx()
    val vGapPx = verticalGap.roundToPx()

    val rows = mutableListOf<MeasuredRow>()
    val itemConstraints = constraints.copy(minWidth = 0)

    for (measurable in measurables) {
        val lastRow = rows.lastOrNull()
        val placeable = measurable.measure(itemConstraints)

        if (lastRow != null && lastRow.width + hGapPx + placeable.width <= constraints.maxWidth) {
            lastRow.items.add(placeable)
            lastRow.width += hGapPx + placeable.width
            lastRow.height = max(lastRow.height, placeable.height)
        } else {
            val nextRow = MeasuredRow(
                items = mutableListOf(placeable),
                width = placeable.width,
                height = placeable.height
            )

            rows.add(nextRow)
        }
    }

    val width = rows.maxOfOrNull { row -> row.width } ?: 0
    val height = rows.sumBy { row -> row.height } + max(vGapPx.times(rows.size - 1), 0)

    val coercedWidth = width.coerceIn(constraints.minWidth, constraints.maxWidth)
    val coercedHeight = height.coerceIn(constraints.minHeight, constraints.maxHeight)

    layout(coercedWidth, coercedHeight) {
        var y = 0

        for (row in rows) {
            var x = when(alignment) {
                Alignment.Start -> 0
                Alignment.CenterHorizontally -> (coercedWidth - row.width) / 2
                Alignment.End -> coercedWidth - row.width

                else -> throw Exception("unsupported alignment")
            }

            for (item in row.items) {
                item.place(x, y)
                x += item.width + hGapPx
            }

            y += row.height + vGapPx
        }
    }
}

private data class MeasuredRow(
    val items: MutableList<Placeable>,
    var width: Int,
    var height: Int
)

and the result: enter image description here

You can find the full sample code here on GitHub.

For those looking for an implementation also accepting Alignment.Vertical to control alignment of items with smaller height, here is a modified version of Valeriy's answer:


@Composable
fun FlowRow(
    modifier: Modifier = Modifier,
    alignment: Alignment.Horizontal = Alignment.Start,
    verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
    verticalGap: Dp = 0.dp,
    horizontalGap: Dp = 0.dp,
    content: @Composable () -> Unit
) = Layout(content, modifier) { measurables, constraints ->
    val hGapPx = horizontalGap.roundToPx()
    val vGapPx = verticalGap.roundToPx()

    val rows = mutableListOf<MeasuredRow>()
    val itemConstraints = constraints.copy(minWidth = 0)

    for (measurable in measurables) {
        val lastRow = rows.lastOrNull()
        val placeable = measurable.measure(itemConstraints)

        if (lastRow != null && lastRow.width + hGapPx + placeable.width <= constraints.maxWidth) {
            lastRow.items.add(placeable)
            lastRow.width += hGapPx + placeable.width
            lastRow.height = max(lastRow.height, placeable.height)
        } else {
            val nextRow = MeasuredRow(
                items = mutableListOf(placeable),
                width = placeable.width,
                height = placeable.height
            )

            rows.add(nextRow)
        }
    }

    val width = rows.maxOfOrNull { row -> row.width } ?: 0
    val height = rows.sumBy { row -> row.height } + max(vGapPx.times(rows.size - 1), 0)

    val coercedWidth = width.coerceIn(constraints.minWidth, constraints.maxWidth)
    val coercedHeight = height.coerceIn(constraints.minHeight, constraints.maxHeight)

    layout(coercedWidth, coercedHeight) {
        var y = 0

        for (row in rows) {
            var x = when(alignment) {
                Alignment.Start -> 0
                Alignment.CenterHorizontally -> (coercedWidth - row.width) / 2
                Alignment.End -> coercedWidth - row.width

                else -> throw Exception("unsupported alignment")
            }

            for (item in row.items) {
                var localY = 0
                if (item.height < row.height) {
                    localY = when (verticalAlignment) {
                        Alignment.Start -> y
                        Alignment.CenterVertically -> y + ((row.height + vGapPx) / 2 - item.height / 2)
                        Alignment.End -> y + row.height
                        else -> throw Exception("unsupported alignment")
                    }
                } else localY = y
                item.place(x, localY)
                x += item.width + hGapPx
            }

            y += row.height + vGapPx
        }
    }
}

private data class MeasuredRow(
    val items: MutableList<Placeable>,
    var width: Int,
    var height: Int
)

you can use LazyVerticalGrid and use grid cells to choose how to display the children items. more on https://alexzh.com/jetpack-compose-building-grids/ .

val data = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5")

LazyVerticalGrid(
    cells = GridCells.Fixed(3),
    contentPadding = PaddingValues(8.dp)
) {
    items(data) { item ->
        Card(
            modifier = Modifier.padding(4.dp),
            backgroundColor = Color.LightGray
        ) {
            Text(
                text = item,
                fontSize = 24.sp,
                textAlign = TextAlign.Center,
                modifier = Modifier.padding(24.dp)
            )
        }
    }
}

Result

this is an experimental feature but it works fine and be careful with imports for items.

Related