Saturday, 21 December, 2024
HomeProgrammingFlutterMastering the Flutter_bloc Library: A Comprehensive Guide from Basics to Advanced

Mastering the Flutter_bloc Library: A Comprehensive Guide from Basics to Advanced

Flutter, Google’s distinguished mobile app development platform, impresses not only with its capability to produce smooth and visually appealing apps for both iOS and Android from a single codebase but also with its rich ecosystem of powerful supporting libraries. Among these, flutter_bloc emerges as an intelligent state management solution that streamlines and modularizes app development.

Employing the BLoC (Business Logic Component) model, flutter_bloc not only clearly separates business logic from the user interface but also ensures scalability and maintainability of your codebase. Delving into reactive programming with Reactive Programming and Streams, this library offers the ability to update app states seamlessly, enabling your app to respond promptly to any changes.

With flutter_bloc, you gain not just a clear unidirectional data flow but also the capacity to reuse and modularize app components, thereby enhancing performance and easily managing the complexity of your project. Particularly for development following the Test-Driven Development (TDD) methodology and clean architecture, `flutter_bloc` is an indispensable tool for achieving the necessary flexibility and strength.

In this article, “Mastering the Flutter_bloc Library: A Comprehensive Guide from Basics to Advanced” we will explore in detail how flutter_bloc functions, from basic examples to implementation in larger projects, helping you to proficiently utilize this library and unlock new possibilities for high-quality Flutter applications. Let’s embark on the journey to elevate your app with flutter_bloc.

Stream

Stream in Dart is an asynchronous sequence of data, like a continuous flow of water. To be able to use the bloc library, you need to understand how Stream works.

How to create a simple Stream:

Stream<int> countStream(int max) async* {
  for (int i = 0; i < max; i++) {
    yield i;
  }
}

The function marked with async* will return a Stream of int type. The yield keyword will push each element into the Stream.

How to use a Stream:

var stream = countStream(10);

stream.listen((data) {
  print(data); 
});

// Result:
// 0
// 1
// 2
// ...

When initializing stream with countStream(), we have a Stream of integers. Use listen() to listen for new data emitted to the Stream.

Example: sum all elements in a Stream

Future<int> sumStream(Stream<int> stream) async {
  int sum = 0;
  await for (var value in stream) {
    sum += value;
  }
  return sum;
}

void main() async {
  var stream = countStream(10);
  var sum = await sumStream(stream);
  print(sum); // 45  
}

sumStream() takes a Stream, loops through and sums all elements. Finally returns the sum of the Stream.

So we have basically understood how to use Stream in Dart. Next, we’ll dig into the main concepts in the flutter_bloc library.

Cubit

Cubit is a simple class that inherits from BlocBase, used to manage application state.

Cubit Architecture

Create a Cubit

To create a Cubit, declare a class that inherits from Cubit and pass in the data type of the state it will manage.

For example with state of int type:

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);
}

In the constructor, call super() and pass in the initial value for the state.

We can also be more flexible by receiving the initial value from outside:

class CounterCubit extends Cubit<int> {
  CounterCubit(int initialState) : super(initialState);
}

var cubitA = CounterCubit(0);
var cubitB = CounterCubit(10);

Change State

To change state, call the emit() method inside Cubit.

For example a method to increment state by 1:

class CounterCubit extends Cubit<int> {

  CounterCubit() : super(0);

  void increment() => emit(state + 1);

}

When calling increment(), the current state is retrieved via state, incremented by 1 then pushed back in with emit().

Only methods inside Cubit can call emit().

Using Cubit

Simple usage of a Cubit:

// Initialize Cubit
var cubit = CounterCubit();

// Get current state
print(cubit.state); // 0

// Call method to change state  
cubit.increment();

// State has changed
print(cubit.state); // 1

We can also listen to state changes via Stream:

// Listen for changes
cubit.stream.listen((newState) {
  print(newState);
});

cubit.increment();
// Prints: 1

Use BlocObserver to centrally observe all Cubits in the app:

class CubitObserver extends BlocObserver {

  @override
  void onChange(BlocBase bloc, Change change) {
    print('${bloc.runtimeType} $change');
    super.onChange(bloc, change);
  }

}

