Search code examples
android-jetpack-compose

Strange behaviour of requiredWidthIn() inside Row in compose


I have a Row which contains three Text components. I want the second and third texts to reduce their width as needed, down to a minimum of 50 dp, when the first text is too long.

The issue I'm having is that when I set requiredWidthIn(50.dp) for second and third texts inside that row and if the first text is long, it is pushing the second text until it disappears.

The other issue is that when it pushes the other texts, it's disregarding the end and spacedBy paddings I've set. All the paddings are gone from the second and third texts.

Is there any way to fix this? thanks!

enter image description here

@Composable
fun MinWidthWithRowTestComp(modifier: Modifier = Modifier) {
    Row(
        modifier = Modifier
            .background(color = Color.Black)
            .fillMaxWidth()
            .padding(16.dp),
        horizontalArrangement = Arrangement.spacedBy(16.dp)
    ) {
        Text(
            modifier = Modifier
                .background(color = Color.Red),
            text = "Hello World! Hello World!  Hello World! Hello World! ",
        )

        Text(
            modifier = Modifier
                .background(color = Color.Blue)
                .requiredWidthIn(min = 50.dp),
            text = "Hello World!",
        )

        Text(
            modifier = Modifier
                .background(color = Color.Yellow)
                .requiredWidthIn(min = 50.dp),
            text = "Hello World!",
        )
    }
}

