Use item keys in non-lazy Column - android-jetpack-compose

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.

Related

Button within ForEach Loop

I am creating an autocomplete search TextField. Currently, it is a VStack of categories and I want the user to tap a category to select it, and have the category become the TextField value.
This code works, but it is just text and nothing happens on tap:
var body: some View {
...
Form {
...
ForEach(observed.results, id: \.id) {
Text($0.result).foregroundColor(.gray)
}
...
}
...
}
When I try to turn them into buttons, XCode throws a Trailing closure passed to parameter of type 'FormStyleConfiguration' that does not accept a closure error.
$search is the TextField's text attribute value.
#State private var search: String = ""
...
func setSearch(selected: String) {
self.search = selected
}
...
var body: some View {
...
Form {
...
ForEach(observed.results, id: \.id) {
Button( action: { self.setSearch(selected: $0.result) }) {
Text($0.result).foregroundColor(.gray)
}
}
...
}
...
}
How can I change state to update text field on tap?
Changing my Form to List produced more descriptive errors.
Used an explicit argument for my ForEach loop.
I intended $0 to be an implicit argument from my ForEach loop. However, when used within another closure, e.g. Button { } or .onTapGesture() { }, the nested closure threw an error believing the implicit argument to belong to itself.

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.

Compose: LazyColumn recomposes all items on single item update

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

Get count of Observable array before returning it

In my ViewModel file I have an observable array created after applying map on it. Now before returning it I want to check if it has any content or not. If there is nothing in there I want to return it without applying map. Following is my code:
func retrieveDeals(location: CLLocation?) -> Observable<[SaleItem]> {
let specials = nearestFlightSpecials.retrieveNearestFlightSpecials(userLocation: location)
let happyHourDeals = specials.map {
$0.filter { $0.isHappyHour }
}
return happyHourDeals
}
Before I return happyHourDeals I want to check if it contains any element or not. The above array is subscribed in view but I don't want to apply the above logic there. I want to keep it here in ViewModel.
I suspect what you want to do is filter out empty output:
func retrieveDeals(location: CLLocation?) -> Observable<[SaleItem]> {
let specials = nearestFlightSpecials.retrieveNearestFlightSpecials(userLocation: location)
let happyHourDeals = specials.map {
$0.filter { $0.isHappyHour }
}
.filter { !$0.isEmpty } // this is the line you need.
return happyHourDeals
}
Terminology is important here. Observables don't "contain" values. Observables don't return values, they emit events.
Your happyHourDeals will still be returned but with the filter line, it will no longer emit empty arrays. What this means is that whatever is subscribed to the value returned will not be updated if specials.map { $0.filter { $0.isHappyHour } } emits an empty array.

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