Blocs and Compose Overview

We are going to talk about Blocs and integration with Compose. All what we are going to explain is equally applicable to Cubits.

Blocs allow for more flexible and powerful ways to manage Compose UI state with respect to Android ViewModels. and also avoid introducing a dependency from Android in the Data and Business Logic layer of the application.

At the same time, as we will discuss now, Blocs ( thanks to their integration with Screens), have automatic handling of their lifecycle, with a Bloc lifecycle linked to that of the Screen where a BlocProvider is declared, as we will now explain.

Counter Example UI

We will discuss all the basic features of Bloc and Compose integration with an example.

We want to create a Screen that display a Text with a counter and some buttons to allow the user to increase or reduce the current count.

Here is the Compose code to display the UI we have just described.

class CounterScreen : Screen 
    @Composable
    override fun Content() {
        CounterScreenContent()
    }
}

@Composable
fun CounterScreenContent() {
    // Currently the counter value is fixed and the buttons do nothing...
    CounterControls(
        counterValue=1,
        onDecrement={},
        onIncrement={}
    )
}

@Composable
fun CounterControls(
    counterValue: Int,
    onDecrement: () -> Unit,
    onIncrement: () -> Unit,
    explanatoryText:String?=null
) {
    Column {
        explanatoryText?.let { Text(it) }
        Text("Counter value: ${counterValue}")
        Row {
            Button(
                onClick = onDecrement,
                modifier = Modifier.padding(horizontal = 8.dp)
            ) { Text(text = "-") }
            Button(
                onClick = onIncrement,
                modifier = Modifier.padding(horizontal = 8.dp)
            ) { Text(text = "+") }
        }
    }
}

Counter Example Data Model

As you can see we have implemented the UI but is currently stateless. Let's now define the UI state with a Bloc:

interface CounterEvent
class AdditionEvent(val value:Int):CounterEvent
class SubtractionEvent(val value:Int):CounterEvent

data class CounterState(val counter:Int=0)

class CounterBloc(cscope: CoroutineScope, startCounter:Int=0):
 Bloc<CounterEvent, CounterState>(cscope,CounterState(startCounter),false)
  {
    init {
        on<AdditionEvent> { event, emit ->
            val s=state
            emit(s.copy(counter =s.counter+event.value ))
        }
        on<SubtractionEvent> { event, emit ->
            val s=state
            emit(s.copy(counter =s.counter-event.value ))
        }
    }

}

Note that CounterBloc class constructor has a CoroutineScope argument. Every Bloc has such argument. You should run all the async code in the Bloc event handler using the associated Coroutine scope. This scope is automatically cancelled when the Bloc is disposed. In the integration between Compose and Bloc this coroutineScope follow the lifecycle of Screen where the Bloc is declared

For an introduction of what is a Bloc see Bloc and Cubit Overview

Counter Example BlocProvider

Now that have defined the state with the CounterBloc class, how we use it in Compose? This is done using BlocProvider:

@Composable
fun CounterScreenContent() {
    // BlocProvider makes available the specified bloc (CounterBloc) to the 
    // associated composable subtree
    BlocProvider(create = {cscope-> CounterBloc(cscope,1)} ) {
        CounterControls(
            counterValue=1,
            onDecrement={},
            onIncrement={}
        )
    }
}

Note that in the code above the create argument in BlocProvider is a builder method for obtaining an instance of CounterBloc that will be remembered in recompositions. In this case we create CounterBloc with a simple call to its constructor, but we could have also used Dependency Injection.

Counter Example: rememberProvidedBloc

Now that we have declared the Bloc we want to use, and how to create it, we can now add the code for accessing it. This is done with rememberProvidedBloc

@Composable
fun CounterScreenContent() {
    BlocProvider(create = {cscope-> CounterBloc(cscope,1)} ) {
        //rememberProvidedBloc is similar to dependency injection: it retrieves the specified
        //bloc type as defined by the closest enclosing BlocProvider
        val b:CounterBloc = rememberProvidedBloc()?:return@BlocProvider
        val onDecrement = { b.add(SubtractionEvent(1)) }
        val onIncrement = { b.add(AdditionEvent(1)) }
        val cscope= rememberCoroutineScope()
        val counterState by b.stream.collectAsState(cscope)  
        CounterControls(
            counterValue=counterState.counter,
            onDecrement=onDecrement,
            onIncrement=onIncrement
        )
    }
}

Now our datamodel is fully connected to the UI

Counter Example: BlocBuilder

We can now use BlocBuilder to reduce some of the boilerplate code that we had to write to connect the stream of CounterBloc states to a UI state that Compose can listen to.

@Composable
fun CounterScreenContent() {
    BlocProvider(create = {cscope-> CounterBloc(cscope,1)} ) {
        val b:CounterBloc = rememberProvidedBloc()?:return@BlocProvider
        val onDecrement = { b.add(SubtractionEvent(1)) }
        val onIncrement = { b.add(AdditionEvent(1)) }
        // counterState is a Compose State created from the Flow of state changes
        // associated to the "b" bloc that when changes trigger recomposition
        BlocBuilder(b) { counterState->
            CounterControls(
                counterValue=counterState.counter,
                onDecrement=onDecrement,
                onIncrement=onIncrement
            )
        }
    }
}

BlocBuilder not only allows us to remove some boilerplate code, it also has other options. see the full documentation for more details

Counter Example: BlocListener