Bloc.observer = CubitObserver();

var cubit = CounterCubit();
cubit.increment();

// Prints: CounterCubit Change(...)

Bloc

Bloc also inherits from BlocBase but works based on sending and handling events.

Bloc Architecture

Create a Bloc

When creating a Bloc, we need to declare:

  • Input events
  • Output state type
abstract class CounterEvent {}

class IncrementEvent extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0);
}

In which:

  • CounterEvent: input events of the Bloc
  • int: output state type
  • super(0): initialize initial state

Handle Events

To handle input events, use the on<Event>() method inside Bloc.

on<Event> takes in a callback with the event and emitter as parameters.

class CounterBloc extends Bloc<CounterEvent, int> {

  @override
  on<IncrementEvent>((event, emit) {
    // handle event
  });

}

In the callback we can:

  • Get current state via state
  • Handle logic based on input event
  • Emit new states using the emitter

For example handling IncrementEvent:

on<IncrementEvent>((event, emit) {
  emit(state + 1);
});

When receiving this event, state will be incremented by 1 and emitted.

Using Bloc

Basic usage of a Bloc:

// Initialize Bloc
var bloc = CounterBloc();

// Listen to state  
bloc.stream.listen((state) {
  // Update UI
});

// Add event
bloc.add(IncrementEvent());

When users interact, we will dispatch corresponding events to the Bloc.

The Bloc will handle events, update state and send to stream. UI will update when state changes.

Use BlocObserver to centrally observe all Blocs:

class BlocObserver extends BlocObserver {

  @override
  void onTransition(Bloc bloc, Transition transition) {
    print(transition);
    super.onTransition(bloc, transition);
  }

}

Bloc.observer = BlocObserver();  

var bloc = CounterBloc()..add(IncrementEvent());

// Prints: Transition(...)

Compare Cubit and Bloc

Cubit

Advantages:

  • Simple syntax, easy to understand and less code than Bloc.
  • Change state by directly calling methods, no need to declare events.

Disadvantages:

  • Hard to trace state changes.
  • Less extensible and customizable than Bloc.

Bloc

Advantages:

  • Can trace all state changes as well as the causes leading to those changes.
  • Can apply Rx operators like debounceTime, distinctUntilChanged,… to process event stream.
  • More extensible and customizable.

Disadvantages:

  • More complex syntax, more boilerplate code.
  • Need to declare separate events.

Conclusion

  • Cubit is suitable for simple cases. Bloc is suitable for complex logic.
  • Should start with Cubit when learning flutter_bloc. Can refactor to Bloc later if needed.

So the article has analyzed quite thoroughly the pros and cons of Cubit and Bloc. Hope it helps you choose the appropriate approach for your project.

Bloc Widgets

BlocBuilder

BlocBuilder is a Flutter widget that requires a Bloc and a builder function. BlocBuilder handles building the widget in response to new states. BlocBuilder is very similar to StreamBuilder but has a simpler API to reduce the required boilerplate code. The builder function can be called many times and should be a pure function that returns a widget to respond to the state.

Use BlocListener if you want to “execute” anything to respond to state changes like navigation, showing a SnackBar, Dialog, etc.

If the bloc parameter is omitted, BlocBuilder will automatically search using BlocProvider and the current BuildContext.

BlocBuilder<BlocA, BlocAState>(
  builder: (context, state) {
    // return widget based on BlocA's state
  }
)

Only specify the bloc if you want to provide a bloc scoped to a specific widget and not accessible via a parent BlocProvider and the current BuildContext.

BlocBuilder<BlocA, BlocAState>(
  bloc: blocA, // provide the local bloc instance
  builder: (context, state) {
    // return widget based on BlocA's state
  }
)

For more fine-grained control over when the builder function is called, you can pass an additional optional buildWhen. buildWhen takes the previous bloc state and current bloc state and returns a boolean. If buildWhen returns true, builder will be called with state and the widget will rebuild. If buildWhen returns false, builder will not be called with state and no rebuild will occur.

BlocBuilder<BlocA, BlocAState>(
  buildWhen: (previousState, state) {
    // return true/false to determine whether or not
    // to rebuild the widget with state
  },
  builder: (context, state) {
    // return widget based on BlocA's state
  }
)

