Duqe
Duqe

Reputation: 339

Android Compose with single event

Right now I have an Event class in the ViewModel that is exposed as a Flow this way:

abstract class BaseViewModel() : ViewModel() {

    ...

    private val eventChannel = Channel<Event>(Channel.BUFFERED)
    val eventsFlow = eventChannel.receiveAsFlow()

    fun sendEvent(event: Event) {
        viewModelScope.launch {
            eventChannel.send(event)
        }
    }

    sealed class Event {
        data class NavigateTo(val destination: Int): Event()
        data class ShowSnackbarResource(val resource: Int): Event()
        data class ShowSnackbarString(val message: String): Event()
    }
}

And this is the composable managing it:

@Composable
fun SearchScreen(
    viewModel: SearchViewModel
) {
    val events = viewModel.eventsFlow.collectAsState(initial = null)
    val snackbarHostState = remember { SnackbarHostState() }
    val coroutineScope = rememberCoroutineScope()
    Box(
        modifier = Modifier
            .fillMaxHeight()
            .fillMaxWidth()
    ) {
        Column(
            modifier = Modifier
                .padding(all = 24.dp)
        ) {
            SearchHeader(viewModel = viewModel)
            SearchContent(
                viewModel = viewModel,
                modifier = Modifier.padding(top = 24.dp)
            )
            when(events.value) {
                is NavigateTo -> TODO()
                is ShowSnackbarResource -> {
                    val resources = LocalContext.current.resources
                    val message = (events.value as ShowSnackbarResource).resource
                    coroutineScope.launch {
                        snackbarHostState.showSnackbar(
                            message = resources.getString(message)
                        )
                    }
                }
                is ShowSnackbarString -> {
                    coroutineScope.launch {
                        snackbarHostState.showSnackbar(
                            message = (events.value as ShowSnackbarString).message
                        )
                    }
                }
            }
        }
        SnackbarHost(
            hostState = snackbarHostState,
            modifier = Modifier.align(Alignment.BottomCenter)
        )
    }
}

I followed the pattern for single events with Flow from here.

My problem is, the event is handled correctly only the first time (SnackBar is shown correctly). But after that, seems like the events are not collected anymore. At least until I leave the screen and come back. And in that case, all events are triggered consecutively.

Can't see what I'm doing wrong. When debugged, events are sent to the Channel correctly, but seems like the state value is not updated in the composable.

Upvotes: 15

Views: 18659

Answers (5)

https://github.com/Kotlin-Android-Open-Source/Jetpack-Compose-MVI-Coroutines-Flow/blob/master/core-ui/src/main/java/com/hoc/flowmvi/core_ui/rememberFlowWithLifecycle.kt

@Suppress("ComposableNaming")
@Composable
fun <T> Flow<T>.collectInLaunchedEffectWithLifecycle(
  vararg keys: Any?,
  lifecycle: Lifecycle = LocalLifecycleOwner.current.lifecycle,
  minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
  collector: suspend CoroutineScope.(T) -> Unit
) {
  val flow = this
  val currentCollector by rememberUpdatedState(collector)

  LaunchedEffect(flow, lifecycle, minActiveState, *keys) {
    withContext(Dispatchers.Main.immediate) {
      lifecycle.repeatOnLifecycle(minActiveState) {
        flow.collect { currentCollector(it) }
      }
    }
  }
}

class ViewModel {
  val singleEvent: Flow<E> = eventChannel.receiveAsFlow()
}

@Composable fun Demo() {
  val snackbarHostState by rememberUpdatedState(LocalSnackbarHostState.current)
  val scope = rememberCoroutineScope()
  viewModel.singleEvent.collectInLaunchedEffectWithLifecycle { event ->
    when (event) {
      SingleEvent.Refresh.Success -> {
        scope.launch {
          snackbarHostState.showSnackbar("Refresh successfully")
        }
      }
      is SingleEvent.Refresh.Failure -> {
        scope.launch {
          snackbarHostState.showSnackbar("Failed to refresh")
        }
      }
      is SingleEvent.GetUsersError -> {
        scope.launch {
          snackbarHostState.showSnackbar("Failed to get users")
        }
      }
      is SingleEvent.RemoveUser.Success -> {
        scope.launch {
          snackbarHostState.showSnackbar("Removed '${event.user.fullName}'")
        }
      }
      is SingleEvent.RemoveUser.Failure -> {
        scope.launch {
          snackbarHostState.showSnackbar("Failed to remove '${event.user.fullName}'")
        }
      }
    }
  }
}

Upvotes: 3

