2

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?

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
Martyna Maron
  • 371
  • 4
  • 19
  • Looks like I might have to use [SubcomposeLayout](https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/package-summary#SubcomposeLayout(androidx.compose.ui.Modifier,kotlin.Function2)) as it states its usage is (among other things): _When you want to use the size of one child during the composition of the second child._ which is what I want here. But the Internet lacks good explanations on how to use it :( – Martyna Maron Dec 08 '21 at 19:33

2 Answers2

8

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:

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • Thank you! The documentation of `weight` modifier is a lot clearer now, I would have never thought - thanks! Just a question about the `place` method - we don't have to figure out the `y` coordinates here because they don't matter since the grid will be pushed down by the image, right? So Any `y` placement would be relative to the space under the image, not the parent as the [method documentation](https://developer.android.com/reference/kotlin/androidx/compose/ui/layout/Placeable.PlacementScope#(androidx.compose.ui.layout.Placeable).place(kotlin.Int,kotlin.Int,kotlin.Float)) says? – Martyna Maron Dec 09 '21 at 14:02
  • @MartynaMaron Many modifiers use `LayoutModifier` under the hood, such as `padding`. You can chain them one by one, and the result will add up one by one. When you return a zero position, you just keep the previously calculated position without modifications. – Phil Dukhov Dec 09 '21 at 15:13
3

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

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
Lanasso
  • 46
  • 3
  • Thank you for taking the time! I have experimented with a similar set up yesterday with a `SubcomposeLayout` but I always had the grid filling up the full height of the screen - I think that was my issue. I've also found that if the `DynamicColumn` is set to `fillMaxSize`, the grid covers the whole screen, so only its outer container can be full size. I see what you mean by going smaller than 200.dp - I was planning to make the grid maxHeight `screenHeight - 200.dp` and if the text overflows in any of the cells, [resize the font size until it fits](https://stackoverflow.com/a/66090448/7707818) – Martyna Maron Dec 09 '21 at 11:54