Compose: placement of measurables in Layout based on measuredWidth

922 Views Asked by At

I am trying to implement a SegmentedControl composable, but allow for segments to be of different sizes if one of them needs more space. So far I've achieved basic implementation, where all segments are equal in width:

Segments

But as you can see, Foo and Bar segments can easily occupy less space to make room for Some very long string.

So my requirements are:

  • When the sum of desired widths of every child is less than width of incoming constraints, distribute children evenly
  • Otherwise shrink children that can be shrinked until all children are visible
  • If it is not possible, find a configuration in which maximum amount of content can be showed.

When trying to implement the first requirement I quickly remembered that it is not possible with default Layout composable since only one measurement per measurable per layout pass is allowed, and for good reasons.

Layout(
    content = {
        // Segments
    }
) { segmentsMeasurables, constraints ->
    var placeables = segmentsMeasurables.map {
        it.measure(constraints)
    }
    // In case every placeable has enough space in the layout,
    // we divide the space evenly between them
    if (placeables.sumOf { it.measuredWidth } <= constraints.maxWidth) {
        placeables = segmentsMeasurables.map { 
            it.measure( // <- NOT ALLOWED!
                Constraints.fixed(
                    width = constraints.maxWidth / state.segmentCount,
                    height = placeables[0].height
                )
            )
        }
    }

    layout(
        width = placeables.sumOf { it.width },
        height = placeables[0].height
    ) {
        var xOffset = 0
        placeables.forEachIndexed { index, placeable ->
            xOffset += placeables.getOrNull(index - 1)?.width ?: 0
            placeable.placeRelative(
                x = xOffset,
                y = 0
            )
        }
    }
}

I also looked into SubcomposeLayout, but it doesn't seem to do what I need (my use-case doesn't need subcomposition).

I can imagine a hacky solution in which I force at least two layout passes to collect children`s sizes and only after that perform layout logic, but it will be unstable, not performant, and will generate a frame with poorly layed-out children.

So how is it properly done? Am I missing something?

1

There are 1 best solutions below

2
Francesc On BEST ANSWER

You have to use intrinsic measurements,

@Composable
fun Tiles(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    Layout(
        modifier = modifier,
        content = content,
    ) { measurables, constraints ->
        val widths = measurables.map { measurable -> measurable.maxIntrinsicWidth(constraints.maxHeight) }
        val totalWidth = widths.sum()
        val placeables: List<Placeable>
        if (totalWidth > constraints.maxWidth) {
            // do not fit, set all to same width
            val width = constraints.maxWidth / measurables.size
            val itemConstraints = constraints.copy(
                minWidth = width,
                maxWidth = width,
            )
            placeables = measurables.map { measurable -> measurable.measure(itemConstraints) }
        } else {
            // set each to its required width, and split the remainder evenly
            val remainder = (constraints.maxWidth - totalWidth) / measurables.size
            placeables = measurables.mapIndexed { index, measurable ->
                val width = widths[index] + remainder
                measurable.measure(
                    constraints = constraints.copy(
                        minWidth = width,
                        maxWidth = width,
                    )
                )
            }
        }
        layout(
            width = constraints.maxWidth,
            height = constraints.maxHeight,
        ) {
            var x = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(
                    x = x,
                    y = 0
                )
                x += placeable.width
            }
        }
    }
}

@Preview(widthDp = 360)
@Composable
fun PreviewTiles() {
    PlaygroundTheme {
        Surface(
            color = MaterialTheme.colorScheme.background
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(all = 16.dp),
            ) {
                Tiles(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(40.dp)
                ) {
                    Text(
                        text = "Foo",
                        textAlign = TextAlign.Center,
                        modifier = Modifier.background(Color.Red.copy(alpha = .3f))
                    )
                    Text(
                        text = "Bar",
                        textAlign = TextAlign.Center,
                        modifier = Modifier.background(Color.Blue.copy(alpha = .3f))
                    )
                }
                Tiles(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(top = 16.dp)
                        .height(40.dp)
                ) {
                    Text(
                        text = "Foo",
                        textAlign = TextAlign.Center,
                        modifier = Modifier.background(Color.Red.copy(alpha = .3f))
                    )
                    Text(
                        text = "Bar",
                        textAlign = TextAlign.Center,
                        modifier = Modifier.background(Color.Blue.copy(alpha = .3f))
                    )
                    Text(
                        text = "Some very long text",
                        textAlign = TextAlign.Center,
                        modifier = Modifier.background(Color.Red.copy(alpha = .3f))
                    )
                }
                Tiles(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(top = 16.dp)
                        .height(40.dp)
                ) {
                    Text(
                        text = "Foo",
                        textAlign = TextAlign.Center,
                        modifier = Modifier.background(Color.Red.copy(alpha = .3f))
                    )
                    Text(
                        text = "Bar",
                        textAlign = TextAlign.Center,
                        modifier = Modifier.background(Color.Blue.copy(alpha = .3f))
                    )
                    Text(
                        text = "Some even much longer text that doesn't fit",
                        textAlign = TextAlign.Center,
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                        modifier = Modifier.background(
                            Color.Red.copy(alpha = .3f)
                        )
                    )
                }
            }
        }
    }
}

enter image description here