m.reiter
m.reiter

Reputation: 2575

Here's a modified version of Soroush Lotfi answer making sure we also stop flow collection whenever the composable is not visible anymore: just replace the LaunchedEffect with a DisposableEffect

@Composable
inline fun <reified T> Flow<T>.observeWithLifecycle(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    noinline action: suspend (T) -> Unit
) {
    DisposableEffect(Unit) {
        val job = lifecycleOwner.lifecycleScope.launch {
           flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action)
        }

        onDispose {
            job.cancel()
        }
    }
}

Upvotes: 0

Soroush Lotfi
Soroush Lotfi

Reputation: 602

Here's a modified version of Jack's answer, as an extension function following new guidelines for safer flow collection.

@Composable
inline fun <reified T> Flow<T>.observeWithLifecycle(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
    noinline action: suspend (T) -> Unit
) {
    LaunchedEffect(key1 = Unit) {
        lifecycleOwner.lifecycleScope.launch {
            flowWithLifecycle(lifecycleOwner.lifecycle, minActiveState).collect(action)
        }
    }
}

Usage:

viewModel.flow.observeWithLifecycle { value ->
    //Use the collected value
}

Upvotes: 14

Phil Dukhov
Phil Dukhov

Reputation: 88437

I'm not sure how you manage to compile the code, because I get an error on launch.

Calls to launch should happen inside a LaunchedEffect and not composition

Usually you can use LaunchedEffect which is already running in the coroutine scope, so you don't need coroutineScope.launch. Read more about side effects in documentation.

A little kotlin advice: when using when in types, you don't need to manually cast the variable to a type with as. In cases like this, you can declare val along with your variable to prevent Smart cast to ... is impossible, because ... is a property that has open or custom getter error:

val resources = LocalContext.current.resources
val event = events.value // allow Smart cast
LaunchedEffect(event) {
    when (event) {
        is BaseViewModel.Event.NavigateTo -> TODO()
        is BaseViewModel.Event.ShowSnackbarResource -> {
            val message = event.resource
            snackbarHostState.showSnackbar(
                message = resources.getString(message)
            )
        }
        is BaseViewModel.Event.ShowSnackbarString -> {
            snackbarHostState.showSnackbar(
                message = event.message
            )
        }
    }
}

This code has one problem: if you send the same event many times, it will not be shown because LaunchedEffect will not be restarted: event as key is the same.

You can solve this problem in different ways. Here are some of them:

  1. Replace data class with class: now events will be compared by pointer, not by fields.

  2. Add a random id to the data class, so that each new element is not equal to another:

    data class ShowSnackbarResource(val resource: Int, val id: UUID = UUID.randomUUID()) : Event()
    

Note that the coroutine LaunchedEffect will be canceled when a new event occurs. And since showSnackbar is a suspend function, the previous snackbar will be hidden to display the new one. If you run showSnackbar on coroutineScope.launch (still doing it inside LaunchedEffect), the new snackbar will wait until the previous snackbar disappears before it appears.

Another option, which seems cleaner to me, is to reset the state of the event because you have already reacted to it. You can add another event to do this:

object Clean : Event()

And send it after the snackbar disappears:

LaunchedEffect(event) {
    when (event) {
        is BaseViewModel.Event.NavigateTo -> TODO()
        is BaseViewModel.Event.ShowSnackbarResource -> {
            val message = event.resource
            snackbarHostState.showSnackbar(
                message = resources.getString(message)
            )
        }
        is BaseViewModel.Event.ShowSnackbarString -> {
            snackbarHostState.showSnackbar(
                message = event.message
            )
        }
        null, BaseViewModel.Event.Clean -> return@LaunchedEffect
    }
    viewModel.sendEvent(BaseViewModel.Event.Clean)
}

But in this case, if you send the same event while the previous one has not yet disappeared, it will be ignored as before. This can be perfectly normal, depending on the structure of your application, but to prevent this you can show it on coroutineScope as before.

Also, check out the more general solution implemented in the JetNews compose app example. I suggest you download the project and inspect it starting from location where the snackbar is displayed.

Upvotes: 9

Jagadeesh K
Jagadeesh K

Reputation: 896

Rather than placing your logic right inside composable place them inside

// Runs only on initial composition 
LaunchedEffect(key1 = Unit) {
  viewModel.eventsFlow.collectLatest { value -> 
    when(value) {
       // Handle events
    }
 }
}

And also rather than using it as state just collect value from flow in LaunchedEffect block. This is how I implemented single event in my application

Upvotes: 22

Related Questions