Search code examples
event-handlingandroid-jetpack-composekotlin-multiplatform

Kotlin Multiplatform Compose ~ How to use events to control views


I have a Kotlin Multiplatform project where I want to use Events to control views.

The basic idea is this:

  • Buttons & Co fire an Event when clicked
  • These events get caught and handled by the responsible program components, which will in turn fire other events
  • Eventually, some kind of ViewEvent is fired, which is subscribed to by the ViewController
  • The ViewController then tells the program what should be drawn on the screen

In theory, that sounds like it should work. In practice, what happens is that while it gets to the point where the ViewController receives the event and reacts accordingly, the actual views are unaffected.

My ViewController looks like this:

import androidx.compose.runtime.Composable
import com.tri_tail.ceal_chronicler.events.OpenCharacterSelectionViewEvent
import com.tri_tail.ceal_chronicler.ui.main_view.MainView
import com.tri_tail.ceal_chronicler.ui.main_view.MainViewState
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe

class ViewController {

    private var mainViewState = MainViewState.TITLE

    init {
        val eventBus = EventBus.getDefault()
        eventBus.register(this)
    }

    @Composable
    fun draw() {
        MainView(mainViewState)
    }

    @Subscribe
    fun onOpenCharacterSelectionViewEvent(event: OpenCharacterSelectionViewEvent) {
        mainViewState = MainViewState.CHARACTER
    }
}

I debugged that, and was able to see that the mainViewState changes, as expected. However, the draw() function is never called again, and so the changed mainViewState never arrives in the MainView.

I've already tried making mainViewState a mutableStateOf(mainViewState), but that didn't change anything.

Furthermore, I can't just call draw() inside the onOpenCharacterSelectionViewEvent, because it is not @Composable, and adding that annotation to the method causes the build to fail.

At this point, I am not even sure whether what I am trying to do here can work this way. Can someone please help me out here?

I have also published a version of the code with the current non-working solution here: https://github.com/KiraResari/ceal-chronicler/tree/event-system


Solution

  • Okay, so after worrying at this for several days, I have now come up with a solution that works.

    Basically, the reason why it doesn't work as I tried it is that the frontend lives in its own little world, and it is very difficult for something from outside that world to affect it.

    However, it can be done using delegates. Basically, what I called the ViewController in above is more of a MainViewModel, and it needs to look like this:

    import com.tri_tail.ceal_chronicler.events.OpenCharacterSelectionViewEvent
    import org.greenrobot.eventbus.EventBus
    import org.greenrobot.eventbus.Subscribe
    
    class MainViewModel {
    
        var state = MainViewState.TITLE
    
        var updateState: ((MainViewState) -> Unit) = { }
            set(value) {
                field = value
                updateState(state)
            }
    
        init {
            val eventBus = EventBus.getDefault()
            eventBus.register(this)
        }
    
        @Subscribe
        fun onOpenCharacterSelectionViewEvent(event: OpenCharacterSelectionViewEvent) {
            state = MainViewState.CHARACTER
            updateState(state)
        }
    }
    

    The other part of the magic happens in the MainView, where the state needs to be a remember with a mutableStateOf(..., policy = neverEqualPolicy()), and the delegate needs to be set like this:

    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.mutableStateOf
    import androidx.compose.runtime.*
    import com.tri_tail.ceal_chronicler.models.main_view.MainViewModel
    import com.tri_tail.ceal_chronicler.models.main_view.MainViewState
    import com.tri_tail.ceal_chronicler.theme.AppTheme
    import com.tri_tail.ceal_chronicler.ui.TitleScreen
    import com.tri_tail.ceal_chronicler.ui.characters.DisplayCharacterSelector
    
    @Composable
    fun MainView(model: MainViewModel = MainViewModel()) {
    
        var state by remember {
            mutableStateOf(
                model.state,
                policy = neverEqualPolicy()
            )
        }
    
        model.updateState = {
            state = it
        }
    
        AppTheme {
            when (state) {
                MainViewState.TITLE -> TitleScreen()
                MainViewState.CHARACTER -> DisplayCharacterSelector()
            }
        }
    }
    

    And that's all there is to it! Works like a charm, no extra libraries required.