Closed vladmircan closed 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.
@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?
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))
}
}
}
@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
)
No problem! Glad it was useful, and thanks for sharing back your adjustments!
I am developing a component that has two blocks of equal width (block
A
andB
). Depending on the situation, either blockA
orB
might turn out to have more content than its siblingHere is the behaviour I want to achieve:
50%
of theConstraintLayout
width, then the two blocks should be of equal size.A
is too long to be displayed in the allocated50%
width, more width should be allocated to it from blockB
(so long as blockB
has enough space to display its content)B
has the longer content.And here is the code snippet in question:
The issue is reproducible on both
1.0.1
and1.1.0
versions. Without theatLeastWrapContent
constraint, everything works as expected.