Home > Enterprise >  How to resize an item in a Compose Column, depending on another item
How to resize an item in a Compose Column, depending on another item

Time:12-09

I have a "simple" layout in Compose, where in a Column there are two elements:

  • top image
  • a grid of 4 squares underneath, each square containing some text

I'd like the layout to have the following behaviour:

  • set the maximum height of the image to be screenWidth
  • set the minimum height of the image to be 200.dp
  • the image should always be in a square container (cropping of the image is fine)
  • let the grid "grow" as much as it needs to, to wrap around the content, making the image shrink as necessary

This means that if the text in the squares is short, the image will cover a large square on the top. But if any of the square text is really, long, I want the whole grid to scale up and shrink the image. These are the desirable outcomes:

  1. When text is short enough

1

  1. When a piece of text is really long

2

I have tried this with ConstraintLayout in Compose, but I can't get the squares to scale properly.

With a Column, I can't get the options to grow with large content - the text just gets truncated and the image remains a massive square.

These are the components I'd built:


// the screen

Column {
    Box(modifier = Modifier
        .heightIn(min = 200.dp, max = screenWidth)
        .aspectRatio(1f)
        .border(BorderStroke(1.dp, Color.Green))
        .align(Alignment.CenterHorizontally),
    ) {
        Image(
            painter = painterResource(id = R.drawable.puppy),
            contentDescription = null,
            contentScale = ContentScale.Crop
        )
    }
    OptionsGrid(choicesList, modifier = Modifier.heightIn(max = screenHeight - 200.dp))
}

@Composable
fun OptionsGrid(choicesList: List<List<String>>, modifier: Modifier = Modifier) {

    Column(
        modifier = modifier
            .border(1.dp, Color.Blue)
            .padding(top = 4.dp, bottom = 4.dp)
            .fillMaxHeight(),
        verticalArrangement = Arrangement.Center
    ) {
        choicesList.forEach { choicesPair ->
            Row(modifier = Modifier.weight(0.5f)) {
                choicesPair.forEach { choice ->
                    Box(
                        modifier = Modifier
                            .padding(4.dp)
                            .background(Color.White)
                            .weight(0.5f)
                    ) {
                        Option(choice = choice)
                    }
                }
            }
        }
    }
}

@Composable
fun Option(choice: String) {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Yellow)
            .border(BorderStroke(1.dp, Color.Red)),

        contentAlignment = Alignment.Center
    ) {
        Text(
            text = choice,
            modifier = Modifier.padding(8.dp),
            textAlign = TextAlign.Center,
        )
    }
}

Do I need a custom layout for this? I suppose what's happening here is that the Column is measuring the image first, letting it be its maximum height, because there is space for that on the screen, and then when measuring the grid, it gives it the remaining space and nothing more.

So I'd need a layout which starts measuring from the bottom?

CodePudding user response:

I suppose what's happening here is that the Column is measuring the image first, letting it be its maximum height, because there is space for that on the screen, and then when measuring the grid, it gives it the remaining space and nothing more.

That is correct, it goes down the UI tree, measures the first child of the column(the box with the image) and since the image doesn't have any children, it returns it's size to the parent Column. (see documentation)

I'm pretty sure this requieres a custom layout, so this is what I came up with:

First, modified your composables a bit for testing purposes (tweaked some modifiers and replaced the Texts with TextFields to be able to see how the UI reacts)

@ExperimentalComposeUiApi
@Composable
fun theImage() {
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier
            .aspectRatio(1f)
            .border(BorderStroke(1.dp, Color.Green))
            .background(Color.Blue)

    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_launcher_foreground),
            contentDescription = null,
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .border(BorderStroke(2.dp, Color.Cyan))
        )
    }
}


@Composable
fun OptionsGrid(modifier: Modifier = Modifier) {
    Column(
        modifier = modifier
            .border(1.dp, Color.Blue)
            .padding(top = 4.dp, bottom = 4.dp)
            .height(IntrinsicSize.Min),
        verticalArrangement = Arrangement.Center
    ) {
        repeat(2){
            Row(modifier = Modifier.weight(0.5f)) {
                repeat(2){
                    Box(
                        modifier = Modifier
                            .padding(4.dp)
                            .background(Color.White)
                            .weight(0.5f)
                            .wrapContentHeight()
                    ) {
                        Option()
                    }
                }
            }
        }
    }
}


