Android Jetpack Compose - ChipGroup with limited number of rows - android

There is requirements to create ChipGroup with maximum lines/rows allowed, for all items that are not shown there should be last chip with text like "+3 more items".
I have tried FlowRow from accompanist, which works fine, but number of rows cannot be limited there (or I don't know how to :))
Another option I have tried is to implement custom layout. I have managed to limit number of rows (implementation similar to FlowRow with additional conditions), but I'm not sure how to append last item from layout(...placementBlock: Placeable.PlacementScope.() -> Unit)
Any help is appreciated

In such cases SubcomposeLayout should be used: it allows you to insert a composable depending on already measured views.
Applying this to FlowRow would take more time, because of many other arguments, so I've took my own simplified variant as the base.
#Composable
fun ChipVerticalGrid(
modifier: Modifier = Modifier,
spacing: Dp,
moreItemsView: #Composable (Int) -> Unit,
content: #Composable () -> Unit,
) {
SubcomposeLayout(
modifier = modifier
) { constraints ->
val contentConstraints = constraints.copy(minWidth = 0, minHeight = 0)
var currentRow = 0
var currentOrigin = IntOffset.Zero
val spacingValue = spacing.toPx().toInt()
val mainMeasurables = subcompose("content", content)
val placeables = mutableListOf<Pair<Placeable, IntOffset>>()
for (i in mainMeasurables.indices) {
val measurable = mainMeasurables[i]
val placeable = measurable.measure(contentConstraints)
fun Placeable.didOverflowWidth() =
currentOrigin.x > 0f && currentOrigin.x + width > contentConstraints.maxWidth
if (placeable.didOverflowWidth()) {
currentRow += 1
val nextRowOffset = currentOrigin.y + placeable.height + spacingValue
if (nextRowOffset + placeable.height > contentConstraints.maxHeight) {
var morePlaceable: Placeable
do {
val itemsLeft = mainMeasurables.count() - placeables.count()
morePlaceable = subcompose(itemsLeft) {
moreItemsView(itemsLeft)
}[0].measure(contentConstraints)
val didOverflowWidth = morePlaceable.didOverflowWidth()
if (didOverflowWidth) {
val removed = placeables.removeLast()
currentOrigin = removed.second
}
} while (didOverflowWidth)
placeables.add(morePlaceable to currentOrigin)
break
}
currentOrigin = currentOrigin.copy(x = 0, y = nextRowOffset)
}
placeables.add(placeable to currentOrigin)
currentOrigin = currentOrigin.copy(x = currentOrigin.x + placeable.width + spacingValue)
}
layout(
width = maxOf(constraints.minWidth, placeables.maxOfOrNull { it.first.width + it.second.x } ?: 0),
height = maxOf(constraints.minHeight, placeables.lastOrNull()?.run { first.height + second.y } ?: 0),
) {
placeables.forEach {
val (placeable, origin) = it
placeable.place(origin.x, origin.y)
}
}
}
}
Usage:
val words = LoremIpsum().values.first().split(" ").map { it.filter { it.isLetter()} }
val itemView = #Composable { text: String ->
Text(
text,
modifier = Modifier
.background(color = Color.Gray, shape = CircleShape)
.padding(vertical = 3.dp, horizontal = 5.dp)
)
}
ChipVerticalGrid(
spacing = 7.dp,
moreItemsView = {
itemView("$it more items")
},
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(7.dp)
) {
words.forEach { word ->
itemView(word)
}
}
Result:

Related

How to add drag to custom layout and change the color of the dragged surface in jetpack compose

