androidx / constraintlayout

ConstraintLayout is an Android layout component which allows you to position and size widgets in a flexible way
Apache License 2.0
1.06k stars 177 forks source link

[Compose] Layout not behaving as expected when using Dimension.fillToConstraints.atLeastWrapContent #836

Closed vladmircan closed 11 months ago

vladmircan commented 11 months ago

I am developing a component that has two blocks of equal width (block A and B). Depending on the situation, either block A or B might turn out to have more content than its sibling

Here is the behaviour I want to achieve:

  1. If the content of both blocks fits within 50% of the ConstraintLayout width, then the two blocks should be of equal size.
  2. If the content of block A is too long to be displayed in the allocated 50% width, more width should be allocated to it from block B (so long as block B has enough space to display its content)
  3. The same should be true if block B has the longer content.

And here is the code snippet in question:

@Composable
fun ConstraintLayoutComponent() {
    ConstraintLayout(
        modifier = Modifier.fillMaxWidth(),
        constraintSet = ConstraintSet {
            val blockA = createRefFor("blockA")
            val blockB = createRefFor("blockB")

            constrain(blockA) {
                start.linkTo(parent.start)
                end.linkTo(blockB.start)
                top.linkTo(parent.top)
                bottom.linkTo(parent.bottom)

                width = Dimension.fillToConstraints.atLeastWrapContent
            }

            constrain(blockB) {
                start.linkTo(blockA.end)
                end.linkTo(parent.end)
                top.linkTo(parent.top)
                bottom.linkTo(parent.bottom)

                width = Dimension.fillToConstraints.atLeastWrapContent
            }

            val horizontalChain = createHorizontalChain(
                blockA,
                blockB
            )
            constrain(horizontalChain) {
                start.linkTo(parent.start)
                end.linkTo(parent.end)
            }
        }
    ) {
        Box(modifier = Modifier.layoutId("blockA").background(color = Color.Blue))

        Box(modifier = Modifier.layoutId("blockB").background(color = Color.Red))
    }
}

@Preview
@Composable
fun ConstraintLayoutComponentPreview() {
    ConstraintLayoutComponent()
}

The issue is reproducible on both 1.0.1 and 1.1.0 versions. Without the atLeastWrapContent constraint, everything works as expected.

oscar-ad commented 11 months ago

This behavior is currently not possible with ConstraintLayout.

You may use width = Dimension.preferredWrapContent which will give you the closest result to what you want.

As in, the elements will be weighted against each other based on the space they need. But it will not give them 50% if they both fit.

vladmircan commented 11 months ago

@oscar-ad Thank you for your response!

Sorry for labelling this as a bug then, it really seemed like the combination was exactly what I was looking for. Any plans of supporting this in the future, or is this outright not possible with the current implementation of the library?

oscar-ad commented 11 months ago

Yes, from the api, it would seem that it's a supported case. Unfortunately ConstraintLayout doesn't support complex rules like that on a fundamental level. At some point we might do a Helper that supports some behaviors like this that we've seen being asked for. But no real timeline for that.

In the meantime, I built a Custom Layout that supports this behavior, let me know if it's what you expected:

/**
 * Here is the behaviour I want to achieve:
 *
 * - If the content of both blocks fits within 50% of the ConstraintLayout width, then the two blocks should be of equal size.
 * - If the content of block A is too long to be displayed in the allocated 50% width, more width should be allocated to it from block B (so long as block B has enough space to display its content)
 * - The same should be true if block B has the longer content.
 */
