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.
classCounterScreen : Screen@ComposableoverridefunContent() {CounterScreenContent() }}@ComposablefunCounterScreenContent() {// Currently the counter value is fixed and the buttons do nothing...CounterControls( counterValue=1, onDecrement={}, onIncrement={} )}@ComposablefunCounterControls( 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:
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
Now that have defined the state with the CounterBloc class, how we use it in Compose? This is done using BlocProvider:
@ComposablefunCounterScreenContent() {// BlocProvider makes available the specified bloc (CounterBloc) to the // associated composable subtreeBlocProvider(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
@ComposablefunCounterScreenContent() {BlocProvider(create = {cscope->CounterBloc(cscope,1)} ) {//rememberProvidedBloc is similar to dependency injection: it retrieves the specified//bloc type as defined by the closest enclosing BlocProviderval b:CounterBloc=rememberProvidedBloc()?:return@BlocProviderval 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.
@ComposablefunCounterScreenContent() {BlocProvider(create = {cscope->CounterBloc(cscope,1)} ) {val b:CounterBloc=rememberProvidedBloc()?:return@BlocProviderval 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 recompositionBlocBuilder(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
@ComposablefunCounterScreenContent() {BlocProvider(create = {cscope->CounterBloc(cscope,1)} ) {val b:CounterBloc=rememberProvidedBloc()?:return@BlocProviderval 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 recompositionBlocBuilder(b) { counterState->CounterControls( counterValue=counterState.counter, onDecrement=onDecrement, onIncrement=onIncrement ) }BlocListener(b) { counterState ->if(counterState==10) { Log.d("tag","We have reached count 10!") } } }}
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
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.
classMultiCounterScreen : Screen {@ComposableoverridefunContent() {MultiCounterScreenContent() }@ComposableprivatefunMultiCounterScreenContent() {Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) {BlocProvider(create = { cscope ->ABCCounterBloc(cscope, Array(3) { 0 }) }) {val bloc:ABCCounterBloc=rememberProvidedBloc() ?: return@BlocProviderval 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 recompositionBlocSelector( 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 recompositionBlocSelector(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 recompositionBlocSelector(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 recompositionBlocSelector(bloc, selb.withSingleField { c }) { counterValue ->CounterControls(counterValue, onDecr_c, onIncr_c,"c") } } } }}