I am using this tutorial
https://danielrampelt.com/blog/jetpack-compose-custom-schedule-layout-part-1/ and part 2
to draw a custom schedule. How can I want to use draggable inside of it so I can change the color of the dragged surface in jetpack compose. My aim is to drag an time slot vertically. This is my composable function. I have put the draggable function but I don't know how to implement it. Now my screen freezes, I can't scroll vertically.
#Composable
fun DynamicSchedule(
viewModel: CalenderViewModel,
modifier: Modifier = Modifier,
appointmentContent: #Composable (appointment: Appointment) -> Unit = {
ScheduleCard(
appointment = it
)
},
minDate: LocalDate,
maxDate: LocalDate,
dayWidth: Dp,
hourHeight: Dp
) {
val numDays = ChronoUnit.DAYS.between(minDate, maxDate).toInt() + 1
val dividerColor = if (MaterialTheme.colors.isLight) Color.LightGray else Color.DarkGray
var offsetY by remember { mutableStateOf(0f) }
Layout(
content = {
viewModel.state.value.appointmentList.sortedBy { it.startDate }
.forEach { appointment ->
Box(modifier = Modifier.appointmentData(appointment)) {
appointmentContent(appointment)
}
}
},
modifier = modifier
.drawBehind {
repeat(23) {
drawLine(
dividerColor,
start = Offset(0f, (it + 1) * hourHeight.toPx()),
end = Offset(size.width, (it + 1) * hourHeight.toPx()),
strokeWidth = 1.dp.toPx()
)
}
repeat(numDays - 1) {
drawLine(
dividerColor,
start = Offset((it + 1) * dayWidth.toPx(), 0f),
end = Offset((it + 1) * dayWidth.toPx(), size.height),
strokeWidth = 1.dp.toPx()
)
}
}
.pointerInput(Unit) {
detectTapGestures {
val x = it.x.toDp()
val y = it.y.toDp()
val time = y.value.toInt() / hourHeight.value
val date = (x.value.toInt() / dayWidth.value)
println("X: $x, Y: $y")
println("Day: $date, Time: $time")
}
}
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
offsetY += delta
println("Delta: $offsetY")
}
),
) { measurables, constraints ->
println("i got recomposed ======== ")
val height = hourHeight.roundToPx() * 24
val width = dayWidth.roundToPx() * numDays
val placeablesWithAppointment = measurables.map { measurable ->
val appointment = measurable.parentData as Appointment
val appointmentDurationInMinutes =
ChronoUnit.MINUTES.between(
appointment.startDate.time.toJavaLocalTime(),
appointment.endDate.time.toJavaLocalTime()
)
val appointmentHeight =
((appointmentDurationInMinutes / 60f) * hourHeight.toPx()).roundToInt()
val placeable = measurable.measure(
constraints.copy(
minWidth = dayWidth.roundToPx(),
maxWidth = dayWidth.roundToPx(),
minHeight = appointmentHeight,
maxHeight = appointmentHeight
)
)
Pair(placeable, appointment)
}
layout(width, height) {
placeablesWithAppointment.forEach { (placeable, appointment) ->
//appointment time - midnight
val appointmentOffsetMinutes =
ChronoUnit.MINUTES.between(
LocalTime.MIN,
appointment.startDate.time.toJavaLocalTime()
)
val appointmentY =
((appointmentOffsetMinutes / 60f) * hourHeight.toPx()).roundToInt()
val appointmentOffsetDays =
ChronoUnit.DAYS.between(
minDate,
appointment.startDate.date.toJavaLocalDate()
).toInt()
val appointmentX = appointmentOffsetDays * dayWidth.roundToPx()
placeable.place(appointmentX, appointmentY)
}
}
}
}

Can't represent a size of 357913941 in Constraints in Jetpack Compose