@Composable
fun Option() {
    var theText by rememberSaveable { mutableStateOf("a")}

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Yellow)
            .border(BorderStroke(1.dp, Color.Red)),

        contentAlignment = Alignment.Center
    ) {
        OutlinedTextField(value = theText, onValueChange = {theText = it})
    }
}

And now, the custom layout

Since subcompose needs a slotId, and you only need IDs for the image and grid, you can create an Enum class with two ids.

enum class SlotsEnum {Main, Dependent}

slotID: A unique id which represents the slot we are composing into. If you have fixed amount or slots you can use enums as slot ids, or if you have a list of items maybe an index in the list or some other unique key can work. To be able to correctly match the content between remeasures you should provide the object which is equals to the one you used during the previous measuring. content - the composable content which defines the slot. It could emit multiple layouts, in this case the returned list of Measurables will have multiple elements.

Then, with this composable, which receives a screen width, height, an optional modifier and the image, as well as the grid

@Composable
fun DynamicColumn(
    screenWidth: Int,
    screenHeight: Int,
    modifier: Modifier = Modifier,
    img: @Composable () -> Unit,
    squares: @Composable () -> Unit
)

You can measure the total height of the grid and use that to calculate the height of the image (still haven't managed to a proper UI when scaled under 200dp, but it shouldn't be diffcult).

SubcomposeLayout { constraints ->
    val placeableSquares = subcompose(SlotsEnum.Main, squares).map {
        it.measure(constraints)
    }
    val squaresHeight = placeableSquares.sumOf { it.height }
    val remainingHeight = screenHeight - squaresHeight

    val imgMaxHeight = if (remainingHeight > screenWidth) screenWidth else remainingHeight

    val placeableImage = subcompose(SlotsEnum.Dependent, img).map{
        it.measure(Constraints(200, screenWidth, imgMaxHeight, imgMaxHeight))
    }

Then, apply the constraints to the image and finally place the items.

layout(constraints.maxWidth, constraints.maxHeight) {
    var yPos = 0
    
    placeableImage.forEach{
        it.place(x= screenWidth / 2 - it.width / 2, y= yPos)
        yPos  = it.height
    }
    
    placeableSquares.forEach{
        it.place(x=0, y=yPos)
    }
}

and finally, just call the previous composable, DynamicColumn:

@ExperimentalComposeUiApi
@Composable
fun ImageAndSquaresLayout(screenWidth: Int, screenHeight: Int) {

    DynamicColumn(screenWidth = screenWidth, screenHeight = screenHeight,
        img = { theImage() },
        squares = { OptionsGrid() })
}

PS: possibly will update this tomorrow if I can fix the minimum width issue

CodePudding user response:

Here's how you can do it without custom layout.

  1. You need your image size to be calculated after OptionsGrid. In this case you can use Modifier.weight(1f, fill = false): it forces all the views without Modifier.weight to be layout before any weighted elements.

  2. Modifier.weight will override your Modifier.heightIn, but we can restrict it size from the other side: using Modifier.layout on OptionsGrid. Using this modifier we can override constraints applied to the view.

    p.s. Modifier.heightIn(max = screenWidth) is redundant, as views are not gonna grow more than screen size anyway, unless the width constraint is overridden, for example, with a scroll view.

  3. .height(IntrinsicSize.Min) will stop OptionsGrid from growing more than needed. Note that is should be placed after Modifier.layout, as it sets height constraint to infinity. See why modifiers order matters.

val choicesList = listOf(
    listOf(
        LoremIpsum(if (flag) 100 else 1).values.first(),
        "Short stuff",
    ),
    listOf(
        "Medium length text",
        "Hi",
    ),
)
Column {
    Box(
        modifier = Modifier
            .weight(1f, fill = false)
            .aspectRatio(1f)
            .border(BorderStroke(1.dp, Color.Green))
            .align(Alignment.CenterHorizontally)
    ) {
        Image(
            painter = painterResource(id = R.drawable.profile),
            contentDescription = null,
            contentScale = ContentScale.Crop
        )
    }
    OptionsGrid(
        choicesList,
        modifier = Modifier
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints.copy(
                    // left 200.dp for min image height
                    maxHeight = constraints.maxHeight - 200.dp.roundToPx(),
                    // occupy all height except full image square in case of smaller text
                    minHeight = constraints.maxHeight - constraints.maxWidth,
                ))
                layout(placeable.width, placeable.height) {
                    placeable.place(0, 0)
                }
            }
            .height(IntrinsicSize.Min)
    )
}

Result:

  • Related