Jetpack Compose: nested LazyColumn / LazyRow - android

I've read through similar topics but I couldn't find satisfactory result:
What is the equivalent of NestedScrollView + RecyclerView or Nested RecyclerView (Recycler inside another recycler) in Jetpack compose
Jetpack Compose: How to put a LazyVerticalGrid inside a scrollable Column?
Use lazyColum inside the column has an error in the Jetpack Compose
Nested LazyVerticalGrid with Jetpack Compose
My use-case is: to create a comments' list (hundreds of items) with possibility to show replies to each comment (hundreds of items for each item).
Currently it's not possible to do a nested LazyColumn inside another LazyColumn because Compose will throw an exception:
java.lang.IllegalStateException: Vertically scrollable component was
measured with an infinity maximum height constraints, which is
disallowed. One of the common reasons is nesting layouts like
LazyColumn and Column(Modifier.verticalScroll()). If you want to add a
header before the list of items please add a header as a separate
item() before the main items() inside the LazyColumn scope. There are
could be other reasons for this to happen: your ComposeView was added
into a LinearLayout with some weight, you applied
Modifier.wrapContentSize(unbounded = true) or wrote a custom layout.
Please try to remove the source of infinite constraints in the
hierarchy above the scrolling container.
The solutions provided by links above (and others that came to my mind) are:
Using fixed height for internal LazyColumn - I cannot use it as each item can have different heights (for example: single vs multiline comment).
Using normal Columns (not lazy) inside LazyColumn - performance-wise it's inferior to lazy ones, when using Android Studio's Profiler and list of 500 elements, normal Column would use 350MB of RAM in my app comparing to 220-240MB using lazy Composables. So it will not recycle properly.
Using FlowColumn from Accompanist - I don't see any performance difference between this one and normal Column so see above.
Flatten the list's data source (show both comments and replies as "main" comments and only make UI changes to distinguish between them) - this is what I was currently using but when I was adding more complexity to this feature it prevents some of new feature requests to be implemented.
Disable internal LazyColumn's scrolling using newly added in Compose 1.2.0 userScrollEnabled parameter - unfortunately it throws the same error and it's an intended behaviour (see here).
Using other ways to block scrolling (also to block it programatically) - same error.
Using other LazyColumn's .height() parameters like wrapContentHeight() or using IntrinsicSize.Min - same error.
Any other ideas how to solve this? Especially considering that's doable to nest lazy components in Apple's SwiftUI without constraining heights.

I had a similar use case and I have a solution with a single LazyColumn that works quite well and performant for me, the idea is to treat your data as a large LazyColumn with different types of elements.
Because comment replies are now separate list items you have to first flatten your data so that it's a large list or multiple lists.
Now for sub-comments you just add some padding in front but otherwise they appear as separate lazy items.
I also used a LazyVerticalGrid instead of LazyColumn because I've had to show a grid of gallery pictures at the end, you may not need that, but if you do, you have to use span option everywhere else as shown below.
You'll have something like this:
LazyVerticalGrid(
modifier = Modifier
.padding(6.dp),
columns = GridCells.Fixed(3)
) {
item(span = { GridItemSpan(3) }) {
ComposableTitle()
}
items(
items = flattenedCommentList,
key = { it.commentId },
span = { GridItemSpan(3) }) { comment ->
ShowCommentComposable(comment)
//here subcomments will have extra padding in front
}
item(span = { GridItemSpan(3) }) {
ComposableGalleryTitle()
}
items(items = imageGalleryList,
key = { it.imageId }) { image ->
ShowImageInsideGrid(image) //images in 3 column grid
}
}