I have a CustomGrid that is inside a Column. That column is inside a HorizontalPager and the HorizontalPager is inside another column where there are other elements. I want the Grid to grow in height as much as necessary, but whenever I add fillMaxHeight() or wrapContentSize() or another equivalent method, the application crashes with the error you can see in the title. Is there anything I can do to fix this error and have the Grid take up as much space as I need?
I leave you the prints of my Grid, which is custom, and the respective components.
Custom Grid code:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import kotlin.math.max
interface GridScope {
#Stable
fun Modifier.span(columns: Int = 1, rows: Int = 1) = this.then(
GridData(columns, rows)
)
companion object : GridScope
}
private class GridData(
val columnSpan: Int,
val rowSpan: Int,
) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any = this#GridData
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as GridData
if (columnSpan != other.columnSpan) return false
if (rowSpan != other.rowSpan) return false
return true
}
override fun hashCode(): Int {
var result = columnSpan
result = 31 * result + rowSpan
return result
}
}
private val Measurable.gridData: GridData?
get() = parentData as? GridData
private val Measurable.columnSpan: Int
get() = gridData?.columnSpan ?: 1
private val Measurable.rowSpan: Int
get() = gridData?.rowSpan ?: 1
data class GridInfo(
val numChildren: Int,
val columnSpan: Int,
val rowSpan: Int,
)
#Composable
fun Grid(
columns: Int,
modifier: Modifier = Modifier,
content: #Composable GridScope.() -> Unit,
) {
check(columns > 0) { "Columns must be greater than 0" }
Layout(
content = { GridScope.content() },
modifier = modifier,
) { measurables, constraints ->
// calculate how many rows we need
val standardGrid = GridData(1, 1)
val spans = measurables.map { measurable -> measurable.gridData ?: standardGrid }
val gridInfo = calculateGridInfo(spans, columns)
val rows = gridInfo.sumOf { it.rowSpan }
// build constraints
val baseConstraints = Constraints.fixed(
width = constraints.maxWidth / columns,
height = constraints.maxHeight / rows,
)
val cellConstraints = measurables.map { measurable ->
val columnSpan = measurable.columnSpan
val rowSpan = measurable.rowSpan
Constraints.fixed(
width = baseConstraints.maxWidth * columnSpan,
height = baseConstraints.maxHeight * rowSpan
)
}
// measure children
val placeables = measurables.mapIndexed { index, measurable ->
measurable.measure(cellConstraints[index])
}
// place children
layout(
width = constraints.maxWidth,
height = constraints.maxHeight,
) {
var x = 0
var y = 0
var childIndex = 0
gridInfo.forEach { info ->
repeat(info.numChildren) {
val placeable = placeables[childIndex++]
placeable.placeRelative(
x = x,
y = y,
)
x += placeable.width
}
x = 0
y += info.rowSpan * baseConstraints.maxHeight
}
}
}
}
private fun calculateGridInfo(
spans: List<GridData>,
columns: Int,
): List<GridInfo> {
var currentColumnSpan = 0
var currentRowSpan = 0
var numChildren = 0
return buildList {
spans.forEach { span ->
val columnSpan = span.columnSpan.coerceAtMost(columns)
val rowSpan = span.rowSpan
if (currentColumnSpan + columnSpan <= columns) {
currentColumnSpan += columnSpan
currentRowSpan = max(currentRowSpan, rowSpan)
++numChildren
} else {
add(
GridInfo(
numChildren = numChildren,
columnSpan = currentColumnSpan,
rowSpan = currentRowSpan
)
)
currentColumnSpan = columnSpan
currentRowSpan = rowSpan
numChildren = 1
}
}
add(
GridInfo(
numChildren = numChildren,
columnSpan = currentColumnSpan,
rowSpan = currentRowSpan,
)
)
}
}
Code where Grid will be inserted or other components, it is generated based on data that comes from API:
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor.value)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
// We only consume nested flings in the main-axis, allowing cross-axis flings to propagate
// as normal
val consumeFlingNestedScrollConnection =
remember { ConsumeFlingNestedScrollConnection(consumeHorizontal = true) }
Column(
modifier = Modifier
.background(
backgroundColor.value,
RoundedCornerShape(topStart = Dimen30, topEnd = Dimen30)
)
.nestedScroll(connection = consumeFlingNestedScrollConnection)
.fillMaxWidth()
) {
HorizontalPager(
count = size,
state = pagerState,
itemSpacing = Dimen20,
modifier = Modifier.padding(top = Dimen33),
userScrollEnabled = false
) { page ->
Column(
modifier = Modifier
// We don't any nested flings to continue in the pager, so we add a
// connection which consumes them.
.nestedScroll(connection = consumeFlingNestedScrollConnection)
// Constraint the content width to be <= than the width of the pager.
.fillParentMaxWidth()
.wrapContentSize()
) {
// content (where grid could be, content is generated dinamically based on data that comes from api
}
}
}
}
}
How Grid is added to that layout:
Grid(
columns = 5,
modifier = Modifier
.padding(start = Dimen20, end = Dimen20, top = Dimen16)
.fillMaxWidth()
.wrapContentSize()
) {
// cards content
}
The crash points to the baseConstraints of the Grid code, but I can't figure out why and I can't solve the problem.
When you use Constraints.fixed() you need to have Constraints maxWidth and maxHeight that are not infinite.
When you apply Modifier.verticalScroll you transform your maxHeight to Constraints.Infinity which is 2147483647. I explained in detail in this answer about constraints with vertical scroll and Constraints.
And created a sample to show why it crashes
#Composable
private fun Grid(
modifier: Modifier = Modifier,
content: #Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val placeables = measurables.map { measurable: Measurable ->
measurable.measure(
Constraints.fixed(
width = constraints.maxWidth,
height = constraints.maxHeight
)
)
}
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach {
it.placeRelative(0, 0)
}
}
}
}
demonstration why it crashes
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
HorizontalPager(count = 3) {
Column(
modifier = Modifier.wrapContentSize()
) {
Grid() {
BoxWithConstraints() {
Text("Constraints: ${this.constraints}")
}
}
}
}
}
What you should be doing is to constrain maxHeight to parent or screen size.
One, and simple way to do is, passing it via Modifier.onSizeChanged to your Layout, but be careful using Modifier.onSizeChanged which you might trigger recomposition continuously when mutableState is used to update or set size of another Composable.
var height by remember {
mutableStateOf(0)
}
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged {
if (height == 0) {
height = it.height
}
}
){
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
HorizontalPager(count = 3) {
Column(
modifier = Modifier.wrapContentSize()
) {
Grid(height = height) {
BoxWithConstraints {
Text("Constraints: ${this.constraints}")
}
}
}
}
}
}
And use parent height instead of infinite height due to Modifier.verticalScroll
#Composable
private fun Grid(
modifier: Modifier = Modifier,
height:Int,
content: #Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val placeables = measurables.map { measurable: Measurable ->
measurable.measure(
Constraints.fixed(
width = constraints.maxWidth,
height = constraints.maxHeight.coerceAtMost(height)
)
)
}
layout(constraints.maxWidth, constraints.maxHeight) {
placeables.forEach {
it.placeRelative(0, 0)
}
}
}
}
Changing your Compsable based on sample above you can achieve what you wish to create

