LazyColumn, Jetpack Compose. Single items() state by index - android

I have a problem with the state of individual elements in LazyColumn and LazyRow. If the first element is open and I want to delete it, then the second element becomes the first and also becomes open. I want it to work differently.
Screen
enter image description here
Fragment LazyColumn
items(zamList.size) { index ->
ExpandableCard()
}
Expandable Card
#Composable
fun ExpandableCard() {
//Expandable state
var expandedState by remember {
mutableStateOf(false)
}
Card(
onClick = {
expandedState = !expandedState
}
)

It's a bad idea to combine LazyLists with remember:
Try adding 20 items, opening 1, and scrolling, until the item is not visible anymore: the item will have closed.
The way i suggest to do it is:
Hold that state in a viewModel, e.g. a Map<YourItem, isOpen>
onOpen/onClose update your viewmodel-state.
Other than that, it's a good idea to provide ids if possible.
Also, you might try Modifier.animateContentSize() (which is only defined in a LazyScope(!), so your animations look better :)

You need to use unique keys with items. Using key will makes sure only the items that changed being recomposed and keeps order of items that are not changed based on their ids.
val myItems = listOf<MyItem>()
LazyColumn() {
items(
items = myItems,
key = {item: MyItem ->
// Some unique id here
item.hashCode()
}
) {
}
}

Related

RecyclerView - change item value only if item satisfies a condition?

I am having an issue with RecyclerView - in onBindViewHolder(), when I make a condition for each item in the list (going through the whole list, position by position), and want to change the item text value, it doesn't work. I assume that's because RecyclerView recycles items.
`
override fun onBindViewHolder(holder: TimeViewHolder, position: Int) {
val time = holder.itemView.findViewById<TextView>(R.id.timeTextView)
val item = list[position]
//items in list: "11aa", "11bb", "11cc"
if (item.value.substring(0, 2).toInt() == 11 && item.value.substring(2,4) == "bb") {
time.text = "true"
} else {
time.text="false"
}
// list should be shown as: "false", "true", "false"
if (item.value.substring(0,2).toInt() == 11 && item.value.substring(2,4) == "aa") {
time.text="true"
} else {
time.text="false"
}
//list should be shown as: "true", "false", "false"
`
Example: item is "11bb" -> show true, all other items should be false (including "11aa")
item is "11aa" -> show true, all other items are false (including "11bb")
I would greatly appreciate any suggestion!
I tried the else condition, so it is not only "if" without other possible outcome, still doesn't work
For every item in the list, you're doing two checks. First you check if item is "11bb" and display true or false. But then you throw that result out the window by checking if it's "11aa" instead, and display true or false based on that.
The end result is each item displays true if it's "11aa", otherwise it shows false.
Here's what you said you wanted in your question:
Example: item is "11bb" -> show true, all other items should be false (including "11aa")
item is "11aa" -> show true, all other items are false (including "11bb")
So it sounds like you want a single item to display true, and displaying that sets the others to false, right?
Those two rules you've provided contradict each other - what if you have both "11aa" and "11bb" in your data, like your example does?
//items in list: "11aa", "11bb", "11cc"
Which one should show true? The first one in the list, "11aa"? Or does a later one override the earlier one, so "11bb"? What's the rule for resolving this?
Here's a way you can do it, assuming you want the first one in the list.
First, you need a function to set your adapter's data. This is so you can check through it, and work out in advance which item needs to be set to true:
// inside your adapter
// your internal data - private so anything setting the data -has to- go through
// the setter function below.
private var items: List<String>
// index of the item that should be displayed as true, or null if there isn't one
// (I'm storing an index instead of the item in case there are duplicates in the list)
private val trueItemPosition: Int? = null
fun setData(newData: List<String>) {
// set the new data
items = newData
// store the index of the first item that matches your rules, or null if none do
val matchedIndex = items.indexOfFirst(::matchesRule)
trueItemPosition = if (matchedIndex == -1) null else matchedIndex
// always refresh after setting data!
notifyDataSetChanged()
}
// you don't need to do any of that Int conversion or substring stuff, just match the string!
private fun matchesRule(item: String) = when {
item.startsWith("11aa") -> true
item.startsWith("11bb") -> true
else -> false
}
Then in onBindViewHolder you can decide what to do depending on the current item's position and the value of trueItemPosition:
// in onBindViewHolder
when(trueItemPosition) {
null -> {
// display your item normally - there's no 11aa or 11bb in the list
}
position -> {
// this item is the matching one
time.text = "true"
}
else -> {
// there's a matching item, but this isn't it
time.text = "false"
}
}
And that's pretty much it! You can use indexOfLast if you want the last matching item to take precedence, and you could make separate functions for the different rule matchers if you want, say, a 11aa anywhere in the list to take precedence over any 11bbs. Just run them one after another on the whole data set in order of preference, until you get a matching index.
Like I said, this relies on you setting all your data through that setData function, so it can work things out before you display anything. That includes the initial data you provide - so if you're passing data in as a constructor parameter, that can't be a property and you need to call setData with that parameter in an init block:
class MyAdapter(
...
data: List<String> // not a val/var, we're just using this temporarily
... {
init {
// set the internal data through the setter
setData(data)
}
}
and that should do it!
edit If you need arbitrary matching indices (other than first or last) you'll need to code that logic - it's probably best to put it in a function:
fun <T> Iterable<T>.matchingIndexNumber(ordinal: Int, predicate: (T) -> Boolean) =
mapIndexedNotNull { i, item -> if (predicate(item)) i else null }
.getOrNull(ordinal - 1) ?: -1
Now you can call items.matchingIndexNumber(2, ::matchesRule) to get the 2nd match's index, etc.
By the way, I kinda glossed over this, but the reason I used indices was because they're guaranteed to be unique - if you know all your items will be unique objects (i.e. none of them return true for equals when compared to the other items) then you can drop the indices entirely, and just store the matching object:
// potentially store the important -item- itself
private val trueItem: String? = null
fun setData(newData: List<String>) {
...
// just set the first item that matches, or null if nothing matches
trueItem = items.first(::matchesRule)
...
}
override fun onBindViewHolder(holder: TimeViewHolder, position: Int) {
...
when(trueItem) {
null -> // no important item in list - display this normally
item -> // this item is the important one
else -> // there's an important item, this isn't it
}
...
}
Which is a whole lot simpler! The indices approach works no matter what, but if your items are all unique, this is the way I'd go. (If they're not, e.g. you have two "11aa" strings, they'll both match trueItem in that when block so they'll both show true or whatever)
Anyway, if you're working with the items themselves instead of having to juggle indices and return -1 for missing stuff, you can just do this:
// get the 3rd matching item
trueItem = items.filter(::matchesRule).getOrNull(3)
// or
trueItem = items.filter(::matchesRule).drop(2).firstOrNull()
// or if you want the 3rd item, but if there isn't one you want the 2nd, and so on
trueItem = items.filter(::matchesRule).take(3).lastOrNull()
There are way more options in the standard library for working with the collection of items directly, it's much more flexible, so I'd recommend it if you can!
(You could also do the take(count).lastOrNull() trick in that function I wrote if you do want/need to stick with the indices though)

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

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

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: Update composable when list changes

I've got a composable on a Screen which shows a list of Track items (favourites) :
var favourites: MutableList<Track> by mutableStateOf(mutableListOf())
#ExperimentalFoundationApi
#Composable
private fun ResultList(model: FreezerModel) {
with(model) {
if (favourites.isEmpty()) NoDataMessage("No favourites yet")
else {
LazyColumn(state = rememberLazyListState()) {
items(favourites) {
TrackCard(it, model)
}
}
}
}
}
On click events, I am updating my favourites list (add/remove item). How can I make my composable reflect these changes immediately (like to re-draw itself or something similar)? So far, it only works when I first switch to another screen.
Thanks for your inputs!
You need to use a MutableStateList<T> so that Compose can automatically recompose when the state changes.
From official doc:
Caution: Using mutable objects such as ArrayList<T> or mutableListOf() as state in Compose will cause your users to see incorrect or stale data in your app.
In your code use
val favourites = remember { mutableStateListOf<Track>()}
instead of
var favourites: MutableList<Track> by mutableStateOf(mutableListOf())
Just removing state = rememberLazyListState() from the lazyColumnFor params list should work in your case.
According to the doc if we use rememberLazyListState() then :-
Changes to the provided initial values will not result in the state being recreated or changed in any way if it has already been created.
After doing so , normally updating the list should work fine. Also its a good practice to expose an immutable list ( list ) instead of a mutableList to the composable.
For example use:-
var favourites by mutableStateOf(listOf<FavoriteItem>())
then add/remove/update the list by using :-
favourites = favourites + newList // add
favourites = favourites.toMutableList().also { it.remove(item) } // remove

Categories

Resources