@Composable
fun WeightedSpreadRow(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
        modifier = modifier,
        measurePolicy = MeasurePolicy { measurables, constraints ->
            val placeables = if (constraints.hasBoundedWidth) {
                val itemCount = measurables.size
                val targetSize = constraints.maxWidth.toFloat() / itemCount
                var elementsFitWithinTarget = true
                var distributableHorizontalSpace = constraints.maxWidth

                var allItemsTotalSize = 0
                var smallItemsTotalSize = 0

                val elementInfoList = measurables.map {
                    val minWidth = it.maxIntrinsicWidth(constraints.maxHeight)
                    val targetSizeDiff = minWidth - targetSize
                    if (targetSizeDiff > 0f) {
                        elementsFitWithinTarget = false
                    }
                    distributableHorizontalSpace -= minWidth

                    val fits = targetSizeDiff <= 0f
                    if (fits) {
                        smallItemsTotalSize += minWidth
                    }
                    allItemsTotalSize += minWidth

                    ElementInfo(
                        minWidth = minWidth,
                        targetSizeDiff = targetSizeDiff,
                        fitsWithinTarget = fits
                    )
                }

                if (elementsFitWithinTarget) {
                    // All elements fit within the target size, measure to that size
                    val roundedTargetSize = targetSize.roundToInt()
                    measurables.map { measurable ->
                        measurable.measure(
                            Constraints(
                                minWidth = roundedTargetSize,
                                maxWidth = roundedTargetSize,
                                minHeight = constraints.minHeight,
                                maxHeight = constraints.maxHeight
                            )
                        )
                    }
                } else {
                    // At least one element doesn't fit within the target size
                    distributableHorizontalSpace = distributableHorizontalSpace.coerceAtLeast(0)
                    // Calculate additional required space for "big" items
                    val neededSpace = elementInfoList.fold(0f) { acc, elementInfo ->
                        if (elementInfo.fitsWithinTarget) {
                            acc
                        } else {
                            acc + elementInfo.targetSizeDiff
                        }
                    }

                    if (distributableHorizontalSpace > 0 && neededSpace <= distributableHorizontalSpace) {
                        // "Big" elements can fit within remaining space

                        // Calculate final size for "small" elements weighting them against each
                        // other, assigning them their portion of remaining space
                        val remainingSpace = distributableHorizontalSpace - neededSpace

                        measurables.mapIndexed { index, measurable ->
                            val measurableInfo = elementInfoList[index]

                            if (measurableInfo.fitsWithinTarget) {
                                // Weighted width
                                val factor = measurableInfo.minWidth.toFloat() / smallItemsTotalSize
                                val width =
                                    ((remainingSpace * factor) + measurableInfo.minWidth).roundToInt()
                                measurable.measure(
                                    Constraints(
                                        minWidth = width,
                                        maxWidth = width,
                                        minHeight = constraints.minHeight,
                                        maxHeight = constraints.maxHeight
                                    )
                                )
                            } else {
                                measurable.measure(
                                    Constraints(
                                        minWidth = measurableInfo.minWidth,
                                        maxWidth = measurableInfo.minWidth,
                                        minHeight = constraints.minHeight,
                                        maxHeight = constraints.maxHeight
                                    )
                                )
                            }
                        }
                    } else {
                        // Can't fit all elements within the given space, weight them all
                        // against each other and assign their portion of maxWidth
                        measurables.mapIndexed { index, measurable ->
                            val measurableInfo = elementInfoList[index]
                            val factor = measurableInfo.minWidth.toFloat() / allItemsTotalSize
                            val width = (constraints.maxWidth * factor).roundToInt()
                            measurable.measure(
                                Constraints(
                                    minWidth = width,
                                    maxWidth = width,
                                    minHeight = constraints.minHeight,
                                    maxHeight = constraints.maxHeight
                                )
                            )
                        }
                    }
                }
            } else {
                // Typical scrollable row, no specific logic applies
                measurables.map { it.measure(constraints) }
            }

            // Calculate layout size
            var maxWidth = 0
            var maxHeight = 0
            placeables.forEach {
                maxWidth += it.width
                maxHeight = maxOf(maxHeight, it.height)
            }

            val layoutWidth = when {
                constraints.hasFixedWidth -> constraints.minWidth
                constraints.hasBoundedWidth -> maxOf(minOf(maxWidth, constraints.maxWidth), constraints.minWidth)
                else -> maxOf(maxWidth, constraints.minWidth)
            }
            val layoutHeight = when {
                constraints.hasFixedHeight -> constraints.minHeight
                constraints.hasBoundedHeight -> maxOf(minOf(maxHeight, constraints.maxHeight), constraints.minHeight)
                else -> maxOf(maxHeight, constraints.minHeight)
            }

            layout(layoutWidth, layoutHeight) {
                // Layout continuously, no need for padding or alignment as we always occupy all
                // space
                var currX = 0
                placeables.forEach { placeable ->
                    placeable.place(currX, 0)
                    currX += placeable.width
                }
            }
        },
        content = content
    )
}

private class ElementInfo(
    val minWidth: Int,
    val targetSizeDiff: Float,
    val fitsWithinTarget: Boolean
)

@Preview
@Composable
private fun WeightedSpreadRowPreview() {
    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
        WeightedSpreadRow(Modifier.fillMaxWidth()) {
            Text("Text A", Modifier.background(Color.Cyan))
            Text("Text B", Modifier.background(Color.LightGray))
        }
        WeightedSpreadRow(Modifier.fillMaxWidth()) {
            Text("Short text", Modifier.background(Color.Cyan))
            Text("This is a significantly larger text than the other", Modifier.background(Color.LightGray))
        }
        WeightedSpreadRow(Modifier.fillMaxWidth()) {
            Text("This text is not as short", Modifier.background(Color.Cyan))
            Text("This is a significantly larger text than the other", Modifier.background(Color.LightGray))
        }
    }
}
vladmircan commented 11 months ago

