Itemdecoration in Jetpack compose - android

I am currently in the process of evaluating whether or not we can migrate our rather complex UI to jetpack compose at this stage and I am struggling with the following problem.
I am having an infinite scrolling vertical List of various different conceptual components. Some of them are headers, then there can be some text, some horizontally scrolling (infinite) lists and then there are some grouped components that are also stacked vertically but conceptionally belong to a group.
#Compose
fun MyComplexList() {
LazyColumn {
item {
// some header
}
item {
// some horizontal content
LazyRow {
item {}
}
}
item {
// some other header
}
items(x) {
// Some text for each item
}
}
}
As one can see this thing is rather trivial to do using compose and a lot less code than writing this complex RecyclerView + Adapter...
with one exception: that background gradient, spanning (grouping) the Some infinite list of things component. (the tilted gradient in the image)
In the past (:D) I would use an ItemDecoration on the RecyclerView to draw something across multiple items, but I can't find anything similar to that in Compose.
Does anyone have any idea on how one would achieve this with compose?

After your answer, this is what I understood...
#Composable
fun ListWithGradientBgScreen() {
val lazyListState = rememberLazyListState()
val firstVisibleIndex by remember {
derivedStateOf {
lazyListState.firstVisibleItemIndex
}
}
val totalVisibleItems by remember {
derivedStateOf {
lazyListState.layoutInfo.visibleItemsInfo.size
}
}
val itemsCount = 50
BoxWithConstraints(Modifier.fillMaxSize()) {
ListBg(firstVisibleIndex, totalVisibleItems, itemsCount, maxHeight)
LazyColumn(state = lazyListState, modifier = Modifier.fillMaxSize()) {
item {
Column(
Modifier
.fillMaxWidth()
.background(Color.White)
) {
Text(
text = "Some header",
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(16.dp)
)
}
}
item {
Text(
text = "Some infinite list of things",
style = MaterialTheme.typography.h5,
modifier = Modifier.padding(16.dp)
)
}
items(itemsCount) {
Text(
text = "Item $it",
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.background(Color.LightGray)
.padding(8.dp)
)
}
}
}
}
and to change the background in according to the background, you can define something like the following:
#Composable
private fun ListBg(
firstVisibleIndex: Int,
totalVisibleItems: Int,
itemsCount: Int,
maxHeight: Dp
) {
val hasNoScroll = itemsCount <= totalVisibleItems
val totalHeight = if (hasNoScroll) maxHeight else maxHeight * 3
val scrollableBgHeight = if (hasNoScroll) maxHeight else totalHeight - maxHeight
val scrollStep = scrollableBgHeight / (itemsCount + 2 - totalVisibleItems)
val yOffset = if (hasNoScroll) 0.dp else -(scrollStep * firstVisibleIndex)
Box(
Modifier
.wrapContentHeight(unbounded = true, align = Alignment.Top)
.background(Color.Yellow)
.offset { IntOffset(x = 0, y = yOffset.roundToPx()) }
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(totalHeight)
.drawBehind {
drawRoundRect(
Brush.linearGradient(
0f to Color.Red,
0.6f to Color.DarkGray,
1.0f to Color.Green,
),
)
}
)
}
}
Here is the result:

One of the options:
Replace:
items(x) {
// Some text for each item
}
with:
item {
Column(modifier = Modifier.border(...).background(...)) { //Shape, color etc...
x.forEach {
// Some text for each item
}
}
}

Related

scroll lags when trying to listen to scroll position in android jetpack compose

