Jetpack Compose rememberSwipeableState recycling issue - android

I have a LazyColumn that's generating items from a list data structure, every item has a swipeable state. Once an item is deleted from the list (Data structure), it is also reflected in UI, a recomposition is initiated and the LazyColumn is updated - not showing the deleted item - Correctly.
The problem is, all the swipe state variables of the LazyColumn items remain as before the deletion, For example, if the list was red, green, blue and green is deleted, the swipe state of green which was 2nd in the list is now the swipe state of blue which is now second in the list. All the items shift left, but the states remain in place.
Here's the code:
var dailyItems= viewModel.getItems().observeAsState(initial = emptyList())
LazyColumn(...) {
items(dailyItems) { item ->
SomeItem(
item = item,
)
}
}
SomeItem has a swipeable sub component in it
#Composable
private fun SomeItem(
item: Item
) {
val swipeState = rememberSwipeableState(
initialValue = ItemState.HIDDEN,
confirmStateChange = {
onActionsReveal(item.id) // Note the use if item instance
true
}
)
Box(
Modifier.swipeable(
state = swipeState,
anchors = anchors,
orientation = Orientation.Horizontal,
enabled = true,
reverseDirection = isRtl
)
) {
...
}
}
val swipeState = rememberSwipeableState()
val swipeState is recreated when SomeItem is re-composed, I see a new memory address for assigned to it, I also see that item.id is different.
But either confirmStateChange is not being overridden or the previous instance of swipeState is referenced somehow in future invocations - When the confirmStateChange is invoked - it always refers to the initial item.id

Issue was solve by applying rememberUpdatedState to item.id
val id by rememberUpdatedState(item.id)
val swipeState = rememberSwipeableState(
initialValue = ItemState.HIDDEN,
confirmStateChange = {
onActionsReveal(id)
true
}
)

Related

Is Compose's swipe-to-dismiss state always remember the old item based on id, even the list has been refresh to newer one?

I have a simple example app that can
Load a new list (of 2 items, with id 0 and 1, and random text for each)
It can swipe to dismiss any item.
If I
load the new list for the first time
swipe to delete the first item
load a new list (that has same ID, but different random text)
swipe to delete the second item
It will crash as shown in the GIF below
(You can get the code design from here https://github.com/elye/issue_android_jetpack_compose_swipe_to_dismiss_different_data_same_id)
The reason is crashes because, upon Swipe-to-Dismiss the 2 item (of the 2nd time loaded data), the item it found is still the 2 item of the 1st time loaded data.
It does seems dismissState (as shown code below) always remember the 1st time loaded data (instead of the new data loaded)
val dismissState = rememberDismissState(
confirmStateChange = {
Log.d("Track", "$item\n${myListState.value.toMutableList()}")
viewModel.removeItem(item)
true
}
)
Hence this causes the deletion to send the wrong item in for deletion, and thus causes the failure and crash.
The complete LazyColumn and SwipeToDismiss code is as below
LazyColumn(modifier = Modifier.fillMaxHeight()) {
items(
items = myListState.value,
key = { todoItem -> todoItem.id }
) { item ->
val dismissState = rememberDismissState(
confirmStateChange = {
viewModel.removeItem(item)
true
}
)
SwipeToDismiss(
state = dismissState,
background = {
dismissState.dismissDirection ?: return#SwipeToDismiss
Box(modifier = Modifier.fillMaxSize().background(Color.Red))
},
dismissContent = {
// The row view of each item
}
)
}
}
Is this
My issue, is that I miss out on anything to refresh the dismissState upon loading of new data?
A Google Bug, where SwipeToDismiss will always have to work with a list of Unique IDs . Even if the list is refreshed to a new list, it cannot have the same ID that colide with any item of the previous list
i.e. if I replace key = { todoItem -> todoItem.id } with key = { todoItem -> todoItem.title }, then it will all be good
rememberDismissState() will remember the confirmStateChange lambda, which is part of the DismissState. In your case, item can change, but the lambda only captures the initial item value, leading to the crash.
You can use rememberUpdatedState to solve this:
val currentItem by rememberUpdatedState(item)
val dismissState = rememberDismissState(
confirmStateChange = {
viewModel.removeItem(currentItem)
true
}
)

Material Swipe To Dismiss in Compose maks incorrect items for dismissal

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

How to save paging state of LazyColumn during navigation in Jetpack Compose

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

Jetpack compose list wrong item selected after reordering or filtering

I have a ViewModel that produces a StateFlow like this:
private val _profiles = MutableStateFlow<List<ProfileSnap>>(listOf())
val profiles: StateFlow<List<ProfileSnap>>
get() = _profiles
Values are updated in another fun:
private fun loadProfiles() = viewModelScope.launch {
_profiles.value = profileDao.getAll(profilesSearch, profilesSort)
}
Finally, in Compose I list all values (this is a simplified version of my code):
#Composable
fun SetContent(viewModel: ProfilesViewModel){
val profiles = viewModel.profiles.collectAsState()
LazyColumn(
modifier = Modifier
.fillMaxHeight()
) {
itemsIndexed(items = profiles.value) { _, profile ->
Text(
text = "(${profile.profileId}) ${profile.label}",
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
Log.d(TAG, "onLongPress: ${profile.profileId}")
},
onTap = {
Log.d(TAG, "onTap: ${profile.profileId}")
},
)
}
)
}
}
}
At the beginning, when I reach the list fragment and I click on an element, I get the correct corresponding profileId. But, when I apply a filter or I change the list sorting and the loadProfiles() function is called:
the list correctly changes accordingly to the new filtered and/sorted profiles
when I click on an element I get the wrong profileId, I seems the one of the previous list disposition!
What am I doing wrong? profiles are not up to date? But if they are not updated, why the list is "graphically" correct? Here what happens:
(1) A
-----
(2) B
-----
(3) C <== CLICK - onTap: 3 / LONGPRESS - onLongPress: 3
Change sort order:
(3) C
-----
(2) B
-----
(1) A <== CLICK - onTap: 3 [should has been 1] / LONGPRESS - onLongPress: 3 [should has been 1]
Thank you very much
You can check the official doc:
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. If you imagine the scenario of LazyRow within a LazyColumn, if the row changes item position, the user would then lose their scroll position within the row.
To combat this, you can provide a stable and unique key for each item, providing a block to the key parameter. Providing a stable key enables item state to be consistent across data-set changes:
LazyColumn() {
items(
items = profiles.value,
key = { profile ->
// Return a stable + unique key for the item
profile.profileId
}
) { profile ->
//....
}
}
Following Gabriele's hint, that's a working version (I couldn't find the same signature for items function):
LazyColumn() {
items(
count = profiles.value.size,
key = { index -> profiles.value[index].profileId }
) { index ->
val profile = profiles.value[index]
Item(profile)
Line()
}
}

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

Categories

Resources