android-jetpack-composeandroid-viewmodel

Jetpack Composable isn't updating(redrawing) after the viewModel is updated


I have a mutableStateListOf(ReturnItemExamples) and each of the objects in the list are being displayed in a LazyColumn.

The thing I am having trouble with is once I update the quantity using the OutlinedTextField, and it calls the API (on the viewModel) to update the record, after the API returns successful results and updates those to the mutableStateListOf(ReturnItemExamples) list, the update to the quantity, isn't being shown in the OutlinedTextField. I believe that the update is happening and that it is triggering a redraw by the composable, because I have other fields that are being updated, the only field I have trouble is the OutlinedTextField. That is the only one that isn't updating on screen.

Here is the data model that has the quantity property:

class ReturnItemExample (
    val itemCode: String? = null,
    val itemDescription: String? = null,
    var quantity: Int = 0
)

Here's the viewModel:

@HiltViewModel
class ReturnsViewModelExample @Inject constructor(
    val app: Application,
    val authToken: String,
    private val repository: ReturnsRepository,
) : AndroidViewModel(app) {

    val returnItems = mutableStateListOf<ReturnItemExample>()

    private fun updateReturnExample(quantity: Int, index: Int) {
        viewModelScope.launch {

        // Call to an API here returns the updated ReturnItemExample object and I update it here
        // with a function that sets the item from the API response to the item in the array at
        // the specified index
                    returnItems[index] = API.response

        }
    }
}

Here is the listview:

@Composable
fun ReturnsListViewExample(
    viewModel: ReturnsViewModelExample,
) {

    val itemIndex = remember { mutableIntStateOf(0) }

    Column {
        Box() {
            LazyColumn(state = rememberLazyListState()) {
                itemsIndexed(viewModel.returnItems) { index, _ ->
                    ReturnItemViewExample(
                        viewModel = viewModel,
                        itemIndex = index
                    )
                }
            }
        }
    }
}

Here is the composable that show the OutlinedTextField I am having trouble with:

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ReturnItemViewExample(
    viewModel: ReturnsViewModelExample,
    itemIndex: Int
) {

    val keyboardController = LocalSoftwareKeyboardController.current
    val itemData = viewModel.returnItems[itemIndex]
    var testDisplayQuantity by remember { mutableStateOf(itemData.quantity.toString()) }

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp)
            .padding(bottom = 8.dp),
        horizontalArrangement = Arrangement.Center,
        verticalAlignment = Alignment.CenterVertically
    ) {

        Text(
            text = "Test Quantity",
            fontSize = 14.sp,
            fontWeight = FontWeight.SemiBold,
            textAlign = TextAlign.Center
        )

        OutlinedTextField(
            value = testDisplayQuantity,
            onValueChange = { testDisplayQuantity = it },
            textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
            maxLines = 1,
            modifier = Modifier
                .fillMaxWidth(.5f)
                .padding(horizontal = 8.dp)
                .onKeyEvent {

                    if (it.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_ENTER) {

                        if (validQuantity(testDisplayQuantity.toInt())) {
                            viewModel.updateReturnExample(testDisplayQuantity.toInt(), itemIndex)
                        }
                    }
                    true
                },
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Number,
                imeAction = ImeAction.Done
            ),
            keyboardActions = KeyboardActions(
                onDone = {
                    keyboardController?.hide()

                    if (validQuantity(testDisplayQuantity.toInt())) {
                        viewModel.updateReturnExample(testDisplayQuantity.toInt(), itemIndex)
                    }
                }),
            shape = RoundedCornerShape(10.dp)
        )
    }
}

Solution

  • Have a look at the following line in your code:

    var testDisplayQuantity by remember { mutableStateOf(itemData.quantity.toString()) }
    

    It creates a new state variable of type String and initializes it to itemData.quantity.toString(). After this initialization, there is no more connection to the original itemData object. It is completely independent.

    I recommend two steps to resolve this problem:

    1. You need to introduce logic that updates the quantity of an item in the ViewModel. When you modify it directly on itemData, it will have no effect. Jetpack Compose cannot detect when you modify one property of a state object. Instead, it can only recompose once a reference changed.
    2. You can either completely remove the additional testDisplayQuantity state variable or you can introduce logic that refreshes the testDisplayQuantity variable whenever the itemData changes.

    Step 1)

    In order to update the quantity of an item, create a ViewModel function similar to this:

    fun updateQuantity(index: Int, quantity: String) {
        returnItems.set(index, returnItems[index].copy(quantity = quantity.toInt()))
    }
    

    We replace an instance in the list on position index with the set function and assign a new instance with updated quantity using the copy function. Jetpack Compose detects the change and will recompose the LazyList.

    Then call it in onValueChange like this:

    onValueChange = { 
        viewModel.updateQuantity(itemIndex, it) 
    }
    

    Step 2)

    Replace all occurences of testDisplayQuantity with itemData.quantity.toString() and delete the variable.

    Alternatively, you can use a LaunchedEffect to update your testDisplayQuantity variable whenever the itemData changes:

    val itemData = viewModel.returnItems[itemIndex]
    var testDisplayQuantity by remember { mutableStateOf(itemData.quantity.toString()) }
    
    LaunchedEffect(itemData) {
        testDisplayQuantity = itemData.quantity.toString()
    }
    

    As a side note, please consider to pass the item itself to the ReturnItemViewExample Composable instead of the index in the list. It results in cleaner code and makes your local itemData state variable redundant.

    items(viewModel.returnItems) { item ->
        ReturnItemViewExample(
            viewModel = viewModel,
            itemData = item
        )
    }
    

    Then update your Composable signature as follows:

    fun ReturnItemViewExample(
        viewModel: ReturnsViewModelExample,
        itemData: ReturnItemExample 
    ) {
        //...
    }
    

    Also, you should update your ReturnItemExample class as follows:

    data class ReturnItemExample (
        val itemCode: String? = null,
        val itemDescription: String? = null,
        val quantity: Int = 0
    )
    

    When you have a var inside of a data class, this will usually result in things not working in Jetpack Compose.