BlocSelector

BlocSelector is similar to BlocBuilder but allows filtering updates by selecting a new value based on the current bloc state. Unnecessary rebuilds are prevented if the selected value does not change. The selected value must be immutable for BlocSelector to accurately determine if builder should be called again.

If the bloc parameter is omitted, BlocSelector will automatically search using BlocProvider and the current BuildContext.

BlocSelector<BlocA, BlocAState, SelectedState>(
  selector: (state) {
    // return selected state based on the provided state.
  },
  builder: (context, state) {
    // return widget based on the selected state.
  }
)

BlocProvider

BlocProvider is a Flutter widget that provides a bloc to its children via BlocProvider.of<T>(context). It is used as a dependency injection (DI) widget so that a single instance of a bloc can be provided to multiple widgets within a subtree.

In most cases, BlocProvider should be used to create new blocs which will be made available to the rest of the subtree. In this case, since BlocProvider is responsible for creating the bloc, it will automatically handle closing the bloc.

BlocProvider(
  create: (BuildContext context) => BlocA(),
  child: ChildA(),
);

By default, BlocProvider will create the bloc lazily, meaning create will be executed when the bloc is looked up via BlocProvider.of<BlocA>(context).

To override this behavior and force create to run immediately, lazy can be set to false.

BlocProvider(
  lazy: false,
  create: (BuildContext context) => BlocA(),
  child: ChildA(), 
);

In some cases, BlocProvider can be used to provide an existing bloc to a new portion of the widget tree. This will most commonly be used when an existing bloc needs to be made available to a new route. In this case, BlocProvider will not automatically close the bloc since it did not create it.

BlocProvider.value(
  value: BlocProvider.of<BlocA>(context),
  child: ScreenA(),
);

Then from either ChildA, or ScreenA we can retrieve BlocA with:

// with extensions
context.read<BlocA>();

// without extensions
BlocProvider.of<BlocA>(context)

MultiBlocProvider

MultiBlocProvider is a Flutter widget that merges multiple BlocProvider widgets into one. MultiBlocProvider improves readability and eliminates the need to nest multiple BlocProviders. By using MultiBlocProvider we can go from:

BlocProvider<BlocA>(
  create: (BuildContext context) => BlocA(),
  child: BlocProvider<BlocB>(
    create: (BuildContext context) => BlocB(),
    child: BlocProvider<BlocC>(
      create: (BuildContext context) => BlocC(),
      child: ChildA(),
    )
  )
)

to:

MultiBlocProvider(
  providers: [
    BlocProvider<BlocA>(
      create: (BuildContext context) => BlocA(),
    ),
    BlocProvider<BlocB>(
      create: (BuildContext context) => BlocB(),
    ),
    BlocProvider<BlocC>(
      create: (BuildContext context) => BlocC(),
    ),
  ],
  child: ChildA(),
)

BlocListener

BlocListener is a Flutter widget that takes a BlocWidgetListener and an optional Bloc and invokes the listener in response to state changes in the bloc. It should be used for functionality that needs to occur once per state change such as navigation, showing a SnackBar, showing a Dialog, etc.

The listener is only called once per state change (NOT including the initial state) unlike builder in BlocBuilder and is a void function.

If the bloc parameter is omitted, BlocListener will automatically perform a lookup using BlocProvider and the current BuildContext.

BlocListener<BlocA, BlocAState>(
  listener: (context, state) {
    // do stuff here based on BlocA's state
  },
  child: Container(),
)

Only specify the bloc if you wish to provide a bloc that is otherwise not accessible via BlocProvider and the current BuildContext.

BlocListener<BlocA, BlocAState>(
  bloc: blocA,
  listener: (context, state) {
    // do stuff here based on BlocA's state
  },
  child: Container()
)

For finer-grained control over when the listener function is called, an optional listenWhen can be provided. listenWhen takes the previous bloc state and current bloc state and returns a boolean. If listenWhen returns true, listener will be called with state. If listenWhen returns false, listener will not be called with state.

BlocListener<BlocA, BlocAState>(
  listenWhen: (previousState, state) {
    // return true/false to determine whether or not
    // to call listener with state
  },
  listener: (context, state) {
    // do stuff here based on BlocA's state
  },
  child: Container(),
)

