Search code examples
androidkotlinandroid-jetpack-composeandroid-viewmodelkotlin-stateflow

View not updating after update to viewModel


I have the below list of items which has a checkbox next to each item in the form of a button. When the button is not selected, a black empty circle should show. When the button is selected there should be a checkmark inside the black circle.

From running the code and looking at the debug logs, the view model is updating, however my view is not. What's missing?

data class ListItem(
    var title: String,
    var checked: Boolean
)
class ItemsViewModel: ViewModel() {
    private var _reportDetails: MutableStateFlow<MutableList<String>> = MutableStateFlow(mutableListOf())
    val reportDetails = _reportDetails.asStateFlow()

    private var _issues: MutableStateFlow<MutableList<ListItem>> =
        MutableStateFlow(mutableListOf(
            ListItem(title = "Item 1", checked = false),
            ListItem(title = "Item 2", checked = false),
            ListItem(title = "Item 3", checked = false),
            ListItem(title = "Item 4", checked = false),
            ListItem(title = "Item 5", checked = false),
            ListItem(title = "Item 6", checked = false),
            ListItem(title = "Item 7", checked = false),
            ListItem(title = "Item 8", checked = false),
            ListItem(title = "Item 9", checked = false)
        ))
    val issues = _issues.asStateFlow()

    fun addToReportDetails(reportDetail: String) {
        _reportDetails.update {
            it.add(reportDetail)
            return
        }
    }

    fun removeFromReportDetails(reportDetail: String) {
        _reportDetails.update {
            it.remove(reportDetail)
            return
        }
    }

    fun updateOnlineIssuesAtIndex(index: Int, checked: Boolean) {
        _issues.update {
            it[index].checked = checked
            return
        }
    }
}
@Composable
fun ReportView(
    itemsViewModel: ItemsViewModel,
) {
    val reportDetails by itemsViewModel.reportDetails.collectAsStateWithLifecycle()
    val issues by itemsViewModel.issues.collectAsStateWithLifecycle()

    LazyColumn(
        modifier = Modifier
            .padding(50.dp)
    ) {
        item {
            for ((index, issue) in issues.withIndex()) {
                Column(
                    verticalArrangement = Arrangement.spacedBy(5.dp),
                ) {
                    Row(
                        verticalAlignment = Alignment.CenterVertically,
                        horizontalArrangement = Arrangement.spacedBy(10.dp)
                    ) {
                        Text(text = issue.title)

                        Button(
                            onClick = {
                                if (!issue.checked) {
                                    itemsViewModel.updateOnlineIssuesAtIndex(index = index, checked = true)
                                    itemsViewModel.addToReportDetails(issue.title)
                                    Log.d("ReportUser", "reportDetails = $reportDetails")
                                    Log.d("ReportUser", "issue.checked = ${issue.checked}")
                                } else {
                                    itemsViewModel.updateOnlineIssuesAtIndex(index = index, checked = false)
                                    itemsViewModel.removeFromReportDetails(issue.title)
                                    Log.d("ReportUser", "reportDetails = $reportDetails")
                                    Log.d("ReportUser", "issue.checked = ${issue.checked}")
                                }
                            }
                        ) {
                            Box(
                                modifier = Modifier
                                    .size(25.dp)
                                    .border(
                                        width = 1.dp,
                                        color = Color.Black,
                                        shape = CircleShape
                                    )
                            ) {
                                if (issue.checked) {
                                    Icon(
                                        imageVector = Icons.Rounded.CheckCircle,
                                        contentDescription = "null",
                                        modifier = Modifier.size(25.dp),
                                        tint = Color.Black,
                                    )
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

Solution

  • The only reason it doesn't work is you need to update value of MutableState to trigger recomposition. You are changing property of the one item, which still exits in same MutableList.

    fun updateOnlineIssuesAtIndex(index: Int, checked: Boolean) {
        _issues.update {
            it.mapIndexed { itemIndex, item ->
                if (itemIndex == index) item.copy(checked = checked)
                else item
            }.toMutableList()
        }
    }
    

    This change alone fixes the issue. It's nothing to do with StateFlow. However by creating a new list each time you check a box is overkill, you might end up creating a list with 100s of items and even worse by doing this you are recomposing every item in the LazyColumn.

    Correct approach would be using SnapshotStateList which can detect adding or removing or replacing existing item with new one. This means you need to use copy to set new item for that index. When that happens and if you abide stability you can check or uncheck any item only by composing that item instead of every item in LazyColumn.

    private var _issues: MutableStateFlow<SnapshotStateList<ListItem>> =
        MutableStateFlow(
            mutableStateListOf(
                ListItem(title = "Item 1", checked = false),
                ListItem(title = "Item 2", checked = false),
                ListItem(title = "Item 3", checked = false),
                ListItem(title = "Item 4", checked = false),
                ListItem(title = "Item 5", checked = false),
                ListItem(title = "Item 6", checked = false),
                ListItem(title = "Item 7", checked = false),
                ListItem(title = "Item 8", checked = false),
                ListItem(title = "Item 9", checked = false)
            )
        )
    val issues = _issues.asStateFlow()
    

    Update single item at current index

    fun updateOnlineIssuesAtIndex(index: Int, checked: Boolean) {
        _issues.update {
            it.getOrNull(index)?.let { currentItem ->
                it[index] = currentItem.copy(checked = checked)
            }
            return
        }
    }
    

    But in ui you also need to create new functions with stable params to ensure that single item is recomposed. Also you need immutable data class to ensure it's stable. and @Immutable annotation if any of this data class params are unstable. However this won't be an issue in the future if you enable strong skipping.

    You can refer answer below to do changes to make sure that only touched item recomposes on click.

    Jetpack Compose lazy column all items recomposes when a single item update