Jetpack Compose: Update composable when list changes - android

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

Related

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

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

Jetpack Compose do on compose, but not on recomposition - track ContentViewed

I'm trying to implement some kind of LaunchedEffectOnce as I want to track a ContentViewed event. So my requirement is that every time the user sees the content provided by the composable, an event should get tracked.
Here is some example code of my problem:
#Composable
fun MyScreen(viewModel: MyViewModel = get()){
val items by viewModel.itemsToDisplay.collectAsState(initial = emptyList())
ItemList(items)
// when the UI is displayed, the VM should track an event (only once)
LaunchedEffectOnce { viewModel.trackContentViewed() }
}
#Composable
private fun LaunchedEffectOnce(doOnce: () -> Unit) {
var wasExecuted by rememberSaveable { mutableStateOf(false) }
if (!wasExecuted) {
LaunchedEffect(key1 = rememberUpdatedState(newValue = executed)) {
doOnce()
wasExecuted = true
}
}
}
This code is doing do the following:
Tracks event when MyScreen is composed
Does NOT track when the user enters a list item screen and navigates back to MyScreen
Does NOT track the event on recomposition (like orientation change)
But what I wan't to achieve is the following:
Tracks event when MyScreen is composed
Tracks when the user enters a list item screen and navigates back to MyScreen
Does NOT track the event on recomposition (like orientation change)
My ViewModel looks like that:
class MyViewModel() : ViewModel() {
val itemsToDisplay: Flow<List<Item>> = GetItemsUseCase()
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
val contentTracking: Flow<Tracking?> = GetTrackingUseCase()
.distinctUntilChanged { old, new -> old === new }
.shareIn(viewModelScope, SharingStarted.WhileSubscribed(), replay = 1)
fun trackContentViewed(){
// track last element in contentTracking
}
}
I really hope someone can help me and can explain what I'm doing wrong here. Thanks in advance!
Assuming the following are true
your view model is scoped to the Fragment in which MyScreen enters composition
your composables leave the composition when you navigate to an item screen and re-enter composition when you navigate back
then you can simply track inside the view model itself whether specific content was already viewed in this view model's scope. Then when you navigate to any of the items screens you reset that "tracking state".
If you need to track only a single element of content then just a Boolean variable would be enough, but in case you need to track more than one element, you can use either a HashSet or a mutableSetOf (which returns a LinkedHashSet instead). Then when you navigate to any of the item screen you reset that variable or clear the Set.
Your VM code would then change to
class MyViewModel() : ViewModel() {
// ... you existing code remains unchanged
private var viewedContent = mutableSetOf<Any>()
fun trackContentViewed(key: Any){
if (viewedContent.add(key)) {
// track last element in contentTracking
Log.d("Example", "Key $key tracked for 'first time'")
} else {
// content already viewed for this key
Log.d("Example", "Key $key already tracked before")
}
}
fun clearTrackedContent() {
viewedContent.clear()
}
}
and the MyScreen composable would change to
#Composable
fun MyScreen(viewModel: MyViewModel = get()){
// ... you existing code remains unchanged
// Every time this UI enters the composition (but not on recomposition)
// the VM will be notified
LaunchedEffect(Unit) {
viewModel.trackContentViewed(key = "MyScreen") // or some other key
}
}
Where you start the navigation to an item screen (probably in some onClick handler on items) you would call viewmodel.clearTrackedContent().
Since (1) is true when ViewModels are requested inside a Fragment/Activity and if (2) is also true in your case, then the VM instance will survive configuration changes (orientation change, language change...) and the Set will take care of tracking.
If (2) is not true in your case, then you have two options:
if at least recomposition happens when navigating back, replace LaunchedEffect with SideEffect { viewModel.trackContentViewed(key = "MyScreen") }
if your composables are not even recomposed then you will have to call viewModel.trackContentViewed also when navigating back.

Redundant recomposition happenning in my layout. Why does it recompose even though inputs haven't changed?

I have a SnapshotStateMap that I use to track updates in my layout, this map is stored in a viewmodel.
This the site call:
val roundState = viewModel.roundState
for (index in 0 until attempts) {
val state = roundState[index] ?: WordState.Empty
Row {
RoundWord(state, letters)
}
}
In my program there are changes to only one item at the time, so basically my train of thought is:
I add a new state or update the old in map -> I pass it to RoundWord -> If there is no state for index I pass in empty state -> RoundWord Composable relies on state to display the needed UI.
Here is the body of RoundWord
#Composable
private fun RoundWord(
state: WordState,
letters: Int,
) {
when (state) {
is WordState.Progress -> ProgressWord(letters)
is WordState.Empty -> WordCells(letters)
is WordState.Resolved -> WordCells(letters) { WiPLetter(state.value.word[it]) }
}
}
From what I understand if there is no state in roundState map for a given index I provide Empty state that is defined as an object in a sealed interface hierarchy. Same object -> no recomposition. But for some reason it recomposes every time. I have been at this for a few days now and despite going though tons of documentation I can't see what I am missing here. Why does this recomposition happens for empty state?

