I have LazyColumn with swipe-to-delete. When I swipe an item, it is deleted by viewModel. The problem is that if I swipe the item away, the LazyColumn doesn't update the position of other items (as shown in GIF).
Here's my code implementation:
#ExperimentalMaterialApi
#Composable
fun Screen() {
val livedata = viewModel.itemsLiveData.observeAsState()
val stateList = remember { mutableStateListOf<Data>() }
stateList.addAll(livedata.value!!)
SwipableLazyColumn(stateList)
}
#ExperimentalMaterialApi
#Composable
fun SwipableLazyColumn(
stateList: SnapshotStateList<Data>
) {
LazyColumn {
items(items = stateList) { item ->
val dismissState = rememberDismissState()
if (dismissState.isDismissed(EndToStart) || dismissState.isDismissed(StartToEnd)) {
viewModel.swipeToDelete(item)
}
SwipeToDismiss(
state = dismissState,
directions = setOf(StartToEnd, EndToStart),
dismissThresholds = {
FractionalThreshold(0.25f)
},
background = {},
dismissContent = {
MyData(item)
}
)
}
}
}
I use SnapshotStateList as it's suggested here. Although I don't use swapList because it clears out all items
ViewModel:
class MyViewModel #Inject internal constructor(
private val itemRepository: ItemRepository
) : BaseViewModel(), LifecycleObserver {
private val itemsList = mutableListOf<MyData>()
private val _itemsLiveData = MutableLiveData<List<MyData>>()
val itemsLiveData: LiveData<List<MyData>> = _itemsLiveData
init {
loadItems()
}
private fun loadItems() {
viewModelScope.launch {
itemRepository.getItems().collect {
when (it) {
is Result.Success -> onItemsLoaded(it.data)
is Result.Error -> {
onItemsLoaded(emptyList())
}
}
}
}
}
private fun onItemsLoaded(itemsList: List<MyData>) {
itemsList.clear()
itemsList.addAll(notifications)
_itemsLiveData.value = if (itemsList.isNotEmpty()) {
itemsList
} else {
null
}
}
fun swipeToDelete(item: MyData) {
if (itemsList.size == 0) return
viewModelScope.launch {
when (
val result =
itemRepository.deletelItem(item)
) {
is Result.Success -> {
onItemDeleted(item)
}
is Result.Error -> {
showSnackBar(
"error"
)
}
}
}
}
private fun onItemDeleted(item: MyData) {
itemsList.remove(item)
_itemsLiveData.value = itemsList
}
}
You should refresh your list inside viemodel(delete item) and return modified list right here
var tempList = itemList
ItemList.clear()
ItemList.addAll(tempList)
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
Related
I have a list which is stored inside a Viewmodel via Stateflow.
class FirstSettingViewModel : ViewModel() {
private val _mRoomList = MutableStateFlow<List<InitRoom>>(mutableListOf())
val mRoomList: StateFlow<List<InitRoom>> = _mRoomList
...
I observe the flow via collectAsState(). The LazyColumn consists of Boxes which can be clicked.
val roomList = mViewModel.mRoomList.collectAsState()
Dialog {
...
LazyColumn(...) {
items(roomList.value, key = { room -> room.room_seq}) { room ->
Box(Modifier.clickable {
**mViewModel.selectItem(room)**
}) {...}
}
}
}
When a click event occurs, the viewModel changes the 'isSelected' value via a copied list like this.
fun selectItem(room: InitRoom) = viewModelScope.launch(Dispatchers.IO) {
try {
val cpy = mutableListOf<InitRoom>()
mRoomList.value.forEach {
cpy.add(it.copy())
}
cpy.forEach {
it.isSelected = it.room_seq == room.room_seq
}
_mRoomList.emit(cpy)
} catch (e: Exception) {
ErrorController.showError(e)
}
}
When in an xml based view and a ListAdapter, this code will work well, but in the above compose code, it doesn't seem to recompose the LazyColumn at all. What can I do to re-compose the LazyColumn?
Use a SnapshotStateList instead of an ordinary List
change this,
private val _mRoomList = MutableStateFlow<List<InitRoom>>(mutableListOf())
val mRoomList: StateFlow<List<InitRoom>> = _mRoomList
to this
private val _mRoomList = MutableStateFlow<SnapshotStateList<InitRoom>>(mutableStateListOf())
val mRoomList: StateFlow<SnapshotStateList<InitRoom>> = _mRoomList
I am trying to update a list which is kept as state of a composable view, but the composable view is not recomposed even though the values of list are changed.
var list = remember {mutableStateOf(getListOfItems())}
ItemListView({ selectedItem ->
list.value = updateList(selectedItem, list.value)
}, list.value)
private fun updateList(selectedItem: String,
itemsList: List<Product>): List<Product> {
for (item in itemsList){
// selected item in the view is updated
item.selected = item.name == selectedItem
}
return itemsList
}
Any idea why the composable is not getting updated? I noticed the issue happens when using a List as state.
You need to update value of MutableState, which is a List in your example, to trigger recomposition not any property of value you set to MutableState.
#Stable
interface MutableState<T> : State<T> {
override var value: T
operator fun component1(): T
operator fun component2(): (T) -> Unit
}
But there is another function called mutableStateListOf() which can trigger recomposition when you add or remove items, or update existing one with new instance.
val list =remember {
mutableStateListOf< Product>().apply {
addAll(getListOfItems())
}
}
The jetpack compose looks at your object itself to see if it has changed to decide whether to update it or not. You need to update the list itself to do that:
data class A(var v: Int)
#Composable
fun Test1() {
var list by remember { mutableStateOf(listOf(A(1), A(2), A(3))) }
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(list) {
Text(text = it.toString())
}
item {
// it does not work
Button(onClick = {
list[0].v = 2
}) { Text("Change value") }
// it works
Button(onClick = {
list = list.map { it.copy(v = it.v + 1) }
}) { Text("Change list") }
}
}
}
Also, you can use mutableStateListOf, which will monitor the addition and removal of elements.
#Composable
fun Test2() {
val list = remember { mutableStateListOf(A(1), A(2), A(3)) }
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(list) {
Text(text = it.toString())
}
item {
// it not work
Button(onClick = {
list[0].v = 2
}) { Text("Change value") }
// it work
Button(onClick = {
list.add(A(3))
}) { Text("Change list") }
}
}
}
In your case, you can represent the selection like this:
val selectedList = remember { mutableStateListOf<String>() }
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(list) {
val selected = selectedList.contains(it.name)
Text(text = if (selected) "selected" else "not selected")
Button(onClick = {
if (!selected) selectedList.add(it.name)
}) { Text("Select it") }
}
}
Fixed this by taking a copy of list with new values :
private fun updateList(selectedItem: String,
itemsList: List<Product>): List<Product> {
val updatedList = itemsList.map { product ->
if(product.name == selectedItem) {
product.copy(selected = true)
} else {
product.copy(selected = false)
}
}
return updatedList
}
In the following viewModel code I am generating a list of items from graphQl server
private val _balloonsStatus =
MutableStateFlow<Status<List<BalloonsQuery.Edge>?>>(Status.Loading())
val balloonsStatus get() = _balloonsStatus
private val _endCursor = MutableStateFlow<String?>(null)
val endCursor get() = _endCursor
init {
loadBalloons(null)
}
fun loadBalloons(cursor: String?) {
viewModelScope.launch {
val data = repo.getBalloonsFromServer(cursor)
if (data.errors == null) {
_balloonsStatus.value = Status.Success(data.data?.balloons?.edges)
_endCursor.value = data.data?.balloons?.pageInfo?.endCursor
} else {
_balloonsStatus.value = Status.Error(data.errors!![0].message)
_endCursor.value = null
}
}
}
and in the composable function I am getting this data by following this code:
#Composable
fun BalloonsScreen(
navHostController: NavHostController? = null,
viewModel: SharedBalloonViewModel
) {
val endCursor by viewModel.endCursor.collectAsState()
val balloons by viewModel.balloonsStatus.collectAsState()
AssignmentTheme {
Column(modifier = Modifier.fillMaxSize()) {
when (balloons) {
is Status.Error -> {
Log.i("Reyjohn", balloons.message!!)
}
is Status.Loading -> {
Log.i("Reyjohn", "loading..")
}
is Status.Success -> {
BalloonList(edgeList = balloons.data!!, navHostController = navHostController)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(onClick = { viewModel.loadBalloons(endCursor) }) {
Text(text = "Load More")
}
}
}
}
#Composable
fun BalloonList(
edgeList: List<BalloonsQuery.Edge>,
navHostController: NavHostController? = null,
) {
LazyColumn {
items(items = edgeList) { edge ->
UserRow(edge.node, navHostController)
}
}
}
but the problem is every time I click on Load More button it regenerates the view and displays a new set of list, but I want to append the list at the end of the previous list. As far I understand that the list is regenerated as the flow I am listening to is doing the work behind this, but I am stuck here to get a workaround about how to achieve my target here, a kind hearted help would be much appreciated!
You can create a private list in ViewModel that adds List<BalloonsQuery.Edge>?>
and instead of
_balloonsStatus.value = Status.Success(data.data?.balloons?.edges)
you can do something like
_balloonsStatus.value = Status.Success(myLiast.addAll(
data.data?.balloons?.edges))
should update Compose with the latest data appended to existing one
I'm building a jetpack compose app and I want my view model to tell my compose function to display a snack bar by sending it an event. I have read multiple blog posts about the Single Live Event case with Kotlin and I tried to implement it with Compose and Kotlin Flow. I managed to send the event from the view model (I see it in the logs) but I don't know how to receive it in the composable function. Can someone help me figure it out please? Here is my implementation.
class HomeViewModel() : ViewModel() {
sealed class Event {
object ShowSheet : Event()
object HideSheet : Event()
data class ShowSnackBar(val text: String) : Event()
}
private val eventChannel = Channel<Event>(Channel.BUFFERED)
val eventsFlow: Flow<Event> = eventChannel.receiveAsFlow()
fun showSnackbar() {
Timber.d("Show snackbar button pressed")
viewModelScope.launch {
eventChannel.send(Event.ShowSnackBar("SnackBar"))
}
}
}
#Composable
fun HomeScreen(
viewModel: HomeViewModel,
) {
val context = LocalContext.current
val scaffoldState = rememberScaffoldState()
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val lifecycleOwner = LocalLifecycleOwner.current
val eventsFlowLifecycleAware = remember(viewModel.eventsFlow, lifecycleOwner) {
eventsFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}
LaunchedEffect(sheetState, scaffoldState.snackbarHostState) {
eventsFlowLifecycleAware.onEach {
when (it) {
HomeViewModel.Event.ShowSheet -> {
Timber.d("Show sheet event received")
sheetState.show()
}
HomeViewModel.Event.HideSheet -> {
Timber.d("Hide sheet event received")
sheetState.hide()
}
is HomeViewModel.Event.ShowSnackBar -> {
Timber.d("Show snack bar received")
scaffoldState.snackbarHostState.showSnackbar(
context.getString(it.resId)
)
}
}
}
}
ModalBottomSheetLayout(
sheetState = sheetState,
sheetContent = {
Text("Sheet")
}
) {
Button(
onClick = {
viewModel.showSheet()
}
) {
Text("Show SnackBar")
}
}
}
For reference, I've used these blog posts:
Android SingleLiveEvent Redux with Kotlin Flow
A safer way to collect flows from Android UIs
Ok, I was using the wrong approach, I must not send events, I must update the view state and check if I should show the snackbar when recomposing. Something like that:
You store the SnackBar state in the view model
class HomeViewModel: ViewModel() {
var isSnackBarShowing: Boolean by mutableStateOf(false)
private set
private fun showSnackBar() {
isSnackBarShowing = true
}
fun dismissSnackBar() {
isSnackBarShowing = false
}
}
And in the view you use LaunchedEffect to check if you should show the snackbar when recomposing the view
#Composable
fun HomeScreen(
viewModel: HomeViewModel,
) {
val onDismissSnackBarState by rememberUpdatedState(newValue = onDismissSnackBar)
if (isSnackBarShowing) {
val snackBarMessage = "Message"
LaunchedEffect(isSnackBarShowing) {
try {
when (scaffoldState.snackbarHostState.showSnackbar(
snackBarMessage,
)) {
SnackbarResult.Dismissed -> {
}
}
} finally {
onDismissSnackBarState()
}
}
}
Row() {
Text(text = "Hello")
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
viewModel.showSnackBar()
}
) {
Text(text = "Show SnackBar")
}
}
}
I think you have to collect eventsFlowLifecycleAware as a state to trigger a Composable correctly.
Try removing the LaunchedEffect block, and using it like this:
val event by eventsFlowLifecycleAware.collectAsState(null)
when (event) {
is HomeViewModel.Event.ShowSnackBar -> {
// Do stuff
}
}
I want to display information that RecyclerView have no items, but I can't check if Firestore collection is empty. How to set some kind of listener which check if RecyclerView have items or not?
I'm assuming you're using Firebase UI (otherwise you would already have a query callback to hook into). In your FirestoreRecyclerAdapter, you can override onDataChanged & onError:
typealias DataChangedListener = (count: Int) -> Unit
typealias ErrorListener = (error: FirebaseFirestoreException) -> Unit
class MyAdapter(
options: FirestoreRecyclerOptions<MyModel>,
private val onDataChangedListener: DataChangedListener = {},
private val onErrorListener: ErrorListener = {}
) : FirestoreRecyclerAdapter<MyModel, MyViewHolder>(options) {
...
// Notify Activity/Fragment/ViewModel
override fun onDataChanged() =
onDataChangedListener.invoke(itemCount)
// Notify Activity/Fragment/ViewModel
override fun onError(e: FirebaseFirestoreException) =
onErrorListener.invoke(e)
}
You can use it like this:
recyclerView.adapter = MyAdapter(
options,
{ count -> showHideNoData(count > 0) },
{ error -> showError(error) }
)
...
fun showHideNoData(haveData: Boolean) {
recyclerView.isVisible = haveData
noDataView.isVisible = !haveData
errorView.isVisible = false
}
fun showError(error: FirebaseFirestoreException) {
recyclerView.isVisible = false
noDataView.isVisible = false
errorView.isVisible = true
// Logging & other UI changes
}
If it will be useful here is my solution. I simply called this function in the fragment where RecyclerView lives:
private fun setUpRecyclerView() {
val viewManagerPortrait = LinearLayoutManager(activity)
val viewManagerLandscape = GridLayoutManager(activity, 3)
val query = docRef.orderBy("title", Query.Direction.ASCENDING)
query.addSnapshotListener { p0, _ ->
if (p0 != null) {
if(p0.size() > 0) {
emptyAds.visibility = View.GONE;
listItems.visibility = View.VISIBLE
}else {
emptyAds.visibility = View.VISIBLE;
listItems.visibility = View.GONE
}
}
}
val options = FirestoreRecyclerOptions.Builder<Item>()
.setQuery(query,Item::class.java)
.setLifecycleOwner(this)
.build()
mAdapter = ItemCardsAdapter(this,options)
listItems.apply {
setHasFixedSize(true)
// use a linear layout manager if portrait, grid one else
layoutManager = if(activity!!.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE)
viewManagerLandscape
else
viewManagerPortrait
adapter = mAdapter
}
}
As you can see the if statement (inside the SnapShotListener) on size checks whether the database at that reference is empty, showing a message in the layout instead of the RecyclerView.