There's a bug (or a limitation of Compose?) when t...
# compose
n
There's a bug (or a limitation of Compose?) when trying to re-display a snackbar too quickly (less than 16ms) after it's been dismissed. How can I avoid such behavior? Example in 🧵
MainActivity.kt
Copy code
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            SnackbarBugTheme {
                val scope = rememberCoroutineScope()
                val snackBarHostState = remember { SnackbarHostState() }

                val viewModel = viewModel<MainViewModel>()
                val errorState by viewModel.errorStateFlow.collectAsStateWithLifecycle()

                LaunchedEffect(errorState) {
                    if (errorState != null) {
                        scope.launch {
                            val result = snackBarHostState.showSnackbar(
                                message = errorState!!,
                                actionLabel = "Retry",
                            )

                            when (result) {
                                SnackbarResult.Dismissed -> TODO()
                                SnackbarResult.ActionPerformed -> viewModel.onRetry()
                            }
                        }
                    }
                }

                Scaffold(
                    modifier = Modifier.fillMaxSize(),
                    snackbarHost = { SnackbarHost(hostState = snackBarHostState) },
                ) { innerPadding ->
                    Text(
                        modifier = Modifier.padding(innerPadding),
                        text = "Android",
                    )
                }
            }
        }
    }
}
MainViewModel.kt
Copy code
class MainViewModel : ViewModel() {
    private val errorMutableStateFlow = MutableStateFlow<String?>("Error")
    val errorStateFlow = errorMutableStateFlow.asStateFlow()

    fun onRetry() {
        viewModelScope.launch {
            errorMutableStateFlow.value = null

            // Let's say it took less than a Compose frame (16.6ms) to go from "loading" (null) to
            // an error ("Error")
            // It causes the snackbar to not reappear because for Compose, errorStateFlow never
            // changed its value...
            delay(5.milliseconds)

            errorMutableStateFlow.value = "Error"
        }
    }
}
If you click on "Retry", the snackbar disappear (because
showSnackbar
is no longer suspending), but it's never re-displayed...
Putting a
delay(17)
is obviously not a viable option. Any alternative? Or fix incoming?
The only solution that doesn't rely on some arbitrary delay is handling the "dirty" state of the Compose state ourselves by putting a
dirty
flag in my class to force the
LaunchedEffect
to be recomposed. That's awful. There's no other options?
Copy code
class MainViewModel : ViewModel() {
    private val errorMutableStateFlow = MutableStateFlow(
        ErrorUiState(
            dirty = 0,
            message = "Error",
        )
    )
    val errorStateFlow = errorMutableStateFlow.asStateFlow()

    fun onRetry() {
        viewModelScope.launch {
            errorMutableStateFlow.update { current ->
                ErrorUiState(
                    dirty = current.dirty + 1,
                    message = null,
                )
            }

            errorMutableStateFlow.update { current ->
                ErrorUiState(
                    dirty = current.dirty + 1,
                    message = "Error",
                )
            }
        }
    }
}

data class ErrorUiState(
    val dirty: Int,
    val message: String?,
)
v
Putting a
delay(17)
is obviously not a viable option. Any alternative? Or fix incoming?
What you are seeing is expected behaviour. Use
MutableStateFlow
when the latest state should be composed. Use (for example) a buffered
Channel
to notify the composition for each modification.
You could also compare
ErrorUiState
referentially instead of structurally (though I very much dislike this)
n
I'm not sure it has anything to do with
MutableStateFlow
. In "pure Compose" the issue is still there...
Copy code
var errorState: String? by remember {
    mutableStateOf("Error")
}

LaunchedEffect(errorState) {
    android.util.Log.d("Nino", "LaunchedEffect() called")
    if (errorState != null) {
        scope.launch {
            val result  = snackBarHostState.showSnackbar(
                message = errorState!!,
                actionLabel = "Retry",
            )

            when (result) {
                SnackbarResult.Dismissed -> TODO()
                SnackbarResult.ActionPerformed -> {
                    errorState = null
                    errorState = "Error"
                }
            }
        }
    }
}
v
We're on the right track. You assign
errorState
to
null
(notifying the snapshot system / composition that a change has taken place). And then...you immediately assign it back to its original value. Later when composition takes place,
LaunchedEffect
is skipped (because its arguments are equal).
MutableStateFlow
is "just" a CAS (compare-and-swap) wrapper so it behaves ~like a normal variable Remember that everything is running in a single thread. Compose runs cooperatively alongside your own code (when your code is idle)
n
I get that Compose "conflates" to the latest value / snapshot, and it works for pure state because who cares if, for one subframe, I had to display another text in a Text composable? But for "event based state" (such a strong word in Compose), it no longer works (otherwise you lose an event and it's gone forever) and I feel like there's no tool at disposal to synchronize with Compose...
v
Untitled.cpp
Compose is no different than any other slow consumer.
MutableStateFlow
is always the wrong tool if you need intermediary values. Use a
Channel
A
RENDEZVOUS
channel may also be interesting to you, since that allows you to "synchronize" with compose's rendering
n
How should I collect this flow in Compose? If I use this, when rotating the screen the snackbar is no longer displayed...
Copy code
LaunchedEffect(Unit) {
    lifecycle.repeatOnLifecycle(state = Lifecycle.State.STARTED) {
        viewModel.errorFlow.collectLatest { errorState ->
            if (errorState != null) {
                val result = snackBarHostState.showSnackbar(
                    message = errorState,
                    actionLabel = "Retry",
                )

                when (result) {
                    SnackbarResult.Dismissed -> TODO()
                    SnackbarResult.ActionPerformed -> viewModel.onRetry()
                }
            }
        }
    }
}
(By the way, I had to change to
receiveAsFlow
instead of
consumeAsFlow
because otherwise it would crash during a rotation)
v
You don't need to restart your activity on configuration changes (
android:configChanges="allKnown"
). In general you need to figure out what's supposed to be state vs what's supposed to be events. Then model events as
Channel
and state as
State
. I'm not sure how to model this, since I'm not sure what you are trying to accomplish. The "issue" you ran into w.r.t. recreating your screen is that no new event was dispatched nor was any state retained. Taking a guess at what you're trying to do is to re-display a snackbar, I would come up with a "refresh" event but keep the snackbar's content as state. Then let state changes or refresh events recreate the snackbar (from the current state).