Solution

  • This is a fine example of how Constraints and required Modifiers work in Jetpack Compose. You can refer this answer for how requiredWidth gets centered when it doesn't abide parent Constraints.

    enter image description here

    Row, also Column too in vertical orientation, measures children in a loop with Constraints.maxWidth - spaced used by other children by summing this after each measurement.

    What happens is more apparent if you add some borders and offset as

    enter image description here

    @Preview
    @Composable
    fun MinWidthWithRowTestComp() {
        Row(
            modifier = Modifier
                .background(color = Color.Black)
                .fillMaxSize()
                .padding(16.dp)
                .border(1.dp, Color.Red),
            horizontalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            Text(
                modifier = Modifier
                    .background(color = Color.Red),
                text = "Hello World! Hello World!  Hello World! Hello World! ",
            )
    
            Text(
                modifier = Modifier
                    .border(2.dp, Color.Cyan)
                    .offset(y = 40.dp)
                    .background(color = Color.Blue)
                    .requiredWidthIn(min = 50.dp),
                text = "Hello World!",
            )
    
            Text(
                modifier = Modifier
                    .border(2.dp, Color.Magenta)
                    .offset(y = 60.dp)
                    .background(color = Color.Yellow)
                    .requiredWidthIn(min = 50.dp),
                text = "Hello World!",
            )
        }
    }
    

    Cyan border is the space left after red Text is measured but by using requiredWidth you change Constraints of Text so it gets centered. And for the last one you can see that zero space is left so it gets center at right side of the parent Row. Everything works as intended however this is not the outcome you want.

    You can use Layout for the result you desire. Unlike Row you can measure second and third Texts before first one with minWidth = 50.dp.roundToPx() and measure first one with remaining space after Texts and spaces you wish to set between. I can post sample if it's needed.

    Custom Layout

    Result. As you can see second and third one has min 50.dp width and if they grow first Text needs to be measured with smaller maxWidth

    enter image description here

    Implementation

    @Composable
    fun CustomRow(
        modifier: Modifier = Modifier,
        horizontalSpacing: Dp,
        content: @Composable () -> Unit,
    ) {
    
        val measurePolicy = remember(horizontalSpacing) {
            object : MeasurePolicy {
                override fun MeasureScope.measure(
                    measurables: List<Measurable>,
                    constraints: Constraints,
                ): MeasureResult {
                    require(measurables.size == 3)
    
                    val horizontalSpacingPx = horizontalSpacing.roundToPx()
                    val minMeasurementWidth = 50.dp.roundToPx()
    
                
                    val maxWidthForSecondOrThirdText =
                        ( constraints.maxWidth - 2 * horizontalSpacingPx - minMeasurementWidth).
                                coerceAtLeast(minMeasurementWidth)
    
                    // Measure second and third placeables  with min 50.dp
                    // and with max that each one of them can only grow total width - spaces -50.dp
                    val textPlaceables: List<Placeable> = measurables
                        .filterIndexed { index, _ ->
                            index != 0
                        }
                        .map { measurable ->
                            measurable.measure(
                                constraints.copy(
                                    minWidth = minMeasurementWidth,
                                    maxWidth = maxWidthForSecondOrThirdText
                                )
                            )
                        }
    
                    // This is available space after second and third text and 32.dp spacing between
                    val availableWidthForFirstText =
                        constraints.maxWidth - 2 * horizontalSpacingPx - textPlaceables.sumOf { it.width }
    
                    val firstTextPlaceable = measurables.first().measure(
                        constraints.copy(
                            minWidth = 0,
                            maxWidth = availableWidthForFirstText.coerceAtLeast(0)
                        )
                    )
    
                    // This works with Modifier.fillMaxWidth or Modifier.width() is used
                    // And simplicity
                    val layoutWidth = constraints.maxWidth
    
                    // Layout height is the maximum of three
                    val layoutHeight = firstTextPlaceable.height.coerceAtLeast(
                        textPlaceables.maxOf { it.height }
                    )
    
                    var posX: Int
    
                    return layout(layoutWidth, layoutHeight) {
                        firstTextPlaceable.placeRelative(0, 0)
                        posX = firstTextPlaceable.width
    
                        textPlaceables.forEachIndexed { index, placeable ->
                            val space = horizontalSpacingPx * (index + 1)
                            placeable.placeRelative(posX + space, 0)
                            posX += placeable.width
    
                        }
                    }
                }
            }
        }
    
        Layout(
            modifier = modifier,
            content = content,
            measurePolicy = measurePolicy
        )
    }
    

    Demo and Usage

    @Preview
    @Composable
    fun CustomRowTest() {
    
        Column(
            modifier = Modifier.fillMaxSize()
        ) {
    
            var text1 by remember {
                mutableStateOf("Hello World! Hello World!  Hello World! Hello World! ")
            }
    
            var text2 by remember {
                mutableStateOf("Hello World!")
            }
    
            var text3 by remember {
                mutableStateOf("Hello World!")
            }
    
            TextField(value = text1, onValueChange = {text1 = it})
            TextField(value = text2, onValueChange = {text2 = it})
            TextField(value = text3, onValueChange = {text3 = it})
    
            CustomRow(
                modifier = Modifier.fillMaxWidth().border(2.dp, Color.Green),
                horizontalSpacing = 16.dp
            ) {
                Text(
                    modifier = Modifier
                        .background(color = Color.Red),
                    text = text1,
                )
    
                Text(
                    modifier = Modifier
                        .background(color = Color.Blue),
                    text = text2,
                )
    
                Text(
                    modifier = Modifier
                        .background(color = Color.Yellow),
                    text = text3,
                )
            }
        }
    }
    

    Custom Layout2

    This one measures first Text with available space which can shrink second on third down to 50.dp minimum.

    @Composable
    fun CustomRow(
        modifier: Modifier = Modifier,
        horizontalSpacing: Dp,
        content: @Composable () -> Unit,
    ) {
    
        val measurePolicy = remember(horizontalSpacing) {
            object : MeasurePolicy {
                override fun MeasureScope.measure(
                    measurables: List<Measurable>,
                    constraints: Constraints,
                ): MeasureResult {
                    require(measurables.size == 3)
    
                    val horizontalSpacingPx = horizontalSpacing.roundToPx()
                    val minMeasurementWidth = 50.dp.roundToPx()
                    val availableWidth = constraints.maxWidth - 2 * horizontalSpacingPx
    
                    // This is available space after 32.dp spacing between
                    val availableWidthForFirstText = availableWidth - 2 * minMeasurementWidth
    
                    val firstTextPlaceable = measurables.first().measure(
                        constraints.copy(
                            minWidth = 0,
                            maxWidth = availableWidthForFirstText.coerceAtLeast(0)
                        )
                    )
    
                    var unusedWidth =
                        (availableWidth - minMeasurementWidth - firstTextPlaceable.width)
                            .coerceAtLeast(minMeasurementWidth)
    
    
                    // Measure second and third placeables  with min 50.dp
    
                    val textPlaceables: List<Placeable> = measurables
                        .filterIndexed { index, _ ->
                            index != 0
                        }
                        .map { measurable ->
                            measurable.measure(
                                constraints.copy(
                                    minWidth = minMeasurementWidth,
                                    maxWidth = unusedWidth
                                )
                            ).also {
                                unusedWidth =
                                    (unusedWidth - it.width).coerceAtLeast(minMeasurementWidth)
                            }
                        }
    
    
                    // This works with Modifier.fillMaxWidth or Modifier.width() is used
                    // And simplicity
                    val layoutWidth = constraints.maxWidth
    
                    // Layout height is the maximum of three
                    val layoutHeight = firstTextPlaceable.height.coerceAtLeast(
                        textPlaceables.maxOf { it.height }
                    )
    
                    var posX: Int
    
                    return layout(layoutWidth, layoutHeight) {
                        firstTextPlaceable.placeRelative(0, 0)
                        posX = firstTextPlaceable.width
    
                        textPlaceables.forEachIndexed { index, placeable ->
                            val space = horizontalSpacingPx * (index + 1)
                            placeable.placeRelative(posX + space, 0)
                            posX += placeable.width
    
                        }
                    }
                }
            }
        }
    
        Layout(
            modifier = modifier,
            content = content,
            measurePolicy = measurePolicy
        )
    }