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
}
Related
I'm trying to send a variable value from my ViewModel to my composable screen. I tried using the debugger to find out where it gets stuck. It seems like it sends the value but never actually receives it.
This is the code I'm using:
NewEvent.kt
#Composable
fun NewEvent(
viewModel: NewEventViewModel = viewModel(),
navController: NavController
){
val context = LocalContext.current
LaunchedEffect(context){
viewModel.newEventType.collect { eventType ->
Toast.makeText(context, eventType.toString(), Toast.LENGTH_SHORT).show()
}
}
}
changeEventType() gets called here
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
) {
eventTypeList.forEach {
if(it != viewModel.event.eventType && it != EventType.UNKNOWN) {
DropdownMenuItem(
onClick = { viewModel.changeEventType(it); menuExpanded = false },
text = { Text(stringResource(context.resources.getIdentifier(it.toString().lowercase(), "string", context.packageName))) }
)
}
}
}
NewEventViewModel.kt
private val newEventTypeChannel = Channel<EventType>()
val newEventType = newEventTypeChannel.receiveAsFlow()
fun changeEventType(newEventType: EventType){
viewModelScope.launch {
newEventTypeChannel.send(newEventType)
}
}
I downloaded a sample project from GitHub using this exact implementation and it worked, I'm not sure what I'm missing here.
If you want to display toast message, you don't have to create separate composable function for it. Since Toast is dynamic and does not need to be recomposed, you don't need composable function for it.
It would be more clear and better to implement it like this (in one composable function)
val context = LocalContext.current
LaunchedEffect(context){
viewModel.newEventType.collect { eventType ->
Toast.makeText(context, eventType.toString(), Toast.LENGTH_SHORT).show()
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
) {
eventTypeList.forEach {
if(it != viewModel.event.eventType && it != EventType.UNKNOWN) {
DropdownMenuItem(
onClick = { viewModel.changeEventType(it); menuExpanded = false },
text = { Text(stringResource(context.resources.getIdentifier(it.toString().lowercase(), "string", context.packageName))) }
)
}
}
}
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 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
I have Json Data through which I'm doing this .
fun getFact(context: Context) = viewModelScope.launch{
try {
val format = Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
}
val factJson = context.assets.open("Facts.json").bufferedReader().use {
it.readText()
}
val factList = format.decodeFromString<List<FootballFact>>(factJson)
_uiState.value = ViewState.Success(factList)
} catch (e: Exception) {
_uiState.value = ViewState.Error(exception = e)
}
}
This is the way i m getting my job from viewModle in Ui sceeen
viewModel.getFact(context)
when (val result =
viewModel.uiState.collectAsState().value) {
is ViewState.Error -> {
Toast.makeText(
context,
"Error ${result.exception}",
Toast.LENGTH_SHORT
).show()
}
is ViewState.Success -> {
val factsLists = mutableStateOf(result.fact)
val randomFact = factsLists.value[0]
FactCard(quote = randomFact.toString()) {
factsLists.value.shuffled()
}
}
}
I have fact card where i want to show that fact .also i have there a lambda for click where i want my factList to refresh every time whenever is clicked.
#Composable
fun FactCard(quote: String , onClick : ()-> Unit) {
val fact = remember { mutableStateOf(quote)}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.
.clickable { onClick() }
) {
Text(.. )
}
}
I don't know how to approach this, i think there is silly thing I'm doing.
factsLists.shuffled() returns a new list with the elements of this list randomly shuffled.
Composables can only recompose when you update state data. You aren't doing that. Your click event should return the new quote that you want to display. You then set fact.value to the new quote. Calling fact.value with a new value is what triggers a recompose:
when (val result = viewModel.uiState.collectAsState().value) {
is ViewState.Error -> {
Toast.makeText(
context,
"Error ${result.exception}",
Toast.LENGTH_SHORT
).show()
}
is ViewState.Success -> {
val factsLists = mutableStateOf(result.fact)
val randomFact = factsLists.value[0]
FactCard(quote = randomFact.toString()) {
return factsLists.value.shuffled()[0]
}
}
}
#Composable
fun FactCard(quote: String , onClick : ()-> String) {
var fact = remember { mutableStateOf(quote)}
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.
.clickable {
fact.value = onClick()
}
) {
Text(.. )
}
}
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
}
}