I'm using Jetpack compose in my project. I have a scrollable column. I want to show a column as the top bar when the user scrolls the screen. For this purpose, I listen to the state of the scroll in this way:
val scrollState = rememberScrollState()
Box {
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(scrollState)
) {
...
...
...
}
TopBar(scrollOffset = (scrollState.value * 0.1))
}
and the TopBar is another composable:
#Composable
fun HiddenTopBar(scrollOffset: Double, onSearchListener: () -> Unit) {
val offset = if (-50 + scrollOffset < 0) (-50 + scrollOffset).dp else 0.dp
Box(
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.offset(y = offset)
.background(MaterialTheme.colors.secondary)
.padding(vertical = MaterialTheme.space.small)
) {
...
...
...
}
}
The problem is that due to constant recomposition, the scroll lags, and it is not smooth. Is there any way I can implement it more efficiently?
Yes, it's because of constant recomposition in performance documentation.
If you were checking a state derived from scroll state such as if it's scrolled you could go for derivedState but you need it on each change, nestedScrollConnection might help i guess.
This sample might help you how to implement it
#Composable
private fun NestedScrollExample() {
val density = LocalDensity.current
val statusBarTop = WindowInsets.statusBars.getTop(density)
val toolbarHeight = 100.dp
val toolbarHeightPx = with(LocalDensity.current) { toolbarHeight.roundToPx().toFloat() }
// our offset to collapse toolbar
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value =
newOffset.coerceIn(-(2 * statusBarTop + toolbarHeightPx), 0f)
return Offset.Zero
}
}
}
Box(
Modifier
.fillMaxSize()
// attach as a parent to the nested scroll system
.nestedScroll(nestedScrollConnection)
) {
Column(
modifier = Modifier
.padding(
PaddingValues(
top = toolbarHeight + 8.dp,
start = 8.dp,
end = 8.dp,
bottom = 8.dp
)
)
.verticalScroll(rememberScrollState())
,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(modifier = Modifier
.fillMaxWidth()
.height(2000.dp))
}
TopAppBar(modifier = Modifier
.height(toolbarHeight)
.offset { IntOffset(x = 0, y = toolbarOffsetHeightPx.value.roundToInt()) },
elevation = 2.dp,
backgroundColor = Color.White,
title = { Text("toolbar offset is ${toolbarOffsetHeightPx.value}") })
}
}

Jetpack Compose: Detect child item overflow and add more badge

Goal
I want to fit as many items as possible to available space and add "+{num}" badge to the end to indicate if there are more items left. Something like below image.
Problem
Since number and size of the items (i.e chips in this case) and also available space are not known beforehand it is difficult to know exactly how many chips would fit. Additionally, compose measures children only once and lays out right away.
What I've tried
I tried the following approach but it is not quite there yet.
#Composable
fun MainScreen() {
Column {
val states = arrayOf(
"NY", "CA", "NV", "PA", "AZ", "AK", "NE", "CT", "CO", "FL", "IL", "KS", "WA"
)
var chipCount by remember { mutableStateOf(0) }
Row(
modifier = Modifier
.padding(horizontal = 16.dp)
.wrapContentHeight(),
verticalAlignment = Alignment.CenterVertically
) {
ChipRow(
modifier = Modifier
.padding(end = 4.dp)
.weight(1f, fill = false),
onPlacementComplete = { chipCount = it }
) {
for (state in states) {
Chip(
modifier = Modifier
.padding(4.dp)
.wrapContentSize(), text = state
)
}
}
Text(text = "+${states.size - chipCount}", style = MaterialTheme.typography.h6)
}
}
}
#Composable
fun Chip(
text: String,
modifier: Modifier = Modifier
) {
Surface(
color = Color.LightGray,
shape = CircleShape,
modifier = modifier
) {
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.body1,
modifier = Modifier.padding(6.dp)
)
}
}
#Composable
fun ChipRow(
modifier: Modifier = Modifier,
onPlacementComplete: (Int) -> Unit,
content: #Composable () -> Unit
) {
Layout(
modifier = modifier,
content = content
) { measurables, constraints ->
val placeables = measurables.map { it.measure(constraints) };
var counter = 0
layout(constraints.maxWidth, constraints.maxHeight) {
var xPosition = 0
for (placeable in placeables) {
if (xPosition + placeable.width > constraints.maxWidth) break
placeable.placeRelative(x = xPosition, 0)
xPosition += placeable.width
counter++
}
onPlacementComplete(counter)
}
}
}
First of all it looks hacky rather than a legit solution. Secondly, it doesn't work as intended. The output is like below.
Request
I went through official guides for custom layouts and did many google searches. But couldn't come up with anything better. Could you help with this situation? Reference links, pointing errors, etc., anything is appreciated.
Thanks in advance!
You've passed your max constraint values (layout(constraints.maxWidth, constraints.maxHeight)), that's why your view took whole screen.
You need to pass the needed view size instead, for example like this:
data class Item(val placeable: Placeable, val xPosition: Int)
val items = mutableListOf<Item>()
var xPosition = 0
for (placeable in placeables) {
if (xPosition + placeable.width > constraints.maxWidth) break
items.add(Item(placeable, xPosition))
xPosition += placeable.width
}
layout(
width = items.last().let { it.xPosition + it.placeable.width },
height = items.maxOf { it.placeable.height }
) {
items.forEach {
it.placeable.place(it.xPosition, 0)
}
onPlacementComplete(items.count())
}
Result:
i am not too sure either but i think you need to state the column and row width first to make it centervertically.
and i dont think u need 1f in weight, instead 0.8f would be better i believe.
Column (
modifier = Modifier.fillMaxWidth()
){
val states = arrayOf(
"NY", "CA", "NV", "PA", "AZ", "AK", "NE", "CT", "CO", "FL", "IL", "KS", "WA"
)
var chipCount by remember { mutableStateOf(0) }
Row(
modifier = Modifier
.padding(horizontal = 16.dp)
.wrapContentHeight()
.modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
ChipRow(
modifier = Modifier
.padding(end = 4.dp)
.weight(0.8f, fill = false),
onPlacementComplete = { chipCount = it }
) {
for (state in states) {
Chip(
modifier = Modifier
.padding(4.dp)
.wrapContentSize(), text = state
)
}
}
Text(text = "+${states.size - chipCount}", style = MaterialTheme.typography.h6)
}
}
or i would prefer you use constraintlayout instead. here

