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:
- When text is short enough
- When a piece of text is really long
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.
You need your image size to be calculated after
OptionsGrid
. In this case you can useModifier.weight(1f, fill = false)
: it forces all the views withoutModifier.weight
to be layout before any weighted elements.Modifier.weight
will override yourModifier.heightIn
, but we can restrict it size from the other side: usingModifier.layout
onOptionsGrid
. 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..height(IntrinsicSize.Min)
will stopOptionsGrid
from growing more than needed. Note that is should be placed afterModifier.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: