11

I'm building a jetpack compose app and I want my view model to tell my compose function to display a snack bar by sending it an event. I have read multiple blog posts about the Single Live Event case with Kotlin and I tried to implement it with Compose and Kotlin Flow. I managed to send the event from the view model (I see it in the logs) but I don't know how to receive it in the composable function. Can someone help me figure it out please? Here is my implementation.

class HomeViewModel() : ViewModel() {
    sealed class Event {
        object ShowSheet : Event()
        object HideSheet : Event()
        data class ShowSnackBar(val text: String) : Event()
    }

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

    fun showSnackbar() {
        Timber.d("Show snackbar button pressed")
        viewModelScope.launch {
            eventChannel.send(Event.ShowSnackBar("SnackBar"))
        }
    }
}
@Composable
fun HomeScreen(
    viewModel: HomeViewModel,
) {
    val context = LocalContext.current

    val scaffoldState = rememberScaffoldState()
    val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)

    val lifecycleOwner = LocalLifecycleOwner.current
    val eventsFlowLifecycleAware = remember(viewModel.eventsFlow, lifecycleOwner) {
        eventsFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
    }

    LaunchedEffect(sheetState, scaffoldState.snackbarHostState) {
        eventsFlowLifecycleAware.onEach {
            when (it) {
                HomeViewModel.Event.ShowSheet -> {
                    Timber.d("Show sheet event received")
                    sheetState.show()
                }
                HomeViewModel.Event.HideSheet -> {
                    Timber.d("Hide sheet event received")
                    sheetState.hide()
                }
                is HomeViewModel.Event.ShowSnackBar -> {
                    Timber.d("Show snack bar received")
                    scaffoldState.snackbarHostState.showSnackbar(
                        context.getString(it.resId)
                    )
                }
            }
        }
    }

    ModalBottomSheetLayout(
        sheetState = sheetState,
        sheetContent = {
            Text("Sheet")
        }
    ) {
        Button(
            onClick = {
                viewModel.showSheet()
            }
        ) {
            Text("Show SnackBar")
        }
    }
}

For reference, I've used these blog posts:

Bak
  • 161
  • 2
  • 11
  • Did you check https://developer.android.google.cn/jetpack/compose/architecture#example? and https://developer.android.com/jetpack/compose/state#viewmodel-state? – Gabriele Mariotti Apr 15 '21 at 06:26
  • Yes I did but it does not answer the problem I'm trying to resolve. I want to send an event to tell the view to do something, not pass data between the view and the view model. – Bak Apr 15 '21 at 10:34
  • you have to update the state not to send an event – Gabriele Mariotti Apr 15 '21 at 10:54
  • Indeed I had to recompose the view with a different state, there was a lot of boilerplate but it worked, thanks – Bak Apr 19 '21 at 20:28
  • Can you give us more details about the solution? Thanks! – n1k3c May 02 '21 at 13:32
  • Yes, @Bak could you explain your solution? How can an always-present state serve the purpose of a one-time effect? It seems to me we would need to collect an event flow from a channel. The question is how to do that in a way that's scoped to the Composable such that the collector would never be active (and therefore able to consume an event) at a time when the Composable is not in the composition and able to actually handle the event properly. – Chuck Stein Jul 01 '21 at 07:41
  • Also, what doesn't work about the solution you posted in the original question? – Chuck Stein Jul 01 '21 at 21:31
  • Sorry, check my answer below https://stackoverflow.com/a/68222791/7890484 – Bak Jul 02 '21 at 09:30

2 Answers2

4

Ok, I was using the wrong approach, I must not send events, I must update the view state and check if I should show the snackbar when recomposing. Something like that:

You store the SnackBar state in the view model

class HomeViewModel: ViewModel() {
    var isSnackBarShowing: Boolean by mutableStateOf(false)
        private set

    private fun showSnackBar() {
        isSnackBarShowing = true
    }

    fun dismissSnackBar() {
        isSnackBarShowing = false
    }
}

And in the view you use LaunchedEffect to check if you should show the snackbar when recomposing the view

@Composable
fun HomeScreen(
    viewModel: HomeViewModel,
) {
    val onDismissSnackBarState by rememberUpdatedState(newValue = onDismissSnackBar)

    if (isSnackBarShowing) {
        val snackBarMessage = "Message"
        LaunchedEffect(isSnackBarShowing) {
            try {
                when (scaffoldState.snackbarHostState.showSnackbar(
                    snackBarMessage,
                )) {
                    SnackbarResult.Dismissed -> {
                    }
                }
            } finally {
                onDismissSnackBarState()
            }
        }
    }

    Row() {
        Text(text = "Hello")
        Spacer(modifier = Modifier.weight(1f))
        Button(
            onClick = {
                viewModel.showSnackBar()
            }
        ) {
            Text(text = "Show SnackBar")
        }
    }
}
Bak
  • 161
  • 2
  • 11
  • 1
    Why do you use `rememberUpdatedState` to remember `onDismissSnackBarState `? I'm still trying to figure out under which conditions it makes sense to use `rememberUpdatedState`, it still makes no sense to me how lambda can change in this or official examples? – Thracian Sep 19 '21 at 13:22
2

I think you have to collect eventsFlowLifecycleAware as a state to trigger a Composable correctly.

Try removing the LaunchedEffect block, and using it like this:

val event by eventsFlowLifecycleAware.collectAsState(null)
when (event) {
    is HomeViewModel.Event.ShowSnackBar -> {
        // Do stuff
    }
}
Donny Rozendal
  • 852
  • 9
  • 12
  • You can only collect a flow inside a coroutine context and the only way to do that inside a composable function at the root level is to use LaunchedEffect – Bak Apr 19 '21 at 20:30
  • 2
    You can also collect a flow inside a Composable with `collectAsState`. This is also mentioned in the article that you linked: https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda – Donny Rozendal Apr 21 '21 at 09:39