Custom onClickFlow{} vs dedicated MutableStateFlow and CoroutineScope

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 :slight_smile:

Hi, what is the problem you are trying to solve?

1 Like

I try to reduce the states needed to handle clicks. Often you see approaches like this:

private val scope = CoroutineScope(...)
private val _stateFlow = MutableStateFlow(State(
    message = "init",
    onClickUpload = ::upload,
    onClickVerify = ::verify
))
val stateFlow = _stateFlow.asStateFlow()

private fun upload() {
    scope.launch { 
        _stateFlow.emit(State("uploaded"))
    }
}

private fun verify() {
    scope.launch { 
        _stateFlow.emit(State("verified"))
    }
}

The complexity increases even more, if you are not able to create the initial state in a synchronized way.

To be honest, I’m really surprised to see sending event handlers through the flow. Do people really do this?

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)

Ok, assuming you want to keep the general pattern of sending listeners through the flow. Why not simply:

val stateFlow = callbackFlow {
	send(
		State(
			message = "Init",
			onClickUpload = {
				trySendBlocking(State("Uploaded"))
			},
			onClickVerify = {
				trySendBlocking(State("Verified"))
			}
		)
	)
	awaitClose()
}

?

1 Like

Interesting, this would work for not suspending code :slight_smile: 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?

Instead of trySendBlocking() you can use runBlocking() or GlobalScope.launch() - whatever suits you better.

BTW, I couldn’t get your initial code to work correctly. I simply took your code and then used it like this:

suspend fun main(): Unit = coroutineScope {
	var onclick: (() -> Unit)? = null

	launch {
		stateFlow.collect {
			println(it)
			if (it.onClickUpload != null) {
				onclick = it.onClickUpload
			}
		}
	}

	delay(2000)
	onclick?.invoke()
}

But it throws. And I’m not surprised to be honest, flow {} is not supposed to be used like this. But maybe I miss something.

I collect the stateFlow like this:

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.

Thank you for your feedback!

Why not use:

private fun upload() {
    _stateFlow.value = State("uploaded")
}

An upload would be done in a suspend function in a real world example. This example is only meant to outline the approach.