Compose: LazyColumn recomposes all items on single item update - android-jetpack-compose

I am trying to show a list of Orders in a list using LazyColumn. Here is the code:
#Composable
private fun MyOrders(
orders: List<Order>?,
onClick: (String, OrderStatus) -> Unit
) {
orders?.let {
LazyColumn {
items(
items = it,
key = { it.id }
) {
OrderDetails(it, onClick)
}
}
}
}
#Composable
private fun OrderDetails(
order: Order,
onClick: (String, OrderStatus) -> Unit
) {
println("Composing Order Item")
// Item Code Here
}
Here is the way, I call the composable:
orderVm.fetchOrders()
val state by orderVm.state.collectAsState(OrderState.Empty)
if (state.orders.isNotEmpty()) {
MyOrders(state.orders) {
// Handle status change click listener
}
}
I fetch all my orders and show in the LazyColumn. However, when a single order is updated, the entire LazyColumn gets rrecomposed. Here is my ViewModel looks like:
class OrderViewModel(
fetchrderUseCase: FetechOrdersUseCase,
updateStatusUseCase: UpdateorderUseCase
) {
val state = MutableStateFlow(OrderState.Empty)
fun fetchOrders() {
fetchrderUseCase().collect {
state.value = state.value.copy(orders = it.data)
}
}
fun updateStatus(newStatus: OrderStatus) {
updateStatusUseCase(newStatus).collect {
val oldOrders = status.value.orders
status.value = status.value.copy(orders = finalizeOrders(oldOrders))
}
}
}
NOTE: The finalizeOrders() does some list manipulation based on orderId to update one order with the updated one.
This is how my state looks like:
data class OrderState(
val orders: List<Order> = listOf(),
val isLoading: Boolean = false,
val error: String = ""
) {
companion object {
val Empty = FetchOrdersState()
}
}
If I have 10 orders in my DB and I update one's status (let's say 5th item), then OrderDetails gets called for 20 times. Not sure why. Caan I optimize it to make sure only the 5th indexed item will be recomposed and the OrderDetals gets called only with the new order.

Is the Orderclasss stable? If not it could be the reason why all the items get recomposed:
Compose skips the recomposition of a composable if all the inputs are stable and haven't changed. The comparison uses the equals method
This section in the compose's doc explains what are stable types and how to skip recomposition.
Note: If you scroll a lazy list, all invisible items will be destroyed. That means if you scroll back they will be recreated not recomposed (you can't skip recreation even if the input is stable).

Related

Use Room + Paging3 + LazyColumn but pagination is not implemented

I have the following code, but I don't think the pagination is implemented.
dao
interface IArticleDao {
#Query(
"""
SELECT * FROM t_article ORDER BY :order DESC
"""
)
fun pagingSource(order: String): PagingSource<Int, Article>
}
Repository
class ArticleRepository #Inject constructor(
private val articleDao: IArticleDao
) {
fun list(order: String) = articleDao.pagingSource(order)
}
ViewModel
#HiltViewModel
class ArticleViewModel #Inject constructor(private val articleRepository: ArticleRepository) : ViewModel() {
fun list(order: String) = Pager(PagingConfig(pageSize = 20)){
articleRepository.list(order)
}.flow.cachedIn(viewModelScope)
}
Screen
val articleViewModel = hiltViewModel<ArticleViewModel>()
val lazyArticleItem = articleViewModel.list("id").collectAsLazyPagingItems()
ArticlePage(lazyArticleItem)
ArticlePage
LazyColumn{
items(...)
when(val state = lazyArticleItem.loadState.append){
is LoadState.Error -> {
println("error")
}
is LoadState.Loading -> {
println("${lazyArticleItem.itemCount}, ${lazyArticleItem.itemSnapshotList}")
}
else -> {}
}
}
lazyArticleItem.itemCount printed the number 668, so I don't think the pagination is working properly, but the data displayed on the UI interface is fine, it's just not paginated by 20 items per page.
In the PagingConfig, you can specify enablePlaceholders, which is true by default (your case). If placeholders are enabled and paging source knows the total number of items, which it knows when it takes data from room database, lazyArticleItem.itemSnapshotList size will be the total size of the source, only the elements that are not yet loaded will be null.
So you can't say that paging is not working based on itemCount. You are also printing itemSnapshotList, are there nulls? You can also try setting enablePlaceholders = false, itemCount then corresponds to the number of loaded items.