I sloved this problem in this function
#Composable
fun NestedLazyList(
modifier: Modifier = Modifier,
outerState: LazyListState = rememberLazyListState(),
innerState: LazyListState = rememberLazyListState(),
outerContent: LazyListScope.() -> Unit,
innerContent: LazyListScope.() -> Unit,
) {
val scope = rememberCoroutineScope()
val innerFirstVisibleItemIndex by remember {
derivedStateOf {
innerState.firstVisibleItemIndex
}
}
SideEffect {
if (outerState.layoutInfo.visibleItemsInfo.size == 2 && innerState.layoutInfo.totalItemsCount == 0)
scope.launch { outerState.scrollToItem(outerState.layoutInfo.totalItemsCount) }
println("outer ${outerState.layoutInfo.visibleItemsInfo.map { it.index }}")
println("inner ${innerState.layoutInfo.visibleItemsInfo.map { it.index }}")
}
BoxWithConstraints(
modifier = modifier
.scrollable(
state = rememberScrollableState {
scope.launch {
val toDown = it <= 0
if (toDown) {
if (outerState.run { firstVisibleItemIndex == layoutInfo.totalItemsCount - 1 }) {
Log.i(TAG, "NestedLazyList: down inner")
innerState.scrollBy(-it)
} else {
Log.i(TAG, "NestedLazyList: down outer")
outerState.scrollBy(-it)
}
} else {
if (innerFirstVisibleItemIndex == 0 && innerState.firstVisibleItemScrollOffset == 0) {
Log.i(TAG, "NestedLazyList: up outer")
outerState.scrollBy(-it)
} else {
Log.i(TAG, "NestedLazyList: up inner")
innerState.scrollBy(-it)
}
}
}
it
},
Orientation.Vertical,
)
) {
LazyColumn(
userScrollEnabled = false,
state = outerState,
modifier = Modifier
.heightIn(maxHeight)
) {
outerContent()
item {
LazyColumn(
state = innerState,
userScrollEnabled = false,
modifier = Modifier
.height(maxHeight)
) {
innerContent()
}
}
}
}
}
All what I did is that:
At first I set the height of the inner lazyList to the height of the parent view using BoxWithConstraints, this lets the inner list fill the screen without distroying the lazy concept.
Then I controlled the scrolling by disable lazy scroll and make the parent scrollable to determine when the scroll affect the parent list and when the child should scroll.
bud this still has some bugs when the parent size changed , in my case I escaped by this SideEffect

You can use rememberScrollState() for root column. Like this;
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())) {
LazyColumn {
// your code
}
LazyRow {
// your code
}
}

Related

Compose - DropDownMenu is causing unwanted recomposition

This is the composable hierarchy in my app:
HorizontalPager
↪LazyVerticalGrid
↪PostItem
↪AsyncImage
DropdownMenu
↪DropdownMenuItem
When swiping the HorizontalPager the AsyncImage inside my PostItem's are recomposing.
Removing the DropdownMenu fixes this and the PostItem is no longe recomposing and gives the wanted behavior.
The problem is that I have huge FPS drops when swiping through the HorizontalPager.
Why is DropdownMenu causing a recomposition when swiping the HorizontalPager?
var showMenu by remember { mutableStateOf(false) }
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }) {
DropdownMenuItem(onClick = {
}) {
Text(text = "Share")
}
}
Unfortunately, without seeing more code showing the rest of the structure, it's hard to say for sure what your problem is here.
A likely answer is that you haven't split up your Composable function enough. Something the docs hardly talk about is that the content portion of a lot of the built-in Composeables are inline functions which means that if the content recomposes the parent will as well. This is the most simple example of this I can give.
#Composable
fun foo() {
println("recompose function")
Box {
println("recompose box")
Column {
println("recompose column")
Row {
println("recompose row")
var testState by mutableStateOf("my text")
Button(
onClick = { testState = "new text" }
) {}
Text(testState)
}
}
}
}
output is:
recompose function
recompose box
recompose column
recompose row
Not only does this recompose the whole function it also recreates the testState causing it to never change values.
Again not 100% that this is your problem, but I would look into it. The solution would be to split my Row and row content into it's own Composable function.

Jetpack Compose: Mimicking spinner.setSelection() inside of a DropDownMenu