How to build grid images that change position and size and display number when image count is above threshold?

This is a share your knowledge, Q&A-style question about using Layout, different Constraints and placing based on size and other Composables' positions to achieve a grid that changes Composable size, position and adds a number if number of Composables are greater than 4.
To layout Composables based on their count Layout is required and need to select Constraints, a detailed answer about Constraints is available in this link, based on number of items and place them accordingly.
For Constraints selection when there are 2 items we need to pick half width and full height to have result in question. When there are 4 items we need to pick half width and half height.
When item count is 3 we need to use 2 constraints, 1 for measuring first 2 items, another one measuring the one that covers whole width
#Composable
private fun ImageDrawLayout(
modifier: Modifier = Modifier,
itemCount: Int,
divider: Dp,
content: #Composable () -> Unit
) {
val spacePx = LocalDensity.current.run { (divider).roundToPx() }
val measurePolicy = remember(itemCount, spacePx) {
MeasurePolicy { measurables, constraints ->
val newConstraints = when (itemCount) {
1 -> constraints
2 -> Constraints.fixed(
width = constraints.maxWidth / 2 - spacePx / 2,
height = constraints.maxHeight
)
else -> Constraints.fixed(
width = constraints.maxWidth / 2 - spacePx / 2,
height = constraints.maxHeight / 2 - spacePx / 2
)
}
val gridMeasurables = if (itemCount < 5) {
measurables
} else {
measurables.take(3) + measurables.first { it.layoutId == "Text" }
}
val placeables: List<Placeable> = if (measurables.size != 3) {
gridMeasurables.map { measurable: Measurable ->
measurable.measure(constraints = newConstraints)
}
} else {
gridMeasurables
.take(2)
.map { measurable: Measurable ->
measurable.measure(constraints = newConstraints)
} +
gridMeasurables
.last()
.measure(
constraints = Constraints.fixed(
constraints.maxWidth,
constraints.maxHeight / 2 - spacePx
)
)
}
layout(constraints.maxWidth, constraints.maxHeight) {
when (itemCount) {
1 -> {
placeables.forEach { placeable: Placeable ->
placeable.placeRelative(0, 0)
}
}
2 -> {
var xPos = 0
placeables.forEach { placeable: Placeable ->
placeable.placeRelative(xPos, 0)
xPos += placeable.width + spacePx
}
}
else -> {
var xPos = 0
var yPos = 0
placeables.forEachIndexed { index: Int, placeable: Placeable ->
placeable.placeRelative(xPos, yPos)
if (index % 2 == 0) {
xPos += placeable.width + spacePx
} else {
xPos = 0
}
if (index % 2 == 1) {
yPos += placeable.height + spacePx
}
}
}
}
}
}
}
Layout(
modifier = modifier,
content = content,
measurePolicy = measurePolicy
)
}
Another thing to note here is we need to find find Composable that contains Text. It's possible to find it from index since it's 4th item but i used Modifier.layoutId() for demonstration. This Modifier helps finding Composables when you don't know in which order they are placed inside a Composaable.
val gridMeasurables = if (size < 5) {
measurables
} else {
measurables.take(3) + measurables.first { it.layoutId == "Text" }
}
And place items based on item count and we add space only after first item on each row.
Usage
#Composable
fun GridImageLayout(
modifier: Modifier = Modifier,
thumbnails: List<Int>,
divider: Dp = 2.dp,
onClick: ((List<Int>) -> Unit)? = null
) {
if (thumbnails.isNotEmpty()) {
ImageDrawLayout(
modifier = modifier
.clickable {
onClick?.invoke(thumbnails)
},
divider = divider,
itemCount = thumbnails.size
) {
thumbnails.forEach {
Image(
modifier = Modifier.layoutId("Icon"),
painter = painterResource(id = it),
contentDescription = "Icon",
contentScale = ContentScale.Crop,
)
}
if (thumbnails.size > 4) {
val carry = thumbnails.size - 3
Box(
modifier = Modifier.layoutId("Text"),
contentAlignment = Alignment.Center
) {
Text(text = "+$carry", fontSize = 20.sp)
}
}
}
}
}
And using GridImageLayout
Column {
val icons5 = listOf(
R.drawable.landscape1,
R.drawable.landscape2,
R.drawable.landscape3,
R.drawable.landscape4,
R.drawable.landscape5,
R.drawable.landscape6,
R.drawable.landscape7,
)
GridImageLayout(
modifier = Modifier
.border(1.dp, Color.Red, RoundedCornerShape(10))
.clip(RoundedCornerShape(10))
.size(150.dp),
thumbnails = icons5
)
}