Automatically update a flow from a changes of another flow (StateFlow) Jetpack Compose

I have a StateFlow from which my List composable collects any changes as a State.
private val _people = MutableStateFlow(personDataList())
val people = _people.asStateFlow()
And inside my viewModel, I perform modifications on _people and I verify that people as a read-only StateFlow is also getting updated. I also have to make a copy of the original _people as an ordinary kotlin map to use for some verifications use-cases.
val copyAsMap : StateFlow<MutableMap<Int, Person>> = people.map {
it.associateBy( { it.id }, { it } )
.toMutableMap()
}.stateIn(viewModelScope, SharingStarted.Eagerly, mutableMapOf())
however, with my attempt above, it (the copyAsMap) doesn't get updated when I try to modify the list (e.g delete) an item from the _people StateFlow
Any ideas..? Thanks!
Edit:
Nothing is collecting from the copyAsMap, I just display the values everytime an object is removed from _person state flow
delete function (triggered by an action somewhere)
private fun delete(personModel: Person) {
_person.update { list ->
list.toMutableStateList().apply {
removeIf { it.id == personModel.id }
}
}
copyAsMap.values.forEach {
Log.e("MapCopy", "$it")
}
}
So based on your comment how you delete the item, that's the problem:
_people.update { list ->
list.removeIf { it.id == person.id }
list
}
You get an instance of MutableList here, do the modification and you "update" the flow with the same instance. And, as StateFlow documentation says:
Values in state flow are conflated using Any.equals comparison in a similar way to distinctUntilChanged operator. It is used to conflate incoming updates to value in MutableStateFlow and to suppress emission of the values to collectors when new value is equal to the previously emitted one.
Which means that your updated list is actually never emitted, because it is equal to the previous value.
You have to do something like this:
_people.update { list ->
list.toMutableList().apply { removeIf { ... } }
}
Also, you should define your state as val _people: MutableStateFlow<List<T>> = .... This would prevent some mistakes you can make.

Use item keys in non-lazy Column

LazyColumn has item keys, in order to tie the item's state to a unique identifier rather than the list index. Is there a way to use item keys in a non-lazy list like this one?
Column {
for (item in list) {
Text(item)
}
}
The reason I ask is because I want to implement SwipeToDismiss to delete items from a list, which only works if you pass a key to a LazyColumn (solution), but my list of dismissable items is nested inside of a LazyColumn, and I can't nest a LazyColumn inside of another LazyColumn's itemContent block (Nesting scrollable in the same direction layouts is not allowed):
val items = listOf<String>(...)
val groups = items.groupBy { it.first() }
LazyColumn {
items(groups, { /* key */ }) { (firstChar, group) ->
// not allowed!
LazyColumn {
items(group, { /* key */ }) { item ->
Text(item)
}
}
}
}
I could wrap the items() call itself in a for loop like this:
val items = listOf<String>(...)
val groups = items.groupBy { it.first() }
LazyColumn {
groups.forEach { (firstChar, group) ->
items(group, { /* key */ }) { item ->
Text(item)
}
}
}
But then state in each of the outer loop's items would be keyed against its index. And I need to provide item keys for groups as well, in order to preserve their state on position changes.
The general pattern for this is,
for (item in items) {
key(item) {
... // use item
}
}
The key composable is special and Compose will use item as a key to detect when the state should move when an the value of item moves in the items collection.

Jetpack Compose collectAsState() does not work with Flow combine()