MultiBlocListener

MultiBlocListener merges multiple BlocListener widgets into one. MultiBlocListener improves readability and eliminates the need to nest multiple BlocListeners. By using MultiBlocListener we can go from:

BlocListener<BlocA, BlocAState>(
  listener: (context, state) {},
  child: BlocListener<BlocB, BlocBState>(
    listener: (context, state) {},
    child: BlocListener<BlocC, BlocCState>(
      listener: (context, state) {},
      child: ChildA(),
    ),
  ),
)

to:

MultiBlocListener(
  listeners: [
    BlocListener<BlocA, BlocAState>(
      listener: (context, state) {},
    ),
    BlocListener<BlocB, BlocBState>(
      listener: (context, state) {},
    ),
    BlocListener<BlocC, BlocCState>(
      listener: (context, state) {},
    ),
  ],
  child: ChildA(),  
)

BlocConsumer

BlocConsumer exposes a builder and listener in order to react to new states. BlocConsumer is analogous to a nested BlocListener and BlocBuilder but reduces the amount of boilerplate needed. BlocConsumer should only be used when it is necessary to both rebuild UI and execute other reactions to state changes in the bloc. BlocConsumer requires a BlocWidgetBuilder and BlocWidgetListener and optionally a bloc, BlocBuilderCondition, and BlocListenerCondition.

If the bloc parameter is omitted, BlocConsumer will automatically perform a lookup using BlocProvider and the current BuildContext.

BlocConsumer<BlocA, BlocAState>(
  listener: (context, state) {
    // do stuff here based on BlocA's state
  },
  builder: (context, state) {
    // return widget here based on BlocA's state
  }
)

An optional listenWhen and buildWhen can be implemented for more granular control over when listener and builder are called. The listenWhen and buildWhen will be invoked on each bloc state change. They each take the previous state and current state and must return a boolean which determines whether or not the builder and/or listener function will be invoked. The previous state will be initialized to the state of the bloc when the BlocConsumer is initialized. listenWhen and buildWhen are optional and if they aren’t implemented they will default to true.

BlocConsumer<BlocA, BlocAState>(
  listenWhen: (previous, current) {
    // return true/false to determine whether or not
    // to invoke listener with state
  },
  listener: (context, state) {
    // do stuff here based on BlocA's state
  },
  buildWhen: (previous, current) {
    // return true/false to determine whether or not 
    // to rebuild the widget with state
  },
  builder: (context, state) {
    // return widget here based on BlocA's state
  }
)

RepositoryProvider

RepositoryProvider is a Flutter widget that provides a repository to its children via RepositoryProvider.of<T>(context). It is used as a dependency injection (DI) widget so that a single instance of a repository can be provided to multiple widgets within a subtree. BlocProvider should be used to provide blocs whereas RepositoryProvider should only be used for repositories.

RepositoryProvider(
  create: (context) => RepositoryA(),
  child: ChildA(),
);

Then from ChildA we can retrieve the Repository instance with:

// with extensions
context.read<RepositoryA>();

// without extensions
RepositoryProvider.of<RepositoryA>(context)

MultiRepositoryProvider

MultiRepositoryProvider is a Flutter widget that merges multiple RepositoryProvider widgets into one. MultiRepositoryProvider improves readability and eliminates the need to nest multiple RepositoryProviders. By using MultiRepositoryProvider we can go from:

RepositoryProvider<RepositoryA>(
  create: (context) => RepositoryA(),
  child: RepositoryProvider<RepositoryB>(
    create: (context) => RepositoryB(),
    child: RepositoryProvider<RepositoryC>(
      create: (context) => RepositoryC(),
      child: ChildA(),
    )
  )
)

to:

MultiRepositoryProvider(
  providers: [
    RepositoryProvider<RepositoryA>(
      create: (context) => RepositoryA(),
    ),
    RepositoryProvider<RepositoryB>(
      create: (context) => RepositoryB(),      
    ),
    RepositoryProvider<RepositoryC>(
      create: (context) => RepositoryC(),
    ),
  ],
  child: ChildA(),
)

Usage

Let’s look at how to use BlocProvider to provide a CounterBloc to a CounterPage and react to state changes with BlocBuilder.

