Compose - DropDownMenu is causing unwanted recomposition - android

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.

Related

Infinite recomposition on LazyColumn items in Jetpack Compose

I've got simple LazyColumn:
LazyColumn {
val lazySportEvents: LazyPagingItems<RecyclerItem> = stateValue.pagingItems.collectAsLazyPagingItems()
lazySportEvents.apply {
when (loadState.refresh) {
is LoadState.NotLoading -> {
itemsIndexed(
lazyPagingItems = lazySportEvents,
itemContent = { index, item ->
when (item) {
is SportEvent -> Text(item.name)
is AdItem -> AndroidView(
factory = { context ->
AdImageView(context).apply{
loadAdImage(item.id)
}
}
)
}
}
)
}
}
}
}
When I scroll screen down, everything loads fine. But when I scroll up, I end up with fun loadAdImage() called. It means that recomposition for AdItem happened even if that is the very same item (values and reference) like before scrolling screen down! Why does recomposition even happen then? I would like to omit it, to not load the same ad image every time while scrolling.
Is it even possible to skip recomposition for lazy paging items?
Edit: I realised the recomposition for items was infinite and that caused aforementioned behavior.
It turned out that when I changed
modifier = Modifier.fillParentMaxWidth()
to
modifier = Modifier.fillMaxWidth()
for some Boxes in the composable layout, infinite recomposition in LazyColumn stopped.
Edit: while working on the fix I experienced some Gradle dependencies caching problems. Anyway, when the problem approached for the 2nd time - the solution was to... upgrade compose.foundation dependency:
implementation "androidx.compose.foundation:foundation:$1.2.0-alpha07"
Not sure which one helped.

Jetpack Compose: nested LazyColumn / LazyRow

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
}
}

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
}
}
)
}
}
}
}
}

Scroll to top when adding new items