I want to load data from Firestore, and combine it with other data using Flow combine()
ViewModel:
private val userCurrentProject = MutableStateFlow("")
val projects = repository
.listenToProject() //listening via Firestore snapshot listener, no problem here
.combine(userCurrentProject) { projects, currentProjectName ->
// combine works and called normally
projects.map { project ->
project.apply {
isUserCurrentProject = name == currentProjectName
}
}
}
fun setCurrentProject(projectName: String) = viewModelScope.launch {
userCurrentProject.emit(projectName)
}
Composables:
fun ProjectListScreen(navController: NavHostController, viewModel: ProjectsViewModel) {
val projects by viewModel.projects.collectAsState(initial = emptyList())
// This is where the problem started
// Lazy column not updated when projects flow is emitting new value
// Even Timber log does not called
Timber.d("Projects : $projects")
LazyColumn {
items(projects) { project ->
ProjectItem(project = project) {
currentlySelectedProject = project
scope.launch { bottomSheetState.show() }
}
}
}
The flow is working normally, but the state never got updated, I don't know why. Maybe this is a problem with collectAsState()?
But the state is updated when I navigate to next screen (add new project screen), then press back (popBackStack)
NB: using asLiveData() with observeAsState() does not work either.
I've finally found the answer
The culprit is that a State of custom object/class behaves differently than a state of primitives (String, Int, etc.)
For a State of object, you need to use copy()
So I just changed this part of ViewModel
val projects = repository
.listenProject()
.combine(userCurrentProject) { projects, currentProjectName ->
projects.map { project ->
// use copy instead of apply
val isCurrentProject = project.name == currentProjectName
project.copy(isUserCurrentProject = isCurrentProject)
}
}

rxswift viewmodel with input output

I am trying to achieve something similar in rxswift example project from RxSwift repo. But in my case there are dependent observables. I couldn't find any solution without using binding in viewmodel
Here is the structure of my viewmodel:
First the definitions of input, output and viewmodel
typealias UserListViewModelInput = (
viewAppearAction: Observable<Void>,
deleteAction: Observable<Int>
)
typealias UserListViewModelOutput = Driver<[User]>
typealias UserListViewModel = (UserListViewModelInput, #escaping UserApi) -> UserListViewModelOutput
Then there is actual implementation which doesn't compile.
let userListViewModel: UserListViewModel = { input, loadUsers in
let loadedUserList = input.viewAppearAction
.flatMapLatest { loadUsers().materialize() }
.elements()
.asDriver(onErrorDriveWith: .never())
let userListAfterDelete = input.deleteAction
.withLatestFrom(userList) { index, users in
users.enumerated().compactMap { $0.offset != index ? $0.element : nil }
}
.asDriver(onErrorJustReturn: [])
let userList = Driver.merge([loadedUserList, userListAfterDelete])
return userList
}
Viewmodel has two job. First load the user list. Second is delete a user at index. The final output is the user list which is downloaded with UserApi minus deleted users.
The problem in here in order the define userList I need to define userListAfterDelete. And in order to define userListAfterDelete I need to define userList.
So is there a way to break this cycle without using binding inside view model? Like a placeholder observable or operator that keeps state?
This is a job for a state machine. What you will see in the code below is that there are two actions that can affect the User array. When the view appears, a new array is downloaded, when delete comes in, a particular user is removed.
This is likely the most common pattern seen in reactive code dealing with state. So common that there are whole libraries that implement some variation of it.
let userListViewModel: UserListViewModel = { input, loadUsers in
enum Action {
case reset([User])
case delete(at: Int)
}
let resetUsers = input.viewAppearAction
.flatMapLatest { loadUsers().materialize() }
.compactMap { $0.element }
.map { Action.reset($0) }
let delete = input.deleteAction.map { Action.delete(at: $0) }
return Observable.merge(resetUsers, delete)
.scan(into: [User](), accumulator: { users, action in
switch action {
case let .reset(newUsers):
users = newUsers
case let .delete(index):
users.remove(at: index)
}
})
.asDriver(onErrorJustReturn: [])
}

Resources