https://kotlinlang.org logo
#compose
Title
# compose
b

Bradleycorn

12/11/2023, 2:00 PM
Hi all, here’s a question about handling changes in application state which are not triggered by user input events. I’ll use the NowInAndroid app as an example, because my app has a similar architecture. It’s NiaAppState model exposes an
isOffline: StateFlow<Boolean>
property. And, the NiaApp Composable collects the StateFlow and uses a
LaunchedEffect
to show a snackbar when it’s value changes. The use of a LaunchedEffect in the Composable makes sense in this case, since it needs to use the snackabarHostState, which is owned by the
NiaApp
composable. But say that when the app goes offline we want to navigate to a destination instead of showing a snackbar, and that navigation action is handled in the
NiaAppState
model. So, we might make a simple update in the
NiaApp
like this:
Copy code
val isOffline by appState.isOffline.collectAsStateWithLifecycle()
LaunchedEffect(isOffline) {
    if (isOffline) {
        // snackbarHostState.showSnackbar(...)  <-- Remove this 
        appState.navigateToOfflineScreen()      // and replace with this
    }
}
That works fine. But now it seems like the collection of the StateFlow and handling of it’s values in the
NiaApp
composable via a Launched effect is not really necessary. It’s not doing anything that requires
NiaApp
or any values it owns. We could do (nearly?) all of this in the
NiaAppState
model, in a few ways: 1. Add an
onEach
to the
isOffline
StateFlow and do the navigation as necessary in it’s lambda. In
NiaApp
, we’d just do
appState.isOffline.collectAsStateWithLifecycle()
and eliminate the
LaunchedEffect
. 2.
NiaAppState
has a
CoroutineScope
. We could add an
init
block and use the CoroutineScope to collect the isOffline flow, and navigate as necessary:
Copy code
class NiaAppState(....) {
    init {
        coroutineScope.launch {
            networkMonitor.isOnline.collect { isOnline ->
                if (!isOnline) { navController.navigateToOfflineScreen() }
            }
        }
    }
}
So, when we have a state that is not driven by user generated events (like the network monitoring above), and the actions that we need to take are not a direct update of the current interface (like using the navcontroller to navigate somewhere) … what’s the best way to handle that?
z

Zach Klippenstein (he/him) [MOD]

12/11/2023, 4:02 PM
Another potential option is to define a “runWhileVisible” suspend function on your app state class that you call from a launched effect. That allows the lifecycle of the coroutines to be controlled by composition but lets the app state keep the specifics of what it wants those coroutines to do private.
In general, side effects from constructors are smelly and make testing harder
b

Bradleycorn

12/11/2023, 4:04 PM
yeah, using a constructor for something like this seems not only like a side effect, but it’s almost like a hidden side effect that you don’t necessarily know is going to happen when you instantiate an object.
z

Zach Klippenstein (he/him) [MOD]

12/11/2023, 4:05 PM
But if your app state has its own coroutine scope, it seems like this work should be scoped to that. If your framework doesn’t provide an explicit hook for starting coroutines, that seems like a design omission
r

Roman Levinzon

12/11/2023, 4:08 PM
I had the same experience and ended up in the same situation as you did. I’ve put most of side effects and conditional UI Logic into the new component that I called
SomethingCoordinator
that would manage the UI part of the feature, coordinate different states of the Screen, including ViewModels, NavControllers, Composable states and etc. Described more thoroughly here: https://medium.com/@levinzon-roman/jetpack-compose-ui-architecture-a34c4d3e4391
👀 1
b

Bradleycorn

12/11/2023, 4:14 PM
But if your app state has its own coroutine scope, it seems like this work should be scoped to that.
yeah, the AppState does have a coroutine scope, and the other thing I thought of was using an
onEach
on a StateFlow for the offline/online value, like this:
Copy code
val isOffline = networkMonitor.isOnline
        .map(Boolean::not)
        .onEach { isOffline ->
            if (isOffline) { navController.navigateToOfflineScreen() }
        }  
        .stateIn(
            scope = coroutineScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = false,
        )
And then just collect the flow in the
NiaApp
composable:
Copy code
appState.isOffline.collectAsSateWithLifecycle()
z

Zach Klippenstein (he/him) [MOD]

12/11/2023, 4:18 PM
That also seems sneaky. State flows are generally side-effect free, it would be unexpected. E.g. it’s usually fine for a state flow to be collected by >1 collector simultaneously.
b

Bradleycorn

12/11/2023, 4:18 PM
but again,
onEach
here is kind of like hiding the side effect. So, I tend to think I’ll just keep the
LaunchedEffect
because it makes it explicit what is going on.
haha jinx 😄
z

Zach Klippenstein (he/him) [MOD]

12/11/2023, 4:18 PM
If your framework gives you an
onActive
hook or something, i would launch the coroutine there
2 Views