Jetpack Compose set sibling Composables' width to longest one dynamically with SubcomposeLayout

How can i set width of a group of composables, siblings layout from top to bottom, to width of longest one?
What i try to build is exactly same thing as in the images above. For simplicity let's say quote the component at the top and message box which contains message and another container that stores date and message status.
The longest one of quote and message box must be set as parent width and other one must be set to same width as longest one which requires a remeasuring for short one i assume.
Also if message box gets to resized there needs to be an internal parameter that passes this width to set position of container that stores date and status. As can be seen clearly with bounds message text is moved to start while status to end when quote is longer than message box. When message has more than one line message box width and height are set with a calculation as telegram or whatsapp does.
Built this initially with Layout as
#Composable
private fun DynamicLayout(
modifier: Modifier = Modifier,
quote: #Composable () -> Unit,
message: #Composable () -> Unit
) {
val content = #Composable {
quote()
message()
}
Layout(content = content, modifier = modifier) { measurables, constraints ->
val placeableQuote = measurables.first().measure(constraints)
val quoteWidth = placeableQuote.width
val placeableMessage =
measurables.last()
.measure(Constraints(minWidth = quoteWidth, maxWidth = constraints.maxWidth))
val messageWidth = placeableMessage.width
val maxWidth = quoteWidth.coerceAtLeast(messageWidth)
val totalHeight = placeableQuote.height + placeableMessage.height
layout(maxWidth, totalHeight) {
placeableQuote.placeRelative(x = 0, y = 0)
placeableMessage.placeRelative(x = 0, y = placeableQuote.height)
}
}
}
Where i measure message box using width of quote constraint it works but only when quote is longer.
DynamicLayout(
quote = {
Text(
"QUOTE with a very long text",
modifier = Modifier
.background(Color(0xffF44336))
.height(60.dp),
color = Color.White
)
},
message = {
Text(
"MESSAGE Content",
modifier = Modifier
.background(Color(0xff9C27B0)),
color = Color.White
)
}
)
DynamicLayout(
quote = {
Text(
"QUOTE",
modifier = Modifier
.background(Color(0xffF44336))
.height(60.dp),
color = Color.White
)
},
message = {
Text(
"MESSAGE with very long Content",
modifier = Modifier
.background(Color(0xff9C27B0)),
color = Color.White
)
}
)
As it's must be remeasured i think solution for this question should be done with SubComposeLayout but couldn't figure out how to use it for this setup?
#Composable
private fun SubComponentLayout(
modifier: Modifier = Modifier,
mainContent: #Composable () -> Unit,
dependentContent: #Composable (Int) -> Unit
) {
SubcomposeLayout(modifier = modifier) { constraints ->
val mainMeasurables: List<Measurable> = subcompose(SlotsEnum.Main, mainContent)
val mainPlaceables: List<Placeable> = mainMeasurables.map {
it.measure(constraints)
}
val maxSize =
mainPlaceables.fold(IntSize.Zero) { currentMax: IntSize, placeable: Placeable ->
IntSize(
width = maxOf(currentMax.width, placeable.width),
height = maxOf(currentMax.height, placeable.height)
)
}
var maxWidth =
mainPlaceables.maxOf { it.width }
layout(maxSize.width, maxSize.height) {
println("🔥 SubcomposeLayout-> layout() maxSize width: ${maxSize.width}, height: ${maxSize.height}")
val dependentMeasurables: List<Measurable> = subcompose(
slotId = SlotsEnum.Dependent,
content = {
println("🍏 SubcomposeLayout-> layout()->subcompose() mainWidth ZERO")
dependentContent(0)
}
)
val dependentPlaceables: List<Placeable> = dependentMeasurables.map {
it.measure(constraints)
}
maxWidth = maxWidth.coerceAtLeast(
dependentPlaceables.maxOf { it.width }
)
subcompose(SlotsEnum.NEW) {
println("🍒 SubcomposeLayout-> layout()->subcompose() maxWidth: $maxWidth")
dependentContent(maxWidth)
}
mainPlaceables.forEach { it.placeRelative(0, 0) }
dependentPlaceables.forEach { it.placeRelative(0, 150) }
}
}
}
Why cannot remeasure same component second time with same id? When i try to call subCompose with SlotsEnum.Dependent it throws an exception
subcompose(SlotsEnum.NEW) {
println("🍒 SubcomposeLayout-> layout()->subcompose() maxWidth: $maxWidth")
dependentContent(maxWidth)
}
Still not remeasuring correctly after calling it? How can setting sibling can be solved with SubcomposeLayout?
I made a sample based on the sample provided by official documents and #chuckj's answer here.
Orange and pink containers are Columns, which direct children of DynamicWidthLayout, that uses SubcomposeLayout to remeasure.
#Composable
private fun DynamicWidthLayout(
modifier: Modifier = Modifier,
mainContent: #Composable () -> Unit,
dependentContent: #Composable (IntSize) -> Unit
) {
SubcomposeLayout(modifier = modifier) { constraints ->
var mainPlaceables: List<Placeable> = subcompose(SlotsEnum.Main, mainContent).map {
it.measure(constraints)
}
var maxSize =
mainPlaceables.fold(IntSize.Zero) { currentMax: IntSize, placeable: Placeable ->
IntSize(
width = maxOf(currentMax.width, placeable.width),
height = maxOf(currentMax.height, placeable.height)
)
}
val dependentMeasurables: List<Measurable> = subcompose(SlotsEnum.Dependent) {
// 🔥🔥 Send maxSize of mainComponent to
// dependent composable in case it might be used
dependentContent(maxSize)
}
val dependentPlaceables: List<Placeable> = dependentMeasurables
.map { measurable: Measurable ->
measurable.measure(Constraints(maxSize.width, constraints.maxWidth))
}
// Get maximum width of dependent composable
val maxWidth = dependentPlaceables.maxOf { it.width }
println("🔥 DynamicWidthLayout-> maxSize width: ${maxSize.width}, height: ${maxSize.height}")
// If width of dependent composable is longer than main one, remeasure main one
// with dependent composable's width using it as minimumWidthConstraint
if (maxWidth > maxSize.width) {
println("🚀 DynamicWidthLayout REMEASURE MAIN COMPONENT")
// !!! 🔥🤔 CANNOT use SlotsEnum.Main here why?
mainPlaceables = subcompose(2, mainContent).map {
it.measure(Constraints(maxWidth, constraints.maxWidth))
}
}
// Our final maxSize is longest width and total height of main and dependent composables
maxSize = IntSize(
maxSize.width.coerceAtLeast(maxWidth),
maxSize.height + dependentPlaceables.maxOf { it.height }
)
layout(maxSize.width, maxSize.height) {
// Place layouts
mainPlaceables.forEach { it.placeRelative(0, 0) }
dependentPlaceables.forEach {
it.placeRelative(0, mainPlaceables.maxOf { it.height })
}
}
}
}
enum class SlotsEnum { Main, Dependent }
Usage
#Composable
private fun TutorialContent() {
val density = LocalDensity.current.density
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
var mainText by remember { mutableStateOf(TextFieldValue("Main Component")) }
var dependentText by remember { mutableStateOf(TextFieldValue("Dependent Component")) }
OutlinedTextField(
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth(),
value = mainText,
label = { Text("Main") },
placeholder = { Text("Set text to change main width") },
onValueChange = { newValue: TextFieldValue ->
mainText = newValue
}
)
OutlinedTextField(
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth(),
value = dependentText,
label = { Text("Dependent") },
placeholder = { Text("Set text to change dependent width") },
onValueChange = { newValue ->
dependentText = newValue
}
)
DynamicWidthLayout(
modifier = Modifier
.padding(8.dp)
.background(Color.LightGray)
.padding(8.dp),
mainContent = {
println("🍏 DynamicWidthLayout-> MainContent {} composed")
Column(
modifier = Modifier
.background(orange400)
.padding(4.dp)
) {
Text(
text = mainText.text,
modifier = Modifier
.background(blue400)
.height(40.dp),
color = Color.White
)
}
},
dependentContent = { size: IntSize ->
// 🔥 Measure max width of main component in dp retrieved
// by subCompose of dependent component from IntSize
val maxWidth = with(density) {
size.width / this
}.dp
println(
"🍎 DynamicWidthLayout-> DependentContent composed " +
"Dependent size: $size, "
+ "maxWidth: $maxWidth"
)
Column(
modifier = Modifier
.background(pink400)
.padding(4.dp)
) {
Text(
text = dependentText.text,
modifier = Modifier
.background(green400),
color = Color.White
)
}
}
)
}
}
And full source code is here.