The use case is that you have 10s or 100s of items inside of a dropdown menu, the dropdown options have some ordering - as with number values or alphabetical listing of words - and selections are made in succession.
When the user reopens the menu, you'd like for it to open in the same region as their last selection, so that for instance you don't jump from "car" to "apple" but rather from "car" to "cat". Or if they just opted to view order number 358, they can quickly view order number 359.
Using views, you could create a Spinner and put all of your items in an ArrayAdapter and then call spinner.setSelection() to scroll directly to the index you want.
DropdownMenu doesn't have anything like HorizontalPager's scrollToPage(). So what solutions might exist to achieve this?
So far, I've tried adding verticalScroll() to the DropdownMenu's modifier and trying to do arithmetic with the scrollState. But it crashes at runtime with an error saying the component has infinite height, the same error you get if you try to nest scrollable components like a LazyColumn inside of a Column with verticalScroll.
It's a known issue.
DropdownMenu has its own vertical scroll modifier inside, and there is no API to work with it.
Until this problem is fixed by providing a suitable API, the only workaround I can think of is to create your own view - you can take the source code of DropdownMenu as reference.
I'll post a more detailed answer here because I don't want to mislead anyone with my comment above.
If you're in Android Studio, click the three dots on the mouse-hover quick documentation box and select "edit source" to open the source for DropdownMenu in AndroidMenu.android.kt. Then observe that it uses a composable called DropdownMenuItemContent. Edit source again and you're in Menu.kt.
You'll see this:
#Composable
internal fun DropdownMenuContent(
...
...
...
{
Column(
modifier = modifier
.padding(vertical = DropdownMenuVerticalPadding)
.width(IntrinsicSize.Max)
.verticalScroll(rememberScrollState()),//<-We want this
content = content
)
}
So in your custom composable just replace that rememberScrollState() with your favorite variable name for a ScrollState.
And then chain that reference all the way back up to your original view.
Getting Access to the ScrollState
#Composable
fun MyCustomDropdownMenu(
expanded:Boolean,
scrollStateProvidedByTopParent:ScrollState,
...
...
)
{...}
#Composable
fun MyCustomDropdownMenuContent(
scrollStateProvidedByTopParent:ScrollState,
...
...
)
{...}
//And now for your actual content
#Composable
fun TopParent(){
val scrollStateProvidedByTopParent=rememberScrollState()
val spinnerExpanded by remember{mutableStateOf(false)}
...
...
Box{
Row(modifier=Modifier.clickable(onClick={spinnerExpanded=!spinnerExpanded}))//<-More about this line in the sequel
{
Text("Options")
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "")
MyCustomDropdownMenu(
expanded = spinnerExpanded,
scrollStateProvidedByTopParent=scrollStateProvidedByTopParent,
onDismissRequest = { spinnerExpanded = false })
{//your spinner content}
}
}
}
The above only specifies how to access the ScrollState of the DropdownMenu. But once you have the ScrollState, you'll have to do some arithmetic to get the scroll position right when it opens. Here's one way that seems alright.
Calculating the scroll distance
Even after setting the contents of the menu items explicitly, the distance was never quite right if I relied on those values. So I used an onTextLayout callback inside the Text of my menu items in order to get the true Text height at the time of rendering. Then I use that value for the arithmetic. It looks like this:
#Composable
fun TopParent(){
val scrollStateProvidedByTopParent=rememberScrollState()
val spinnerExpanded by remember{mutableStateOf(false)}
val chosenText:String by remember{mutableStateOf(myListOfSpinnerOptions[0])
val height by remember{mutableStateOf(0)}
val heightHasBeenChecked by remember{mutableStateOf(false)}
val coroutineScope=rememberCoroutineScope()
...
...
Box{
Row(modifier=Modifier.clickable(onClick={spinnerExpanded=!spinnerExpanded
coroutineScope.launch{scrollStateProvidedByTopParent.scrollTo(height*myListOfSpinnerOptions.indexOf[chosenText])}}))//<-This gets some arithmetic for scrolling distance
{
Text("Options")
Icon(imageVector = Icons.Filled.ArrowDropDown, contentDescription = "")
MyCustomDropdownMenu(
expanded = spinnerExpanded,
scrollStateProvidedByTopParent=scrollStateProvidedByTopParent,
onDismissRequest = { spinnerExpanded = false }) {
myListOfSpinnerOptions.forEach{option->
DropdownMenuItem(onClick={
chosenText=option
spinnerExpanded=false
}){
Text(option,onTextLayout={layoutResult->
if (!heightHasBeenChecked){
height=layoutResults.size.height
heightHasBeenChecked=true
}
}
)
}
}
}
}
}

What is the equivalent of [NestedScrollView + RecyclerView] or [Nested RecyclerView (Recycler inside another recycler) in Jetpack compose

I want to create the following layout in Jetpack compose.
I've tried creating two lists inside a vertical scrollable Box but that's not possible as I got the this error:
"java.lang.IllegalStateException: Nesting scrollable in the same direction layouts like ScrollableContainer and LazyColumn is not allowed. If you want to add a header before the list of items please take a look on LazyColumn component which has a DSL api which allows to first add a header via item() function and then the list of items via items()."
I've tried creating two different lists inside a parent list by using the following code, but that doesn't work either.
#Composable
fun MainList() {
LazyColumn() {
item {
/* LazyRow code here */
}
item {
/* LazyColumn code here */
}
}
}
Now I'm clueless about what else could I try to achieve two lists (one vertical and one horizontal) on the same activity and keep the activity vertically scrollable too.
I think the best option, would be if the LazyVerticalGrid allows some sort of expand logic on each item, but looks like it's not supported yet (beta-03).
So I'm leaving here my solution using one single LazyColumn for the entire list and LazyRow for "My Books" section.
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
// My Books section
item {
Column(modifier = Modifier.fillMaxWidth()) {
Text("My Books")
LazyRow {
items(books) { item ->
// Each Item
}
}
}
}
// Whishlisted Books title
item {
Text("Whishlisted Books", style = MaterialTheme.typography.h4)
}
// Turning the list in a list of lists of two elements each
items(wishlisted.windowed(2, 2, true)) { item ->
Row {
// Draw item[0]
// Draw item[1]
}
}
}
Here is my gist with the full solution and the result is listed below.
You can do something like:
Column(Modifier.fillMaxWidth()) {
LazyRow() {
items(itemsList){
//.....
}
}
LazyColumn() {
items(itemsList2){
//..
}
}
}
or:
Column(Modifier.fillMaxWidth()) {
LazyRow() {
items(itemsList){
//....
}
}
LazyVerticalGrid(cells = GridCells.Fixed(2)) {
items(itemsList2.size){
//....
}
}
}
An alternative equivalent of nested RecyclerViews is nested LazyColumns, where the heights of the inner LazyColumns are specified or constant, and the inner LazyColumns are placed inside item {} blocks.
Unlike the accepted answer, this approach relies on the .height() modifier to avoid the "java.lang.IllegalStateException: Nesting scrollable in the same direction layouts like ScrollableContainer and LazyColumn is not allowed... " error. Also, this approach addresses the scenario of nested scrolling in the same direction.
Here is an example code and output.
#Composable
fun NestedLists() {
LazyColumn(Modifier.fillMaxSize().padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally) {
//Header for first inner list
item {
Text(text = "List of numbers:", style = MaterialTheme.typography.h5)
}
// First, scrollable, inner list
item {
// Note the important height modifier.
LazyColumn(Modifier.height(100.dp)){
val numbersList = (0 .. 400 step 4).toList()
itemsIndexed(numbersList) { index, multipleOf4 ->
Text(text = "$multipleOf4", style = TextStyle(fontSize = 22.sp, color = Color.Blue))
}
}
}
// Header for second inner list
item {
Text(text = "List of letters:", style = MaterialTheme.typography.h5)
}
// Second, scrollable, inner list
item {
// Note the important height modifier.
LazyColumn(Modifier.height(200.dp)) {
val lettersList = ('a' .. 'z').toList()
itemsIndexed(lettersList) { index, letter ->
Text(text = "$letter", style = TextStyle(color = Color.Blue, fontSize = 22.sp))
}
}
}
}
}

Does Compose ConstraintLayout provide same handling for INVISIBLE and GONE as view based ConstraintLayout?

Disclaimer: I know that Compose just entered alpha01 and thus I do not expect that every functionality
is available. However, layout and handling of specific layout cases is IMHO an important topic
that should be addressed early 😉.
The current view based ConstraintLayout provides some specific handling in case a child view is
marked as GONE, refer to the
ConstrainLayout documentation.
I checked the Compose ConstraintLayout documentation, the available modifiers and so on, however
did not find anything that points in this direction. I also could not find any hint regarding
INVISIBLE and how/if Compose ConstraintLayout handles it like the view based ConstraintLayout.
In general, the current view based layouts (LinearLayout for example) handle INVISIBLE and GONE in a
similar fashion:
if a view is in state INVISIBLE then the view is part of the layout with its sizes, just not
shown. The overall layout of other views does not change and they stay in their positions.
if a view is in state GONE its sizes a usually treated as 0 and the layout is recomputed and
changed, other views usually change their positions.
Here a simple Compose ConstraintLayout UI, just 4 buttons in a row, chained to have them nicely
spread.
// if dontShow is 0 then show all buttons, otherwise make the button with this number
// somehow INVISIBLE. This feature is not yet implemented.
#Composable
fun fourButtonsCL(dontShow: Int) {
ConstraintLayout(Modifier.fillMaxSize()) {
val (btn1, btn2, btn3, btn4) = createRefs()
TextButton(onClick = {}, Modifier.constrainAs(btn1) {}.background(teal200)) { Text("Button1") }
TextButton(onClick = {}, Modifier.constrainAs(btn2) {}.background(teal200)) { Text("Button2") }
TextButton(onClick = {}, Modifier.constrainAs(btn3) {}.background(teal200)) { Text("Button3") }
TextButton(onClick = {}, Modifier.constrainAs(btn4) {}.background(teal200)) { Text("Button4") }
createHorizontalChain(btn1, btn2, btn3, btn4)
}
}
#Preview(showBackground = true)
#Composable
fun previewThreeButtons() {
ComposeAppTheme {
fourButtonsCL()
}
}
Assume I would like to make Button3 invisible, but keep the other 3 buttons positioned where they
are. Thus just a hole between Button2 and Button4. How to achieve this without creating yet another
Composable or adding additional logic. While the logic in this simple case may be just a view lines
of code, more complex layouts would the need some more complex logic. In veiw based ConstraintLayout
wwe just need to modify the child view.
The other assumption: make Button3 disappear completely from the layout (GONE) and re-compute the
layout, remaining buttons become wider and evenly spread out. At a first glance this looks simple,
and in this very simple example it maybe easy. However in more complex layouts this could require
some or even a lot of re-wiring of constraints of the embedded Composables.
Thus the question is:
how does compose handles these cases for Column and Row layouts (like in view based LinearLayout)
and for ConstraintLayout in particular? However with the following restriction 😉: without defining
many new Composables and/or without adding complex layout logic inside the Composables (re-wiring
constraints for example).
Did I miss some modifier? Is this even planned or possible in the Composable layouts? What would be
the preferred way to solve such layout cases in Compose?
Based on #CommonsWare comment to the question I could solve the INVISIBLE
option, see code below.
Currently (in alpha-01) the implementation of ConstraintLayout seems to be incomplete, at least a few TODO comments in the code indicate this. This
seems to include the yet missing support of the GONE feature.
I saw some of these:
// TODO(popam, b/158069248): add parameter for gone margin
Also the chain feature does not yet perform layout rendering in the same way as
in the view based ConstraintLayout.
object FourElementsNoDSL {
const val elementA = "ElementA"
const val elementB = "ElementB"
const val elementC = "ElementC"
const val elementD = "ElementD"
private val noDSLConstraintSet = ConstraintSet {
// Create references with defines ids, here using a string as id. Could be an Int as well,
// actually it's defined as 'Any'
val elemA = createRefFor(elementA)
val elemB = createRefFor(elementB)
val elemC = createRefFor(elementC)
val elemD = createRefFor(elementD)
// Simple chain only. Instead of this simple chain we can use (for example):
// constrain(elemA) {start.linkTo(parent.start) }
// to set a constraint as known in XML
// constrain(elemA) {start.linkTo(parent.start, 16.dp) }
// constrain(elemB) {start.linkTo(elemA.end) }
// constrain(elemC) {start.linkTo(elemB.end) }
// constrain(elemD) {end.linkTo(parent.end) }
createHorizontalChain(elemA, elemB, elemC, elemD)
}
#Composable
fun fourButtonsCLNoDSL(doNotShow: List<String>) {
ConstraintLayout(constraintSet = noDSLConstraintSet, modifier = Modifier.fillMaxSize()) {
// This block contains the children
Text(text = "A",
modifier = Modifier.layoutId(elementA)
.drawOpacity(if (doNotShow.contains(elementA)) 0f else 1f)
.padding(0.dp),
style = TextStyle(fontSize = 20.sp)
)
Text(text = "B",
modifier = Modifier.layoutId(elementB)
.drawOpacity(if (doNotShow.contains(elementB)) 0f else 1f)
.padding(0.dp),
style = TextStyle(fontSize = 20.sp)
)
Text(text = "C",
modifier = Modifier.layoutId(elementC)
.drawOpacity(if (doNotShow.contains(elementC)) 0f else 1f)
.padding(0.dp),
style = TextStyle(fontSize = 20.sp)
)
Text(text = "D",
modifier = Modifier.layoutId(elementD)
.drawOpacity(if (doNotShow.contains(elementD)) 0f else 1f)
.padding(0.dp),
style = TextStyle(fontSize = 20.sp))
}
}
}
#Preview(showBackground = true)
#Composable
fun previewFourFieldsNoDSL() {
val noShow = listOf(FourElementsNoDSL.elementC)
PlaygroundTheme {
FourElementsNoDSL.fourButtonsCLNoDSL(noShow)
}
}
The object FourElementsNoDSL defines the layout, provides element ids and so on.
This is roughly comparable to an XML file that contains such layout.
noDSL means, that this layout does not use the Compose ConstraintLayout's Kotlin
DSL. Currently the DSL does not provide a mechanism to setup element references
(used in layoutId) with defined ids as its done in this example.

What is the Jetpack Compose equivalent of RecyclerView or ListView?

In Jetpack Compose, how can I display a large list of data while laying out only the visible items, instead of composing and laying out every item on the initial layout pass? This would be similar to RecyclerView and ListView in the View toolkit.
One could use a for loop to place all of the components inside of a Column in a VerticalScroller, but this would result in dropped frames and poor performance on larger numbers of items.
The equivalent component to RecyclerView or ListView in Jetpack Compose is LazyColumn for a vertical list and LazyRow for a horizontal list. These compose and lay out only the currently visible items.
You can use it by formatting your data as a list and passing it with a #Composable callback that emits the UI for a given item in the list. For example:
val myData = listOf("Hello,", "world!")
LazyColumn {
items(myData) { item ->
Text(text = item)
}
}
val myData = listOf("Hello,", "world!")
LazyRow {
items(myData) { item ->
Text(text = item)
}
}
You can also specify individual items one at a time:
LazyColumn {
item {
Text("Hello,")
}
item {
Text("world!")
}
}
LazyRow {
item {
Text("Hello,")
}
item {
Text("world!")
}
}
There are also indexed variants, which provide the index in the collection in addition to the item itself:
val myData = listOf("Hello,", "world!")
LazyColumn {
itemsIndexed(myData) { index, item ->
Text(text = "Item #$index is $item")
}
}
val myData = listOf("Hello,", "world!")
LazyRow {
itemsIndexed(myData) { index, item ->
Text(text = "Item #$index is $item")
}
}
These APIs were, in previous releases, known as AdapterList, LazyColumnItems/LazyRowItems, and LazyColumnFor/LazyRowFor.
Update in dev.16
[ScrollableColumn] for vertically scrolling list
[ScrollableRow] for horizontally scrolling list
Check it's implementation from ListCardViewTemplate
You can get the same essence of RecyclerView or ListView in JetpackCompose using AdapterList that's renamed in dev.14 preview version.
[LazyColumnItems] for vertically scrolling list
[LazyRowItems] for horizontally scrolling list
Check what's documentation says:
It was also moved into a lazy sub-package and split into two files. Plus I renamed params:
data -> items. this seems to be more meaningful then just raw
data
itemCallback -> itemContent. this is more meaningful and we
generally don't use word callback in the lambda names, especially for
composable lambdas
Check how to use it:
#Composable
fun <T> LazyColumnItems(
items: List<T>,
modifier: Modifier = Modifier,
itemContent: #Composable (T) -> Unit
) {
LazyItems(items, modifier, itemContent, isVertical = true)
}
In .KT
LazyColumnItems(items = (0..50).toList()) { item ->
cardViewImplementer(item)
}
From my perspective LazyColumnItem or LazyRowItem is not working properly if your item layout is complex because it's stuck the list as a comparison to VerticalScroller working fine in this scenario.

Categories

Resources