There are cases where we want to react to some change of app state by running a side effect. For changes in a Bloc state, this can be done with BlocListener. For example, in the example below we use BlocListener to add some logging

@Composable
fun CounterScreenContent() {
    BlocProvider(create = {cscope-> CounterBloc(cscope,1)} ) {
        val b:CounterBloc = rememberProvidedBloc()?:return@BlocProvider
        val onDecrement = { b.add(SubtractionEvent(1)) }
        val onIncrement = { b.add(AdditionEvent(1)) }
        // counterState is a Compose State created from the Flow of state changes
        // associated to the "b" bloc that when changes trigger recomposition
        BlocBuilder(b) { counterState->
            CounterControls(
                counterValue=counterState.counter,
                onDecrement=onDecrement,
                onIncrement=onIncrement
            )
        }
        BlocListener(b) { counterState ->
            if(counterState==10) {
                Log.d("tag","We have reached count 10!")
            }
        }
    }
}

BlocListener uses under the hood LaunchedEffect

MultiCounter Example: BlocSelector

BlocSelector is very similar to BlocBuilder, but allows to select a specific field of the Bloc state or a combination of fields, and transform it to a Compose state that when changes triggers recomposition.

In order to better see how it works let us work on a new example UI. The UI that we want to implement is the following.

So we have three separate counters but we also want to show the total of all counters. We need now a new Data Model for the UI so we define a new Bloc as follows

interface MultiCounterEvent
class MultiAddEvent(val counterIdx:Int, val value:Int):MultiCounterEvent
class MultiSubEvent(val counterIdx:Int,val value:Int):MultiCounterEvent

data class ABCCounterState(val a:Int,val b:Int, val c:Int)

class ABCCounterBloc(
    cscope: CoroutineScope,
    startCounters: Array<Int> = Array(3) { 0 }
) :
    Bloc<MultiCounterEvent,
            ABCCounterState>(
        cscope, ABCCounterState(a= startCounters[0], b=startCounters[1],c=startCounters[2]),
        true
    ) {
    init {
        on<MultiAddEvent> { event, emit ->
            handleAddEvent(state, emit, event.counterIdx, event.value)
        }
        on<MultiSubEvent> { event, emit ->
            handleAddEvent(state, emit, event.counterIdx, -event.value)
        }
    }
    companion object {
        private fun handleAddEvent(
            s: ABCCounterState,
            emit: Emitter<ABCCounterState>,
            idx: Int,
            delta: Int
        ) {
            val newState = when (idx) {
                0 -> s.copy(a = s.a + delta)
                1 -> s.copy(b = s.b + delta)
                2 -> s.copy(c = s.c + delta)
                else -> throw NotImplementedError()
            }
            emit(newState)
        }
    }

}

It is basically the same as CounterBloc but with three counters.

And here is code for the UI Screen that use BlocSelectors to extract relevant data from the Bloc state for each part of the UI. BlocSelectors cache the data extracted from the bloc state and will not trigger recomposition unless that specific part of the Bloc state changes.

class MultiCounterScreen : Screen {
    @Composable
    override fun Content() {
        MultiCounterScreenContent()
    }

    @Composable
    private fun MultiCounterScreenContent() {
        Column(
            modifier = Modifier.fillMaxWidth(),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            BlocProvider(create = { cscope -> ABCCounterBloc(cscope, Array(3) { 0 }) })
            {
                val bloc:ABCCounterBloc = rememberProvidedBloc() ?: return@BlocProvider
                val onIncr_a = remember {
                    {
                        bloc.add(MultiAddEvent(0, 1))
                    }
                }
                val onIncr_b = remember {
                    {
                        bloc.add(MultiAddEvent(1, 1))
                    }
                }
                val onIncr_c = remember {
                    {
                        bloc.add(MultiAddEvent(2, 1))
                    }
                }
                val onDecr_a = remember {
                    {
                        bloc.add(MultiSubEvent(0, 1))
                    }
                }
                val onDecr_b = remember {
                    {
                        bloc.add(MultiSubEvent(1, 1))
                    }
                }
                val onDecr_c = remember {
                    {
                        bloc.add(MultiSubEvent(2, 1))
                    }
                }
                val selb = SelectorFor<ABCCounterState>()
                //here with SelectorFor we build a selector that when one the input
                // fields "a","b","c" changes recalculated their sum and trigger recomposition
                BlocSelector(
                    bloc,
                    selb.withField { a }.withField { b }.withField { c }
                        .compute { a, b, c -> a + b + c }) {
                    Text("total count=$it")

                }
                // here we use a selector based on the single field "a", that when changes
                // trigger recomposition
                BlocSelector(bloc, selb.withSingleField { a })
                { counterValue ->
                    CounterControls(counterValue, onDecr_a, onIncr_a,"a")

                }
                Divider(modifier = Modifier.height(2.dp))
                // here we use a selector based on the single field "b", that when changes
                // trigger recomposition
                BlocSelector(bloc, selb.withSingleField { b })
                { counterValue ->
                    CounterControls(counterValue, onDecr_b, onIncr_b,"b")

                }
                Divider(modifier = Modifier.height(2.dp))
                // here we use a selector based on the single field "c", that when changes
                // trigger recomposition
                BlocSelector(bloc, selb.withSingleField { c })
                { counterValue ->
                    CounterControls(counterValue, onDecr_c, onIncr_c,"c")
                }

            }

        }
    }
}

Last updated