I have a usecase where I would like a LazyColumn to scroll to the top if a new item is added to the start of the list - but only if the list was scrolled to top before. I'm currently using keys for bookkeeping the scroll position automatically, which is great for all other cases than when the scroll state is at the top of the list.
This is similar to the actual logic I have in the app (in my case there is however no button, showFirstItem is a parameter to the composable function, controlled by some other logic):
var showFirstItem by remember { mutableStateOf(true) }
Column {
Button(onClick = { showFirstItem = !showFirstItem }) {
Text("${if (showFirstItem) "Hide" else "Show"} first item")
}
LazyColumn {
if (showFirstItem) {
item(key = "first") {
Text("First item")
}
}
items(count = 100,
key = { index ->
"item-$index"
}
) { index ->
Text("Item $index")
}
}
}
As an example, I would expect "First item" to be visible if I scroll to top, hide the item and them show it again. Or hide the item, scroll to top and then show it again.
I think the solution could be something with LaunchedEffect, but I'm not sure at all.
If a new item has been added and user is at top, the new item would not appear unless the list is scrolled to the top. I have tried this:
if (lazyListState.firstVisibleItemIndex <= 1) {
//scroll to top to ensure latest added book gets visible
LaunchedEffect(key1 = key) {
lazyListState.scrollToItem(0)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState
) {...
And it seems to work. But it breaks the pull to refresh component that I am using. So not sure how to solve that. I am still trying to force myself to like Compose. Hopefully that day will come =)
You can scroll to the top of the list on a LazyColumn like this:
val coroutineScope = rememberCoroutineScope()
...
Button(onClick = {
coroutineScope.launch {
// 0 is the first item index
scrollState.animateScrollToItem(0)
}
}) {
Text("Scroll to the top")
}
So call scrollState.animateScrollToItem(0) where ever you need from a coroutine, e.g. after adding a new item.
In your item adding logic,
scrollState.animateScrollToItem(0)
//Add an optional delay here
showFirst = ... //Handle here whatever you want
Here, scrollState is to be passed in the LazyColumn
val scrollState = rememberScrollState() LazyColumn(state = scrollState) { ... }

Why do I sometimes need key() in lists?

I have a component with some mutable state list. I pass an item of that, and a callback to delete the item, to another component.
#Composable
fun MyApp() {
val myItems = mutableStateListOf("1", "2", "3")
LazyColumn {
items(myItems) { item ->
MyComponent(item) { toDel -> myItems.remove(toDel) }
}
}
}
The component calls the delete callback in a clickable Modifier.
#Composable
fun MyComponent(item: String, delete: (String) -> Unit = {}) {
Column {
Box(
Modifier
.size(200.dp)
.background(MaterialTheme.colors.primary)
.clickable { delete(item) }
) {
Text(item, fontSize = 40.sp)
}
}
}
This works fine. But when I change the clickable for my own Modifier with pointerInput() then there's a problem.
fun Modifier.myClickable(delete: () -> Unit) =
pointerInput(Unit) {
awaitPointerEventScope { awaitFirstDown() }
delete()
}
#Composable
fun MyComponent(item: String, delete: (String) -> Unit = {}) {
Column {
Box(
Modifier
.size(200.dp)
.background(MaterialTheme.colors.primary)
.myClickable { delete(item) } // NEW
) {
Text(item, fontSize = 40.sp)
}
}
}
If I click on the first item, it removes it. Next, if I click on the newest top item, the old callback for the now deleted first item is called, despite the fact that the old component has been deleted.
I have no idea why this happens. But I can fix it. I use key():
#Composable
fun MyApp() {
val myItems = mutableStateListOf("1", "2", "3")
LazyColumn {
items(myItems) { item ->
key(item) { // NEW
MyComponent(item) { toDel -> myItems.remove(toDel) }
}
}
}
}
So why do I need key() when I use my own modifier? This is also the case in this code from jetpack, and I don't know why.
As the accepted answer says, Compose won't recalculate my custom Modifier because pointerEvent() doesn't have a unique key.
fun Modifier.myClickable(key:Any? = null, delete: () -> Unit) =
pointerInput(key) {
awaitPointerEventScope { awaitFirstDown() }
delete()
}
and
Box(
Modifier
.size(200.dp)
.background(MaterialTheme.colors.primary)
.myClickable(key = item) { delete(item) } // NEW
) {
Text(item, fontSize = 40.sp)
}
fixes it and I don't need to use key() in the outer component. I'm still unsure why I don't need to send a unique key to clickable {}, however.
Compose is trying to cache as many work as it can by localizing scopes with keys: when they haven't changes since last run - we're using cached value, otherwise we need to recalculate it.
By setting key for lazy item you're defining a scope for all remember calculations inside, and many of system functions are implemented using remember so it changes much. Item index is the default key in lazy item
So after you're removing first item, first lazy item gets reused with same context as before
And now we're coming to your myClickable. You're passing Unit as a key into pointerInput(It has a remember inside too). By doing this you're saying to recomposer: never recalculate this value until context changes. And the context of first lazy item hasn't changed, e.g. key is still same index, that's why lambda with removed item remains cached inside that function
When you're specifying lazy item key equal to item, you're changing context of all lazy items too and so pointerInput gets recalculated. If you pass your item instead of Unit you'll have the same effect
So you need to use key when you need to make use your calculations are not gonna be cached between lazy items in a bad way
Check out more about lazy column keys in the documentation
Jetpack compose optimizes the re-compose by only recomposing Widget which value has been changed.
In your Custom implementation of Modifier.myClickable when item list is changing due to deletion, only the inner Text(item, fontSize = 40.sp) will be recomposed since item has changed and it is the only one which is reading item. The outer Box() is not recomposed, hence it is holding the previous callback. But When you add key(item), the outer box will also be re-composed as the key value has changed. Hence it is working after adding the key.
So why is was working with Modifier.clickable { delete(item) }?
I think Compose kept track of change in the callback clickable { delete(item) }. So when the callback changed due to item deletion, it recomposed MyComponent, Hence is was working with clickable

Categories

Resources