LiveData - How to observe changes to List inside object?

I have a Composable, a ViewModel and an object of a User class with a List variable in it. Inside the ViewModel I define a LiveData object to hold the User object and in the Composable I want to observe changes to the List inside the User object but it doesn't seem to work very well.
I understand when you change the contents of a List its reference is the same so the List object doesn't change itself, but I've tried copying the list, and it doesn't work; copying the whole User object doesn't work either; and the only way it seems to work is if I create a copy of both. This seems too far-fetched and too costly for larger lists and objects. Is there any simpler way to do this?
The code I have is something like this:
Composable
#Composable
fun Greeting(viewModel: ViewModel) {
val user = viewModel.user.observeAsState()
Column {
// TextField and Button that calls viewModel.addPet(petName)
LazyColumn {
items(user.value!!.pets) { pet ->
Text(text = pet)
}
}
}
}
ViewModel
class ViewModel {
val user: MutableLiveData<User> = MutableLiveData(User())
fun addPet(petName: String){
val sameList = user.value!!.pets
val newList = user.value!!.pets.toMutableList()
newList.add(petName)
sameList.add(petName) // This doesn't work
user.value = user.value!!.copy() // This doesn't work
user.value!!.pets = newList // This doesn't work
user.value = user.value!!.copy(pets = newList) // This works BUT...
}
}
User
data class User(
// Other variables
val pets: MutableList<String> = mutableListOf()
)
MutableLiveData will only notify view when it value changes, e.g. when you place other value which is different from an old one. That's why user.value = user.value!!.copy(pets = newList) works.
MutableLiveData cannot know when one of the fields was changed, when they're simple basic types/classes.
But you can make pets a mutable state, in this case live data will be able to notify about changes. Define it like val pets = mutableStateListOf<String>().
I personally not a big fan of live data, and code with value!! looks not what I'd like to see in my project. So I'll tell you about compose way of doing it, in case your project will allow you to use it. You need to define both pets as a mutable state list of strings, and user as a mutable state of user.
I suggest you read about compose states in the documentation carefully.
Also note that in my code I'm defining user with delegation, and pets without delegation. You can use delegation only in view model, and inside state holders you cannot, othervise it'll become plain objects at the end.
#Composable
fun TestView() {
val viewModel = viewModel<TestViewModel>()
Column {
// TextField and Button that calls viewModel.addPet(petName)
var i by remember { mutableStateOf(0) }
Button(onClick = { viewModel.addPet("pet ${i++}") }) {
Text("add new pet")
}
LazyColumn {
items(viewModel.user.pets) { pet ->
Text(text = pet)
}
}
}
}
class User {
val pets = mutableStateListOf<String>()
}
class TestViewModel: ViewModel() {
val user by mutableStateOf(User())
fun addPet(petName: String) {
user.pets.add(petName)
}
}
Jetpack Compose works best with immutable objects, making a copy with modern Android and ART is not the issue that it was in the past.
However, if you do not want to make a whole copy of your object, you could add a dummy int to it and then mutate that int when you also mutate the list, but I strongly urge you to consider immutability and instantiate a new User object instead.

value of list in model is updated but it does not reflect on composable function

