In the Log i can see if i just touch the list it will recompose it multiple times. It works fine and print "!!!" once every time new row appears if i run composable function separatly from app, but inside composeView it goes crazy and print it a lot of times.
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().background(colorResource(R.color.dark_blue))
) {
itemsIndexed(items, { index, item -> index) }) { index, item ->
println("!!!")
TableRow(item)
}
}
the function itself only runs once
Related
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.
I'm implementing drag/swipe to dismiss functionality in a simple notepad app implemented in Compose. I've run into a strange issue where SwipeToDismiss() in a LazyColumn dismisses not only the selected item but those after it as well.
Am I doing something wrong or is this a bug with SwipeToDismiss()? (I'm aware that it's marked ExperimentalMaterialApi)
I've used the Google recommended approach from here:
https://developer.android.com/reference/kotlin/androidx/compose/material/package-summary#swipetodismiss
this is where it happens:
/* ...more code... */
LazyColumn {
items(items = results) { result ->
Card {
val dismissState = rememberDismissState()
//for some reason the dismmissState is EndToStart for all the
//items after the deleted item, even adding new items becomes impossible
if (dismissState.isDismissed(EndToStart)) {
val scope = rememberCoroutineScope()
scope.launch {
dismissed(result)
}
}
SwipeToDismiss(
state = dismissState,
modifier = Modifier.padding(vertical = 4.dp),
/* ...more code... */
and here is my project with the file in question
https://github.com/davida5/ComposeNotepad/blob/main/app/src/main/java/com/anotherday/day17/ui/NotesList.kt
You need to provide key for the LazyColumn's items.
By default, each item's state is keyed against the position of the
item in the list. However, this can cause issues if the data set
changes, since items which change position effectively lose any
remembered state.
Example
LazyColumn {
items(
items = stateList,
key = { _, listItem ->
listItem.hashCode()
},
) { item ->
// As it is ...
}
}
Reference
I'm using androidx.paging:paging-compose (v1.0.0-alpha-14), together with Jetpack Compose (v1.0.3), I have a custom PagingSource which is responsible for pulling items from backend.
I also use compose navigation component.
The problem is I don't know how to save a state of Pager flow between navigating to different screen via NavHostController and going back (scroll state and cached items).
I was trying to save state via rememberSaveable but it cannot be done as it is not something which can be putted to Bundle.
Is there a quick/easy step to do it?
My sample code:
#Composable
fun SampleScreen(
composeNavController: NavHostController? = null,
myPagingSource: PagingSource<Int, MyItem>,
) {
val pager = remember { // rememberSaveable doesn't seems to work here
Pager(
config = PagingConfig(
pageSize = 25,
),
initialKey = 0,
pagingSourceFactory = myPagingSource
)
}
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn() {
itemsIndexed(items = lazyPagingItems) { index, item ->
MyRowItem(item) {
composeNavController?.navigate(...)
}
}
}
}
I found a solution!
#Composable
fun Sample(data: Flow<PagingData<Something>>):
val listState: LazyListState = rememberLazyListState()
val items: LazyPagingItems<Something> = data.collectAsLazyPagingItems()
when {
items.itemCount == 0 -> LoadingScreen()
else -> {
LazyColumn(state = listState, ...) {
...
}
}
}
...
I just found out what the issue is when using Paging.
The reason the list scroll position is not remembered with Paging when navigating boils down to what happens below the hood.
It looks like this:
Composable with LazyColumn is created.
We asynchronously request our list data from the pager. Current pager list item count = 0.
The UI draws a lazyColumn with 0 items.
The pager responds with data, e.g. 10 items, and the UI is recomposed to show them.
User scrolls e.g. all the way down and clicks the bottom item, which navigates them elsewhere.
User navigates back using e.g. the back button.
Uh oh. Due to navigation, our composable with LazyColumn is recomposed. We start again with asynchronously requesting pager data. Note: pager item count = 0 again!
rememberLazyListState is evaluated, and it tells the UI that the user scrolled down all the way, so it now should go back to the same offset, e.g. to the fifth item.
This is the point where the UI screams in wild confusion, as the pager has 0 items, so the lazyColumn has 0 items.
The UI cannot handle the scroll offset to the fifth item. The scroll position is set to just show from item 0, as there are only 0 items.
What happens next:
The pager responds that there are e.g. 10 items again, causing another recomposition.
After recomposition, we see our list again, with scroll position starting on item 0.
To confirm this is the case with your code, add a simple log statement just above the LazyColumn call:
Log.w("TEST", "List state recompose. " +
"first_visible=${listState.firstVisibleItemIndex}, " +
"offset=${listState.firstVisibleItemScrollOffset}, " +
"amount items=${items.itemCount}")
You should see, upon navigating back, a log line stating the exact same first_visible and offset, but with amount items=0.
The line directly after that will show that first_visible and offset are reset to 0.
My solution works, because it skips using the listState until the pager has loaded the data.
Once loaded, the correct values still reside in the listState, and the scroll position is correctly restored.
Source: https://issuetracker.google.com/issues/177245496
Save the list state in your viewmodel and reload it when you navigate back to the screen containing the list. You can use LazyListState in your viewmodel to save the state and pass that into your composable as a parameter. Something like this:
class MyViewModel: ViewModel() {
var listState = LazyListState()
}
#Composable
fun MessageListHandler() {
MessageList(
messages: viewmodel.messages,
listState = viewmode.listState
)
}
#Composable
fun MessageList(
messages: List<Message>,
listState: LazyListState) {
LazyColumn(state = listState) {
}
}
If you don't like the limitations that Navigation Compose puts on you, you can try using Jetmagic. It allows you to pass any object between screens and even manages your viewmodels in a way that makes them easier to access from any composable:
https://github.com/JohannBlake/Jetmagic
The issue is that when you navigate forward and back your composable will recompose and collectAsLazyPagingItems() will be called again, triggering a new network request.
If you want to avoid this issue, you should call pager.flow.cacheIn(viewModelScope) on your ViewModel with activity scope (the ViewModel instance is kept across fragments) before calling collectAsLazyPagingItems().
LazyPagingItems is not intended as a persistent data store; it is just a simple wrapper for the UI layer. Pager data should be cached in the ViewModel.
please try using '.cachedIn(viewModelScope) '
simple example:
#Composable
fun Simple() {
val simpleViewModel:SimpleViewModel = viewModel()
val list = simpleViewModel.simpleList.collectAsLazyPagingItems()
when (list.loadState.refresh) {
is LoadState.Error -> {
//..
}
is LoadState.Loading -> {
BoxProgress()
}
is LoadState.NotLoading -> {
when (list.itemCount) {
0 -> {
//..
}
else -> {
LazyColumn(){
items(list) { b ->
//..
}
}
}
}
}
}
//..
}
class SimpleViewModel : ViewModel() {
val simpleList = Pager(
PagingConfig(PAGE_SIZE),
pagingSourceFactory = { SimpleSource() }).flow.cachedIn(viewModelScope)
}
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) { ... }
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