How to achieve a staggered grid layout using Jetpack compose?

As far as I can see we can only use Rows and Columns in Jetpack Compose to show lists. How can I achieve a staggered grid layout like the image below? The normal implementation of it using a Recyclerview and a staggered grid layout manager is pretty easy. But how to do the same in Jetpack Compose ?
One of Google's Compose sample Owl shows how to do a staggered grid layout. This is the code snippet that is used to compose this:
#Composable
fun StaggeredVerticalGrid(
modifier: Modifier = Modifier,
maxColumnWidth: Dp,
children: #Composable () -> Unit
) {
Layout(
children = children,
modifier = modifier
) { measurables, constraints ->
check(constraints.hasBoundedWidth) {
"Unbounded width not supported"
}
val columns = ceil(constraints.maxWidth / maxColumnWidth.toPx()).toInt()
val columnWidth = constraints.maxWidth / columns
val itemConstraints = constraints.copy(maxWidth = columnWidth)
val colHeights = IntArray(columns) { 0 } // track each column's height
val placeables = measurables.map { measurable ->
val column = shortestColumn(colHeights)
val placeable = measurable.measure(itemConstraints)
colHeights[column] += placeable.height
placeable
}
val height = colHeights.maxOrNull()?.coerceIn(constraints.minHeight, constraints.maxHeight)
?: constraints.minHeight
layout(
width = constraints.maxWidth,
height = height
) {
val colY = IntArray(columns) { 0 }
placeables.forEach { placeable ->
val column = shortestColumn(colY)
placeable.place(
x = columnWidth * column,
y = colY[column]
)
colY[column] += placeable.height
}
}
}
}
private fun shortestColumn(colHeights: IntArray): Int {
var minHeight = Int.MAX_VALUE
var column = 0
colHeights.forEachIndexed { index, height ->
if (height < minHeight) {
minHeight = height
column = index
}
}
return column
}
And then you can pass in your item composable in it:
StaggeredVerticalGrid(
maxColumnWidth = 220.dp,
modifier = Modifier.padding(4.dp)
) {
// Use your item composable here
}
Link to snippet in the sample: https://github.com/android/compose-samples/blob/1630f6b35ac9e25fb3cd3a64208d7c9afaaaedc5/Owl/app/src/main/java/com/example/owl/ui/courses/FeaturedCourses.kt#L161
Your layout is a scrollable layout with rows of multiple cards (2 or 4)
The row with 2 items :
#Composable
fun GridRow2Elements(row: RowData) {
Row(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
GridCard(row.datas[0], small = true, endPadding = 0.dp)
GridCard(row.datas[1], small = true, startPadding = 0.dp)
}
}
The row with 4 items :
#Composable
fun GridRow4Elements(row: RowData) {
Row(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Column {
GridCard(row.datas[0], small = true, endPadding = 0.dp)
GridCard(row.datas[1], small = false, endPadding = 0.dp)
}
Column {
GridCard(row.datas[2], small = false, startPadding = 0.dp)
GridCard(row.datas[3], small = true, startPadding = 0.dp)
}
}
}
The final grid layout :
#Composable
fun Grid(rows: List<RowData>) {
ScrollableColumn(modifier = Modifier.fillMaxWidth()) {
rows.mapIndexed { index, rowData ->
if (rowData.datas.size == 2) {
GridRow2Elements(rowData)
} else if (rowData.datas.size == 4) {
GridRow4Elements(rowData)
}
}
}
Then, you can customize with the card layout you want . I set static values for small and large cards (120, 270 for height and 170 for width)
#Composable
fun GridCard(
item: Item,
small: Boolean,
startPadding: Dp = 8.dp,
endPadding: Dp = 8.dp,
) {
Card(
modifier = Modifier.preferredWidth(170.dp)
.preferredHeight(if (small) 120.dp else 270.dp)
.padding(start = startPadding, end = endPadding, top = 8.dp, bottom = 8.dp)
) {
...
}
I transformed the datas in :
data class RowData(val datas: List<Item>)
data class Item(val text: String, val imgRes: Int)
You simply have to call it with
val listOf2Elements = RowData(
listOf(
Item("Zesty Chicken", xx),
Item("Spring Rolls", xx),
)
)
val listOf4Elements = RowData(
listOf(
Item("Apple Pie", xx),
Item("Hot Dogs", xx),
Item("Burger", xx),
Item("Pizza", xx),
)
)
Grid(listOf(listOf2Elements, listOf4Elements))
Sure you need to manage carefully your data transformation because you can have an ArrayIndexOutOfBoundsException with data[index]
It's now available in version 1.3.0-beta02. You can implement it like this:
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
) {
itemsIndexed((0..50).toList()) { i, item ->
Box(
Modifier
.padding(2.dp)
.fillMaxWidth()
.height(20.dp * i)
.background(Color.Cyan),
)
}
}
Or you can use horizontal view LazyHorizontalStaggeredGrid
Starting from 1.3.0-beta02 you can use the LazyVerticalStaggeredGrid.
Something like:
val state = rememberLazyStaggeredGridState()
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
state = state,
content = {
items(count) {
//item content
}
}
)
This library will help you LazyStaggeredGrid
Usage:
LazyStaggeredGrid(cells = StaggeredCells.Adaptive(minSize = 180.dp)) {
items(60) {
val randomHeight: Double = 100 + Math.random() * (500 - 100)
Image(
painter = painterResource(id = R.drawable.image),
contentDescription = null,
modifier = Modifier.height(randomHeight.dp).padding(10.dp),
contentScale = ContentScale.Crop
)
}
}
Result:
Better to use LazyVerticalStaggeredGrid
Follow this steps
Step 1 Add the below dependency in your build.gradle file
implementation "androidx.compose.foundation:foundation:1.3.0-rc01"
Step 2 import the below classes in your activity file
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
Step 3 Add LazyVerticalStaggeredGrid like this
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
state = state,
modifier = Modifier.fillMaxSize(),
content = {
val list = listOf(1,2,4,3,5,6,8,8,9)
items(list.size) { position ->
Box(
Modifier.padding(5.dp)
) {
// create your own layout here
NotesItem(list[position])
}
}
})
OUTPUT
I wrote custom staggered column
feel free to use it:
#Composable
fun StaggerdGridColumn(
modifier: Modifier = Modifier,
columns: Int = 3,
content: #Composable () -> Unit,
) {
Layout(content = content, modifier = modifier) { measurables, constraints ->
val columnWidths = IntArray(columns) { 0 }
val columnHeights = IntArray(columns) { 0 }
val placables = measurables.mapIndexed { index, measurable ->
val placable = measurable.measure(constraints)
val col = index % columns
columnHeights[col] += placable.height
columnWidths[col] = max(columnWidths[col], placable.width)
placable
}
val height = columnHeights.maxOrNull()
?.coerceIn(constraints.minHeight.rangeTo(constraints.maxHeight))
?: constraints.minHeight
val width =
columnWidths.sumOf { it }.coerceIn(constraints.minWidth.rangeTo(constraints.maxWidth))
val colX = IntArray(columns) { 0 }
for (i in 1 until columns) {
colX[i] = colX[i - 1] + columnWidths[i - 1]
}
layout(width, height) {
val colY = IntArray(columns) { 0 }
placables.forEachIndexed { index, placeable ->
val col = index % columns
placeable.placeRelative(
x = colX[col],
y = colY[col]
)
colY[col] += placeable.height
}
}
}
}
Using side:
Surface(color = MaterialTheme.colors.background) {
val size = remember {
mutableStateOf(IntSize.Zero)
}
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.onGloballyPositioned {
size.value = it.size
},
contentAlignment = Alignment.TopCenter
) {
val columns = 3
StaggerdGridColumn(
columns = columns
) {
topics.forEach {
Chip(
text = it,
modifier = Modifier
.width(with(LocalDensity.current) { (size.value.width / columns).toDp() })
.padding(8.dp),
)
}
}
}
}
#Composable
fun Chip(modifier: Modifier = Modifier, text: String) {
Card(
modifier = modifier,
border = BorderStroke(color = Color.Black, width = 1.dp),
shape = RoundedCornerShape(8.dp),
elevation = 10.dp
) {
Column(
modifier = Modifier.padding(start = 8.dp, top = 4.dp, end = 8.dp, bottom = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(16.dp, 16.dp)
.background(color = MaterialTheme.colors.secondary)
)
Spacer(Modifier.height(4.dp))
Text(
text = text,
style = TextStyle(color = Color.DarkGray, textAlign = TextAlign.Center)
)
}
}
}
Really saved a lot of time thanks guys(author of answers). I tried all 3 ways.
This is not an answer rather an observation. For me order of items were not maintained for answer#11. For sample list it did , but with actual list in office work it did not. ordering was altered by one position. I tried even with array list, input list were ordered but views were displaced still.
However, answer#22 did maintained order. And works correctly. I am using this one.
answer#33 did worked as expected as both columns have their individual and independent scroll behaviour
Note: Pagination is still not supported in any of the custom implementation. Manual observation on last item is required to trigger fetching new data. (we can't use pager from pager library, there's no way to make call on pager obj. However, there is manual paging in 'start' code of advance paging codelab (manual paging works there in sample)) https://developer.android.com/codelabs/android-paging#0
Cheers folks.!!
UPDATE with working answer
Please go thorough Android jetpack compose pagination : Pagination not working with staggered layout jetpack compose , Where I have working sample of staggered layout in compose and also with supporting pagination.
Solution : https://github.com/rishikumr/stackoverflow_code_sharing/tree/main/staggered-layout-compose-with_manual_pagination
Working video : https://drive.google.com/file/d/1IsKy0wzbyqI3dme3x7rzrZ6uHZZE9jrL/view?usp=sharing

Categories

Resources