Scroll Multiple LazyRows together - LazyHorizontalGrid alike?

How to assign the same scroll state to two LazyRows, so that both row scrolls together?
Jetpack compose lists currently doesn't have LazyHorizontalGrid, So any alternative solution?
Column{
LazyRow(
modifier = Modifier.fillMaxWidth()
) {
// My sublist1
}
LazyRow(
modifier = Modifier.fillMaxWidth()
) {
// My sublist2
}
}
Trying to implement below:
Update: Google has added the component officially - LazyHorizontalGrid.
I modified the LazyVerticalGrid class, and made it work towards only GridCells.Fixed(n) horizontal grid.
Here is the complete gist code: LazyHorizontalGrid.kt
Main changes
#Composable
#ExperimentalFoundationApi
private fun FixedLazyGrid(
nRows: Int,
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
scope: LazyGridScopeImpl
) {
val columns = (scope.totalSize + nRows - 1) / nRows
LazyRow(
modifier = modifier,
state = state,
contentPadding = contentPadding,
) {
items(columns) { columnIndex ->
Column {
for (rowIndex in 0 until nRows) {
val itemIndex = columnIndex * nRows + rowIndex
if (itemIndex < scope.totalSize) {
Box(
modifier = Modifier.wrapContentSize(),
propagateMinConstraints = true
) {
scope.contentFor(itemIndex, this#items).invoke()
}
} else {
Spacer(Modifier.weight(1f, fill = true))
}
}
}
}
}
}
Code Usage
LazyHorizontalGrid(
cells = GridCells.Fixed(2)
) {
items(items = restaurantsList){
RestaurantItem(r = it, modifier = Modifier.fillParentMaxWidth(0.8f))
}
}

How to detect up/down scroll for a Column with vertical scroll?

I have a column which has many items; based on the scroll, I want to show/hide the Floating action button, in case the scroll is down, hide it and in case the scroll is up, show it.
My code is working partially, but the scrolling is buggy. Below is the code. Need help.
Column(
Modifier
.background(color = colorResource(id = R.color.background_color))
.fillMaxWidth(1f)
.verticalScroll(scrollState)
.scrollable(
orientation = Orientation.Vertical,
state = rememberScrollableState {
offset.value = it
coroutineScope.launch {
scrollState.scrollBy(-it)
}
it
},
)
) { // 10-20 items }
Based on the offset value (whether positive/negative), I am maintaining the visibility of FAB.
You can use the nestedScroll modifier.
Something like:
val fabHeight = 72.dp //FabSize+Padding
val fabHeightPx = with(LocalDensity.current) { fabHeight.roundToPx().toFloat() }
val fabOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = fabOffsetHeightPx.value + delta
fabOffsetHeightPx.value = newOffset.coerceIn(-fabHeightPx, 0f)
return Offset.Zero
}
}
}
Since composable supports nested scrolling just apply it to the Scaffold:
Scaffold(
Modifier.nestedScroll(nestedScrollConnection),
scaffoldState = scaffoldState,
//..
floatingActionButton = {
FloatingActionButton(
modifier = Modifier
.offset { IntOffset(x = 0, y = -fabOffsetHeightPx.value.roundToInt()) },
onClick = {}
) {
Icon(Icons.Filled.Add,"")
}
},
content = { innerPadding ->
Column(
Modifier
.fillMaxWidth(1f)
.verticalScroll(rememberScrollState())
) {
//....your code
}
}
)
It can work with a Column with verticalScroll and also with a LazyColumn.

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