Search code examples
kotlinandroid-jetpack-composeuisegmentedcontrol

Compose: placement of measurables in Layout based on measuredWidth


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?


Solution

  • 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