@oscar-ad Wow, wasn't expecting a full-blown code sample but I can't thank you enough! Everything worked as expected, aside from a little scenario that was easy to fix. Could I buy you a coffee? Because you sure saved me a lot of time!

P.S. Here is the code I arrived at after a bit of tinkering, feel free to close the issue:

@Composable
fun WeightedSpreadRow(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) = Layout(
    modifier = modifier,
    measurePolicy = MeasurePolicy { measurables, constraints ->
        val placeables = if (constraints.hasBoundedWidth) {
            val targetSize = constraints.maxWidth.toFloat() / measurables.size
            var extraNeededSpace = 0f
            var distributableHorizontalSpace = constraints.maxWidth
            var totalSizeOfAllItems = 0
            var totalSizeOfSmallItems = 0

            val elementInfoList = measurables.map { measurable ->
                val minWidth = measurable.maxIntrinsicWidth(constraints.maxHeight)
                val targetSizeDiff = minWidth - targetSize

                distributableHorizontalSpace -= minWidth
                totalSizeOfAllItems += minWidth

                val doesFitWithinTarget = targetSizeDiff <= 0
                if (doesFitWithinTarget) {
                    totalSizeOfSmallItems += minWidth
                } else {
                    extraNeededSpace += targetSizeDiff
                }

                ElementInfo(
                    minWidth = minWidth,
                    doesFitWithinTarget = doesFitWithinTarget
                )
            }

            when {
                extraNeededSpace <= 0f -> {
                    // All elements fit within the target size, measure to that size
                    val roundedTargetSize = targetSize.roundToInt()
                    val newConstraints = constraints.copy(
                        minWidth = roundedTargetSize,
                        maxWidth = roundedTargetSize
                    )
                    measurables.map { it.measure(newConstraints) }
                }

                extraNeededSpace <= distributableHorizontalSpace -> {
                    // "Big" elements can fit within remaining space
                    measurables.mapIndexed { index, measurable ->
                        val measurableInfo = elementInfoList[index]

                        val width = if (measurableInfo.doesFitWithinTarget) {
                            // Weighted width
                            val factor = measurableInfo.minWidth.toFloat() / totalSizeOfSmallItems
                            ((distributableHorizontalSpace * factor) + measurableInfo.minWidth).roundToInt()
                        } else {
                            measurableInfo.minWidth
                        }

                        measurable.measure(
                            constraints.copy(
                                minWidth = width,
                                maxWidth = width
                            )
                        )
                    }
                }

                else -> {
                    // Can't fit all elements within the given space, weight them all
                    // against each other and assign their portion of maxWidth
                    measurables.mapIndexed { index, measurable ->
                        val measurableInfo = elementInfoList[index]
                        val factor = measurableInfo.minWidth.toFloat() / totalSizeOfAllItems
                        val width = (constraints.maxWidth * factor).roundToInt()

                        measurable.measure(
                            constraints.copy(
                                minWidth = width,
                                maxWidth = width
                            )
                        )
                    }
                }
            }
        } else {
            // Typical scrollable row, no specific logic applies
            measurables.map { it.measure(constraints) }
        }

        layout(placeables.getMaxWidth(constraints), placeables.getMaxHeight(constraints)) {
            // Layout continuously, no need for padding or alignment as we always occupy all space
            var xOffset = 0
            placeables.forEach { placeable ->
                placeable.place(xOffset, 0)
                xOffset += placeable.width
            }
        }
    },
    content = content
)

private fun List<Placeable>.getMaxWidth(constraints: Constraints): Int {
    val totalWidth = this.sumOf { it.width }
    return when {
        constraints.hasFixedWidth -> constraints.minWidth
        constraints.hasBoundedWidth -> maxOf(
            minOf(totalWidth, constraints.maxWidth),
            constraints.minWidth
        )

        else -> maxOf(totalWidth, constraints.minWidth)
    }
}

private fun List<Placeable>.getMaxHeight(constraints: Constraints): Int {
    val maxHeight = this.maxOf { it.height }
    return when {
        constraints.hasFixedHeight -> constraints.minHeight
        constraints.hasBoundedHeight -> maxOf(
            minOf(maxHeight, constraints.maxHeight),
            constraints.minHeight
        )

        else -> maxOf(maxHeight, constraints.minHeight)
    }
}

private class ElementInfo(
    val minWidth: Int,
    val doesFitWithinTarget: Boolean
)
oscar-ad commented 11 months ago

No problem! Glad it was useful, and thanks for sharing back your adjustments!