What is the current recommended way to pass events...
# compose
a
What is the current recommended way to pass events to somethin inside of Compose? This is a rare case, but sometimes it's needed. For instance, Composable might have an AndroidView with something like a Google Map inside, and Map has some imperative methods like
move()
. Another example is https://google.github.io/accompanist/permissions/ - is has a
rememberPermissionState()
which creates a PermissionState state-holder which can be used to call methods like
launchPermissionRequest()
. In some cases it can be called directly from a callback or a
LaunchedEffect
inside a composable, but there are situations where I want to call it from the
ViewModel
. Google Maps for Compose use a similar approach - a state holder is introduced, and you can call imperative methods on this state holder to do some side-effects. One way to do this is to hoist
rememberPermissionState()
to ViewModel, but IMO it makes the VM less clean - it now depends on a whole Compose library and whatnot. And if I use some strict UDF approach like MVI, I can't have some weird PermissionState in my screen state. Another way to solve this is to pass some Flow of events to compose, collect them in an Effect, and call PermissionState from there, but is feels a bit like a hack. Can you recommend some clean and practical approach for such situations? What are your thoughts of the approaches I mentioned?
l
I would add some callbacks as a param to the viewmodel, and pass them in when I construct it. This works well for permissions, which make sense to have at the top, but not sure about maps.
a
Can you elaborate a little? Why would a VM have callbacks as parameters?
l
It provides an easy way to inject behavior into the VM that may not belong inside (and also makes testing easier). When the VM wants to request permissions, it calls the 'requestPermission' callback that you pass in. When you create your VM, you pass in an implementation that uses rememberPermissionState, or for testing, you can pass in a lambda that runs an assertion.
a
I see your point, but this would mean that ViewModel has a reference for some View-scoped object, would it not? And this is usually not good, because ViewModel survives recompositions and Activity recreation, and so it must not hold references to anything from the UI.
c
Another way to solve this is to pass some Flow of events to compose, collect them in an Effect
If your UI truly needs to consume events (rather than just reflect state), then I think this approach is the correct one and not a hack. I think for any given situation, you need to investigate whether you definitely need this.
In some cases it can be called directly from a callback or a
LaunchedEffect
inside a composable, but there are situations where I want to call it from the
ViewModel
.
Can you elaborate on this? Why do you want to invoke permissions methods from your VM?
a
Well, even the basic permission sample from Accompanist does this:
Button(onClick = { cameraPermissionState.launchPermissionRequest() })
I'm not a fan of implementing button click logic directly in Compose, and would prefer it co call ViewModel, and then launch permission from there. The logic could be more complex - maybe some some logging, maybe something suspending work before
launchPermissionRequest
, maybe even
launchPermissionRequest
by some other event, not related of button click. Permission request is just an example, the same pattern could be applied to other situations. Move the map when the user's location changes is another example, you get the idea.
c
Yeah I get what you mean. A user may be clicking + dragging to move the map. Or they may click a button somewhere else (like a “home” button which moves the map to your home). If you definitely want to send events through your VM, then implement as a flow and collect the flow:
Copy code
class MyViewModel : ViewModel() {
  private val _events = MutableSharedFlow()
  val events = _events.asSharedFlow()
  
  fun emitEvent(event: Event) {
    viewModelScope.launch {
      _events.emit(event)
    }
  }
}

sealed interface Event {
  object RequestPermission : Event
}

@Composable
fun MyComposable(viewModel: MyViewModel) {
  val permissionState = rememberPermissionState(...)

  LaunchedEffect(viewModel) {
    viewModel.events.collect {
      when (it) {
        is Event.RequestPermission -> {
          permissionState.launchPermissionRequest()
        }
      }
    }
  }

  Button(onClick = {
    viewModel.emitEvent(Event.RequestPermission)
  })
}
a
This is a nice example of the "introduce a stateful controller" approach, thanks) Do you consider this the best practice for such situations? Need to control a map - introduce a controller and share it between VM and Screen?
a
yes we have same implementation for maps in https://github.com/icerockdev/moko-maps but at now mapbox provider is outdated and block us from update lib to support iosSimulatorArm64 for example... in maps case i decide that better to do "declarative map controller" - just describe State, abstracted from map provider, and implement only on native side consumers of this state for any map provider. in this case in ViewModel we just set state like:
Copy code
mapState.value = MapState(
    markers = listOf(Marker(image = ***, lat = **, long = **), **),
    route = listOf(point1, point2, point3),
    areas = listOf(...)
)
but this not implemented yet. it's just our experience after using of MapController from moko-maps in several projects
a
I already have a state-oriented implementation for Google and Yandex maps, would be nice to compare with your implementation, thanks) It works pretty well for the map state - markers, polygons etc, but there are some cases that don't go nicely with state, for example a wide variety of imperative
move()
methods that accept different paramerets. For such actions I use a similar approach - I share a Flow of events from VM to my Compose Map, and collect them inside Compose implementation. I'll definitely check out your approach to such things as movement with animation, setting a focus rectangle etc.
I guess I'll try to combine my existing state-oriented map wrapper with your idea of MapController and see what happens)
p
I was using an event channel but found out it wasn't 100% delivery guarantee. So I switched to a StateFlow<SingleEvent<T>> where SingleEvent encapsulates T an a
consumed
boolean to track if the state was consumed on the composable side. That or having a function in the ViewModel appears to be the recommended way.