// counter_bloc.dart

sealed class CounterEvent {}

class CounterIncrementPressed extends CounterEvent {}

class CounterDecrementPressed extends CounterEvent {}

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0);

  @override
  Stream<int> mapEventToState(CounterEvent event) async* {
    if (event is CounterIncrementPressed) {
      yield state + 1;
    } else if (event is CounterDecrementPressed) {
      yield state - 1;
    }
  }
}
// main.dart

void main() => runApp(CounterApp());

class CounterApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: BlocProvider(
        create: (_) => CounterBloc(),
        child: CounterPage(),
      ),
    );
  }
}
// counter_page.dart

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Counter')),
      body: BlocBuilder<CounterBloc, int>(
        builder: (context, count) {
          return Center(
            child: Text(
              '$count',
              style: TextStyle(fontSize: 24.0),
            ),
          );
        }
      ),
      floatingActionButton: Column(
        crossAxisAlignment: CrossAxisAlignment.end,
        mainAxisAlignment: MainAxisAlignment.end,
        children: <Widget>[
          Padding(
            padding: EdgeInsets.symmetric(vertical: 5.0),
            child: FloatingActionButton(
              child: Icon(Icons.add),
              onPressed: () => 
                context.read<CounterBloc>().add(CounterIncrementPressed()),
            ),
          ),
          Padding(
            padding: EdgeInsets.symmetric(vertical: 5.0),
            child: FloatingActionButton(
              child: Icon(Icons.remove),
              onPressed: () => 
                context.read<CounterBloc>().add(CounterDecrementPressed()),
            ),
          ),
        ],
      ),
    );
  }
}

At this point we have successfully separated our presentation layer from our business logic layer. Notice that the CounterPage widget knows nothing about what happens when a user taps the buttons. The widget simply tells the CounterBloc that the user has pressed either the increment or decrement button.

RepositoryProvider Usage

We will take a look at how to use RepositoryProvider in the context of the flutter_weather example.

// weather_repository.dart

class WeatherRepository {

  final MetaWeatherApiClient _weatherApiClient;

  WeatherRepository({MetaWeatherApiClient weatherApiClient}):
    _weatherApiClient = weatherApiClient ?? MetaWeatherApiClient();

  Future<Weather> getWeather(String city) async {
    final location = await _weatherApiClient.locationSearch(city);
    final woeid = location.woeid;
    final weather = await _weatherApiClient.getWeather(woeid);

    return Weather(
      temperature: weather.theTemp,
      location: location.title,
      condition: weather.weatherStateAbbr.toCondition,
    );
  }
}

Since the app has an explicit dependency on WeatherRepository we inject an instance via the constructor. This allows us to inject different instances of WeatherRepository based on the build flavor or environment.

// main.dart

import 'package:flutter/material.dart';
import 'package:flutter_weather/app.dart';
import 'package:weather_repository/weather_repository.dart';

void main() {
  runApp(WeatherApp(weatherRepository: WeatherRepository()));
}

Since we only have one repository in our app, we will inject it into our widget tree via RepositoryProvider.value. If you have more than one repository, you can use MultiRepositoryProvider to provide multiple repository instances to the subtree.

// app.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:weather_repository/weather_repository.dart';

class WeatherApp extends StatelessWidget {

  final WeatherRepository _weatherRepository;

  const WeatherApp({
    Key key,
    required WeatherRepository weatherRepository
  }) : _weatherRepository = weatherRepository, super(key: key);

  @override
  Widget build(BuildContext context) {
    return RepositoryProvider.value(
      value: _weatherRepository,
      child: BlocProvider(
        create: (_) => ThemeCubit(),
        child: WeatherAppView(),
      ),
    );
  }
}

In most cases, the root app widget will expose one or more repositories to the subtree via RepositoryProvider.

// weather_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_weather/weather/weather.dart';
import 'package:weather_repository/weather_repository.dart';

class WeatherPage extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => 
        WeatherCubit(context.read<WeatherRepository>()),
      child: WeatherView(),
    );
  }
}

Now when instantiating a bloc, we can access the repository instance via context.read and inject the repository into the bloc via the constructor.

Related articles

Most Popular