Compose crashes with "class [...] cannot be cast to class [...]" when switching between two types that inherit from the same class

3 weeks ago 29
ARTICLE AD BOX

This bug requires some setup but I tried to reduce it as much as possible.

The app

I'm working on a compose desktop app that has different "modes". Each mode has its own UI and business logic and there are buttons to switch between those modes.

The ViewModel and StateType superclass

Each mode has its own ViewModel, which contains a state containing all the information the UI needs, and also a contentComposable which takes the state as an argument and composes the UI content.

The subclasses of ViewModel override the state and contentComposable with the ones belonging to their mode, and then the ViewModel superclass provides a compose() method that takes no arguments, collects the state as a compose state and calls the contentComposable.

Also, each state inherits from the StateType superclass.

Those two superclasses are defined like this:

// doesn't need extra logic interface StateType abstract class ViewModel<S: StateType> { protected abstract val state: MutableStateFlow<S> protected abstract val contentComposable: @Composable (S) -> Unit @Composable fun compose() { contentComposable( state.collectAsState().value ) } }

The implementations of the two example modes

I implemented view models and states for two example modes, called "A" and "B". They both have a state which holds just one value, and compose a text that displays that value.

Mode A

data class StateTypeA(val x: Int) : StateType class ViewModelA : ViewModel<StateTypeA>() { override val state = MutableStateFlow(StateTypeA(x = 69)) override val contentComposable: @Composable ((StateTypeA) -> Unit) = { state -> Text("The current number is: ${state.x}") } }

Mode B

data class StateTypeA(val x: Int) : StateType class ViewModelB : ViewModel<StateTypeB>() { override val state = MutableStateFlow(StateTypeB(msg = "hello")) override val contentComposable: @Composable ((StateTypeB) -> Unit) = { state -> Text("Message: ${state.msg}") } }

The main function

Now, I want to display two things on the screen:

The UI of the current mode

A button to switch between mode A and B

To do that, I saved the mode as a string in a mutable state:

var mode by remember { mutableStateOf("a") }

Then, I save the view model using a remember block that takes mode as a key, so it always updates when mode changes:

val viewModel = remember(mode) { when (mode) { "a" -> ViewModelA() "b" -> ViewModelB() else -> throw IllegalStateException("fuck") } }

(according to my IDE, viewModel has the type ViewModel<out StateType>)

Finally, I call the compose() function on the view model to compose its content, and also display a button to change the mode:

Column { // Compose the current ViewModel's content viewModel.compose() // Button to change the mode Button( onClick = { mode = when(mode) { "a" -> "b" "b" -> "a" else -> throw IllegalStateException("fuck") } } ) { Text("Click to change mode") } }

The whole code

All in all, my code looks like this:

import androidx.compose.foundation.layout.Column import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.window.* import kotlinx.coroutines.flow.MutableStateFlow fun main() = application { Window( title = "playground", onCloseRequest = ::exitApplication, ) { var mode by remember { mutableStateOf("a") } val viewModel = remember(mode) { when (mode) { "a" -> ViewModelA() "b" -> ViewModelB() else -> throw IllegalStateException("fuck") } } Column { // Compose the current ViewModel's content viewModel.compose() // Button to change the mode Button( onClick = { mode = when(mode) { "a" -> "b" "b" -> "a" else -> throw IllegalStateException("fuck") } } ) { Text("Click to change mode") } } } } interface StateType data class StateTypeA(val x: Int) : StateType data class StateTypeB(val msg: String) : StateType abstract class ViewModel<S: StateType> { protected abstract val state: MutableStateFlow<S> protected abstract val contentComposable: @Composable (S) -> Unit @Composable fun compose() { contentComposable( state.collectAsState().value ) } } class ViewModelA : ViewModel<StateTypeA>() { override val state = MutableStateFlow(StateTypeA(x = 69)) override val contentComposable: @Composable ((StateTypeA) -> Unit) = { state -> Text("The current number is: ${state.x}") } } class ViewModelB : ViewModel<StateTypeB>() { override val state = MutableStateFlow(StateTypeB(msg = "hello")) override val contentComposable: @Composable ((StateTypeB) -> Unit) = { state -> Text("Message: ${state.msg}") } }

Here my gradle files (if you want to run it yourself):

build.gradle.kts

plugins { kotlin("jvm") version "2.2.0" id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.compose") } repositories { mavenCentral() google() } dependencies { implementation(compose.desktop.currentOs) } compose.desktop { application { mainClass = "MainKt" } }

settings.gradle.kts

pluginManagement { repositories { gradlePluginPortal() mavenCentral() } plugins { id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" id("org.jetbrains.compose") version "1.8.2" } } rootProject.name = "ComposePlayground"

The error

The thing is, when I now click the button, I'd expect the following to happen:

mode is updated to the value "b"

As viewModel has mode listed as a key, it is re-created

While creating viewModel, the when-expression now returns an object of type ViewModelB, and the old object of type ViewModelA gets destroyed

As viewModel has changed, the inside of the Column needs to be recomposed as viewModel is used there

viewModel.compose() is called, which now produces the content for ViewModelB instead of ViewModelA

That new content is shown on screen and everyone is happy

The problem is, when I click the button, it throws an exception instead:

Exception in thread "AWT-EventQueue-0" java.lang.ClassCastException: class StateTypeA cannot be cast to class StateTypeB (StateTypeA and StateTypeB are in unnamed module of loader 'app') at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:130) at androidx.compose.runtime.internal.ComposableLambdaImpl.invoke(ComposableLambda.kt:51) at ViewModel.compose(Main.kt:54) at ComposableSingletons$LauncherKt.lambda_2120161133$lambda$8(Main.kt:24) ...

When I did some print debugging, I found that actually all these steps seem to happen. viewModel indeed changes to an object of type ViewModelB, and the column is really recomposed.

But I believe that somehow, the old ViewModelA is not destroyed even though viewModel is re-assigned, and somehow compose tries to call the new view model with the old state type.

I found that I can solve the problem by wrapping the viewModel.compose() line in a key that depends on mode, like this:

key(mode) { viewModel.compose() }

But this doesn't seem to be the intended way of solving the problem, more like a workaround. And also, I'm just curious what caused the error, and if I did something wrong or this is just a scenario that compose was not designed for.

Another weird thing

I also found out that when I make the compose() method of ViewModel open, like this:

abstract class ViewModel<S: StateType> { // ... @Composable open fun compose() { // ... } }

and then override it in ViewModelA and / or ViewModelB, like this:

class ViewModelA : ViewModel<StateTypeA>() { // ... @Composable override fun compose() { super.compose() } }

the error does not occur, and the mode changes as expected.

I find this extremely weird, because just calling super.compose() without introducing any new logic shouldn't change the behaviour at all. Also, it doesn't matter if I add that overriden function to ViewModelA or ViewModelB or both of them, if at least one of them has it, then everything works fine.

Summary

I know that I probably didn't follow Kotlin / Android / Compose best practices here, and that some of my design choices are weird. But Compose desktop is quite new and I had to write a few things, like view models manually, which would've existed on android.

You can tell me if you have suggestions how to improve the design pattern, but I'd still like to know what was causing the bug here because I really do not have any idea. To me it looks like a bug in compose, but maybe I did something wrong.

Thanks everyone and have a nice day :)

Read Entire Article