Search code examples
kotlinandroid-jetpack-composeviewmodelrecompose

(Android Studio) Jetpack Compose, ViewModel updates, but UI not recomposing


I'm trying to test the ViewModel concept in Jetpack Compose, and I've been stumped on this problem and it lost me a whole day unable to figure it out. When debugging the app, I see that the ViewModel is actually updating, but it's not causing my UI to recompose after the update.

As an even smaller test of the concept, I created an app with literally just a Text and Button element composable. Clicking the button is supposed to change the text after updating the ViewModel; the ViewModel updates, but the text doesn't change. I was coding based on example code that works. I followed its structure, 100% accurately as far as I'm aware, yet mine doesn't work.

If I copy-paste the working demo code into a new blank slate project, it works so there is obviously something I'm missing. I also tried eyeing it line-by-line, and found nothing that explains the cause. If I add my own mutableStateOf variables to the working code's viewmodel and use it in its app, that also works. If I rename variables, function names, etc in the working code, it still works. Basically, doing anything in the demo code works as it should/expected. But creating one from scratch that follows its format exactly, the UI doesn't recompose.

I created a simple test app with just a Text and Button. An instance of a ViewModel extending class was created for it. Clicking the Button should have changed the content of the Text. Instead, nothing happened. But the value in the ViewModel extending class was updated. There are no errors or warnings relevant to the issue, and the preview works fine.

My code (NOT WORKING)

TestViewModel.kt:

class TestViewModel: ViewModel(){
    var testVar by mutableStateOf("Hi")

    fun changeText(){
        testVar = "New"
    }
}

MainActivity.kt:

class MainActivity: ComponentActivity(){
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
                TestAppTheme {
                        Surface(
                                modifier = Modifier.fillMaxSize(),
                                color = MaterialTheme.colorScheme.background
                        ) {
                                Greeting(viewModel = viewModel())
                        }
                }
        }
    }
}

@Composable
fun Greeting(viewModel: TestViewModel = viewModel()) {
        DisplayOnScreen(testVar = viewModel.testVar, changeText = {viewModel.changeText()})
}

@Composable
fun DisplayOnScreen(testVar: String, changeText: () -> Unit){
        Column() {
                Text(testVar)
                Button(onClick = {changeText}){
                        Text("Click")
                }
        }
}

@Preview (showBackground = true, showSystemUi = true)
@Composable
fun GreetingPreview(viewModel: TestViewModel = viewModel()) {
        TestAppTheme {
                Greeting(viewModel = viewModel())
        }
}

Demo code (WORKING)

TestingViewModel.kt:

class TestingViewModel : ViewModel() {
    var isCelsius by mutableStateOf(true)
    var result by mutableStateOf("")


    fun convertTemp(temp: String) {
        result = try {
            val tempInt = temp.toInt()

            if (isCelsius) {
                ((tempInt * 1.8) + 32).roundToInt().toString()

            } else {
                ((tempInt - 32) * 0.5556).roundToInt().toString()
            }

        } catch (e: Exception) {
            "Invalid Entry"
        }
    }


    fun switchChange() {
        isCelsius = !isCelsius
    }
}

MainActivity.kt:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JustTheFilesTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                     TestTop(viewModel = viewModel())
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewLines( isCelsius: Boolean, textState: String,
              switchChange: () -> Unit, onTextChange: (String) -> Unit
) {
    Row (verticalAlignment = Alignment.CenterVertically) {
        Switch(checked = isCelsius, onCheckedChange = { switchChange() }
        )

        OutlinedTextField(value = textState, onValueChange = { onTextChange(it) },
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Number
            ),
            singleLine = true, label = { Text("Enter temperature") },
            modifier = Modifier.padding(10.dp),
            textStyle = TextStyle(fontWeight = FontWeight.Bold, fontSize = 30.sp),
            trailingIcon = {
                Icon(
                    painter = painterResource(R.drawable.baseline_ac_unit_24),
                    contentDescription = "frost", modifier = Modifier.size(40.dp)
                )
            }
        )

        Crossfade(
            targetState = isCelsius, animationSpec = tween(2000), label = ""
        ) { visible ->
            when (visible) {
                true -> Text("\u2103", style = MaterialTheme.typography.headlineSmall)
                false -> Text("\u2109", style = MaterialTheme.typography.headlineSmall)
            }
        }
    }
}

@Composable
fun TestTop(viewModel: TestingViewModel = viewModel()) {
    TempConvert(isCelsius = viewModel.isCelsius, result = viewModel.result,
        convertTemp = { viewModel.convertTemp(it) },
        switchChange = { viewModel.switchChange() }
    )
}

@Composable
fun TempConvert( isCelsius: Boolean, result: String,
                convertTemp: (String) -> Unit, switchChange: () -> Unit)
    {
        Column (horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier.fillMaxSize()) {

            var textState by remember { mutableStateOf("") }

            val onTextChange = { text : String -> textState = text }

            Text("Temperature Converter",
                modifier = Modifier.padding(20.dp),
                style = MaterialTheme.typography.headlineSmall
            )

            InputRow(
                isCelsius = isCelsius, textState = textState,
                switchChange = switchChange, onTextChange = onTextChange
            )

            Text (result, modifier = Modifier.padding(20.dp),
                style = MaterialTheme.typography.headlineMedium
            )

            Button ( onClick = {convertTemp(textState) }
            ) { Text("Convert Temperature") }
    }
}

@Preview(showBackground = true, showSystemUi = true)
@Composable
fun GreetingPreview(model: TestingViewModel = viewModel()) {
    JustTheFilesTheme {
        ScreenSetup(viewModel = viewModel())
    }
}

Solution

  • You're supposed to remember the value to make sure that the Compose framework tracks changes successfully. For example, here in the Greeting composable, make sure you wrap your state value in a remember(it also supports having delegate by). With this, Compose will know what to do.

    @Composable
    fun Greeting(viewModel: TestViewModel = viewModel()) {
      val rememberedtestVar by remember { viewmodel.testVar } 
      DisplayOnScreen(testVar = rememberedtestVar, changeText 
     = {viewModel.changeText()})
    }
    

    EDIT: This doesn't seem to be relevant to Jetpack Compose itself, but the way you're modifying the text. It seems here in the code that you're passing a lambda but you're not invoking it when you're clicking the button, you're just calling the lambda without invoking, here's what I am talking about:

    @Composable
    fun DisplayOnScreen(testVar: String, changeText: () -> Unit){
            Column() {
                    Text(testVar)
                    Button(onClick = {changeText}){
                            Text("Click")
                    }
            }
    }
    

    It should be something like this instead, no ? :

    @Composable
    fun DisplayOnScreen(testVar: String, changeText: () -> Unit){
            Column() {
                    Text(testVar)
                    Button(onClick = { changeText.invoke() }){
                            Text("Click")
                    }
            }
    }