I am creating demo project for using jetpack compose with mvvm , i have created model class that holds the list of users.. those users are displayed in list and there is a button at top which adds new user to the list when clicked...
when user clicks on the button an the lambda updates activity about it and activity calls viewmodel which adds data to list and updates back to activity using livedata, now after the model receives the new data it does not update composable function about it and hence ui of list is not updated..
here is the code
#Model
data class UsersState(var users: ArrayList<UserModel> = ArrayList())
Activity
class MainActivity : AppCompatActivity() {
private val usersState: UsersState = UsersState()
private val usersListViewModel: UsersListViewModel = UsersListViewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
usersListViewModel.getUsers().observe(this, Observer {
usersState.users.addAll(it)
})
usersListViewModel.addUsers()
setContent {
UsersListUi.addList(
usersState,
onAddClick = { usersListViewModel.addNewUser() },
onRemoveClick = { usersListViewModel.removeFirstUser() })
}
}
}
ViewModel
class UsersListViewModel {
private val usersList: MutableLiveData<ArrayList<UserModel>> by lazy {
MutableLiveData<ArrayList<UserModel>>()
}
private val users: ArrayList<UserModel> = ArrayList()
fun addUsers() {
users.add(UserModel("jon", "doe", "android developer"))
users.add(UserModel("john", "doe", "flutter developer"))
users.add(UserModel("jonn", "dove", "ios developer"))
usersList.value = users
}
fun getUsers(): MutableLiveData<ArrayList<UserModel>> {
return usersList
}
fun addNewUser() {
users.add(UserModel("jony", "dove", "ruby developer"))
usersList.value = users
}
fun removeFirstUser() {
if (!users.isNullOrEmpty()) {
users.removeAt(0)
usersList.value = users
}
}
}
composable function
#Composable
fun addList(state: UsersState, onAddClick: () -> Unit, onRemoveClick: () -> Unit) {
MaterialTheme {
FlexColumn {
inflexible {
// Item height will be equal content height
TopAppBar( // App Bar with title
title = { Text("Users") }
)
FlexRow() {
expanded(flex = 1f) {
Button(
text = "add",
onClick = { onAddClick.invoke() },
style = OutlinedButtonStyle()
)
}
expanded(flex = 1f) {
Button(
text = "sub",
onClick = { onRemoveClick.invoke() },
style = OutlinedButtonStyle()
)
}
}
VerticalScroller {
Column {
state.users.forEach {
Column {
Row {
Text(text = it.userName)
WidthSpacer(width = 2.dp)
Text(text = it.userSurName)
}
Text(text = it.userJob)
}
Divider(color = Color.Black, height = 1.dp)
}
}
}
}
}
}
}
the whole source code is available here
I am not sure if i am doing something wrong or is it because jetpack compose is still in developers preview , so would appreciate any help..
thank you
Ahoy!
Sean from Android Devrel here. The main reason this isn't updating is the ArrayList in UserState.users is not observable – it's just a regular ArrayList so mutating it won't update compose.
Model makes all properties of the model class observable
It seems like this might work because UserState is annotated #Model, which makes things automatically observable by Compose. However, the observability only applies one level deep. Here's an example that would never trigger recomposition:
class ModelState(var username: String, var email: String)
#Model
class MyImmutableModel(val state: ModelState())
Since the state variable is immutable (val), Compose will never trigger recompositions when you change the email or username. This is because #Model only applies to the properties of the class annotated. In this example state is observable in Compose, but username and email are just regular strings.
Fix Option #0: You don't need #Model
In this case you already have a LiveData from getUsers() – you can observe that in compose. We haven't shipped a Compose observation yet in the dev releases, but it's possible to write one using effects until we ship a observation method. Just remember to remove the observer in onDispose {}.
This is also true if you're using any other observable type, like Flow, Flowable, etc. You can pass them directly into #Composable functions and observe them with effects without introducing an intermediate #Model class.
Fix Option #1: Using immutable types in #Model
A lot of developers prefer immutable data types for UI state (patterns like MVI encourage this). You can update your example to use immutable lists, then in order to change the list you'll have to assign to the users property which will be observable by Compose.
#Model
class UsersState(var users: List<UserModel> = listOf())
Then when you want to update it you have to assign the users variable:
val usersState = UsersState()
// ...
fun addUsers(newUsers: List<UserModel>) {
usersState.users = usersState.users + newUsers
// performance note: note this allocates a new list every time on the main thread
// which may be OK if this is rarely called and lists are small
// it's too expensive for large lists or if this is called often
}
This will always trigger recomposition any time a new List<UserModel is assigned to users, and since there's no way to edit the list after it's been assigned the UI will always show the current state.
In this case, since the data structure is a List that you're concatenating the performance of immutable types may not be acceptable. However, if you're holding an immutable data class this option is a good one so I included it for completeness.
Fix Option #2: Using ModelList
Compose has a special observable list type for exactly this use case. You can use instead of an ArrayList and any changes to the list will be observable by compose.
#Model
class UsersState(val users: ModelList<UserModel> = ModelList())
If you use ModelList the rest of the code you've written in the Activity will work correctly and Compose will be able to observe changes to users directly.
Related: Nesting #Model classes
It's worth noting that you can nest #Model classes, which is how the ModelList version works. Going back to the example at the beginning, if you annotate both classes as #Model, then all of the properties will be observable in Compose.
#Model
class ModelState(var username: String, var email: String)
#Model
class MyModel(var state: ModelState())
Note: This version adds #Model to ModelState, and also allows reassignment of state in MyModel
Since #Model makes all of the properties of the class that is annotated observable by compose, state, username, and email will all be observable.
TL;DR which option to choose
Avoiding #Model (Option #0) completely in this code will avoid introducing a duplicate model layer just for Compose. Since you're already holding state in a ViewModel and exposing it via LiveData you can just pass the LiveData directly to compose and observe it there. This would be my first choice.
If you do want to use #Model to represent a mutable list, then use ModelList from Option #2.
You'll probably want to change the ViewModel to hold a MutableLiveData reference as well. Currently the list held by the ViewModel is not observable. For an introduction to ViewModel and LiveData from Android Architecture components check out the Android Basics course.
Your model is not observed so changes won't be reflected.
In this article under the section 'Putting it all together' the List is added.
val list = +memo{ calculation: () -> T}
Example for your list:
#Composable
fun test(supplier: UserState) {
val list = +memo{supplier.users}
ListConsumer(list){
/* Do other stuff for your usecase */
}
}

Categories

Resources