To prevent the usage of a dedicated MutableStateFlow in combination with a CoroutineScope to handle onClick:()->Unit callbacks, I created an onClickFlow{ emit(...) } to simplify and inline click handling. The logic behind the onClickFlow allows the emission of values to the outer flow collector as soon as the returned onClick-lambda is called. First the usage:
data class State(
val message: String,
val onClickUpload: (() -> Unit)? = null,
val onClickVerify: (() -> Unit)? = null,
)
val stateFlow = flow {
emit(
State(
message = "Init",
onClickUpload = onClickFlow {
emit(State("Uploaded"))
},
onClickVerify = onClickFlow {
emit(State("Verified"))
}
)
)
}
And now the implementation of onClickFlow{}:
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun <T> FlowCollector<T>.onClickFlow(
clickFlowCollector: suspend FlowCollector<T>.() -> Unit
): () -> Unit {
val clickFlowAdapter = object : AbstractFlow<T>() {
override suspend fun collectSafely(collector: FlowCollector<T>) {
collector.clickFlowCollector()
}
}
val awaitClickFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1, replay = 1)
val callingFlowContext = currentCoroutineContext()
CoroutineScope(EmptyCoroutineContext).launch {
withContext(callingFlowContext) {
awaitClickFlow.collect {
this@onClickFlow.emitAll(clickFlowAdapter)
}
}
}
return { awaitClickFlow.tryEmit(Unit) }
}
I am pretty sure that this approach has some flaws but my knowledge about flows and coroutines is not that deep. That is the reason why I am asking here for help and feedback
Imagine a deep nested complex compose layout with a lot of listeners. In such a layout, you would have to pass a lot of event handlers, in parallel to the states, all the way down to the composable which triggers the event. The number of arguments for the composables would get 2 or 3 times larger.
As long as you pass function references instead of anonymous lambdas, there should be no disadvantages?
I don’t know, maybe this is indeed a common pattern in Compose. It feels super weird to me. Flows are mostly for sending the data, not the behavior. And it seems like a huge hack that the first item is magic, it has handlers, then all further states have nulls, but we should still use handlers from the very first item.
The huge hack was to reduce the size of the example. Normally it would look like this:
sealed interface State {
data object Initializing: State
data class Ready (
val onClickUpload: () -> Unit,
val onClickVerify: () -> Unit,
): State
data class Uploading(val progress: Float): State
data object Verified: State
}
val stateFlow = flow {
emit(State.Initializing)
delay(1000)
emit(State.Ready(
onClickUpload = onClickFlow { emitAll(uploadFlow()) },
onClickVerify = onClickFlow { emitAll(verifyFlow()) }
))
}
fun uploadFlow(): Flow<State> = flow {
emit(State.Uploading(0f))
delay(1000)
emit(State.Uploading(1f))
}
fun verifyFlow(): Flow<State> = flowOf(State.Verified)
Interesting, this would work for not suspending code If I want to execute suspending code after onClick, then this approach would not be possible. Except I use an additional CoroutineScope. Or did I oversee something?
fun main(args: Array<String>) {
runBlocking {
stateFlow.collect { state ->
println(state.message)
state.onClickUpload?.invoke()
}
}
} // Prints "Init" and then "Uploaded"
If I would use runBlocking I would block the UI thread of the calling composable. If I would use GlobalScope, the upload would not cancel if I leave the screen and the collection of the flow is cancelled.
Is this even close to your real app use case? I would expect the onclick listener to be called when the user clicks on something and not inside the collector. I believe it will throw if you try to move it outside of collect {}.
I doubt your approach fixes this problem to be honest. Do you use it already in your app or are you still experimenting?
But never mind, I made a mistake above - we don’t need GlobalScope.launch(), we can do simply launch() and it will run as a child of callbackFlow() - which I believe is what you are trying to do.
This is only an experiment. I want to use the higher-order functions of flows for onClick events, too. So I thought I have to convert onClick callbacks into flows to combine them with the data flow to update the state of the UI. I think callbackFlow is the right approach if you want to go this direction.