Business Logic Component [4/4]
In this final article of the BLoC series, we will look at code examples and practical implementation of several popular cases.
The first case - creating your own simple bloc based on a change notifier.
In the second example, we implement pagination using the bloc
package.
Creating your own BLoC using ChangeNotifier
This can be useful if you want your own implementation without using streams and libraries, for example, in other languages or for your package.
Just in case, the implementation of change notification is straightforward, but of course, with flutter, you should use ChangeNotifier
from package:flutter/foundation.dart
mixin ChangeNotifier {
final List<VoidCallback> _listeners = List<VoidCallback>.empty();
bool get hasListeners => _listeners.isNotEmpty;
void addListener(VoidCallback listener) => _listeners.add(listener);
void removeListener(VoidCallback listener) => _listeners.remove(listener);
void notifyListeners() => _listeners.forEach((listener) => listener.call());
void dispose() => _listeners.clear();
}
Let's create a base "BLoC" class based on ChangeNotifier:
import 'package:flutter/foundation.dart'
show Listenable, ValueListenable, VoidCallback, ChangeNotifier;
import 'package:meta/meta.dart';
typedef SetState<State extends Object> = void Function(State state);
/// Selector from [BLoC]
typedef BLoCSelector<BLoC extends Listenable, Value> = Value Function(
BLoC bloc,
);
/// Filter for [BLoC]
typedef BLoCFilter<State> = bool Function(State prev, State next);
abstract class BLoC<State extends Object> with ChangeNotifier {
BLoC(State initialState) : _$state = initialState;
/// The current state of the bloc
@nonVirtual
State get state => _$state;
State _$state;
/// Whether the bloc is currently handling a request
@nonVirtual
bool get isProcessing => _$isProcessing;
bool _$isProcessing = false;
@nonVirtual
@protected
void setState(State state) {
_$state = state;
notifyListeners();
}
@nonVirtual
@protected
Future<void> handle(
Future<void> Function(SetState<State> setState) handler,
) async {
// For throttling handle calls
// also you can implement a queue for handle calls or something else
if (_$isProcessing) return;
_$isProcessing = true;
notifyListeners();
try {
await handler(setState);
} on Object {
// TODO: Rethrow all errors to global observer,
// and handle them in a single place
} finally {
_$isProcessing = false;
notifyListeners();
}
}
@protected
@nonVirtual
@override
void notifyListeners() => super.notifyListeners();
/// Transform [BLoC] in to [ValueListenable]
@nonVirtual
ValueListenable<Value> select<Value>(
BLoCSelector<BLoC<State>, Value> selector, [
BLoCFilter<Value>? test,
]) =>
_BLoCView<BLoC<State>, Value>(this, selector, test);
}
@sealed
class _BLoCView<BLoC extends Listenable, Value>
with ChangeNotifier
implements ValueListenable<Value> {
_BLoCView(
BLoC bloc,
BLoCSelector<BLoC, Value> selector,
BLoCFilter<Value>? test,
) : _bloc = bloc,
_selector = selector,
_test = test;
final BLoC _bloc;
final BLoCSelector<BLoC, Value> _selector;
final BLoCFilter<Value>? _test;
@override
Value get value => hasListeners ? _$value : _selector(_bloc);
late Value _$value;
void _update() {
final newValue = _selector(_bloc);
if (identical(_$value, newValue)) return;
if (!(_test?.call(_$value, newValue) ?? true)) return;
_$value = newValue;
notifyListeners();
}
@override
void addListener(VoidCallback listener) {
if (!hasListeners) {
_$value = _selector(_bloc);
_bloc.addListener(_update);
}
super.addListener(listener);
}
@override
void removeListener(VoidCallback listener) {
super.removeListener(listener);
if (!hasListeners) _bloc.removeListener(_update);
}
@override
void dispose() {
_bloc.removeListener(_update);
super.dispose();
}
}
Thus, we created a base class and, in addition, made it possible to convert it to ValueListenable
, for use in the ValueListenableBuilder.
Let's now write an example implementation:
@freezed
class SampleState with _$SampleState {
const SampleState._();
/// Idling state
const factory SampleState.idle({
required final Data? data,
@Default('Idle') final String message,
}) = IdleSampleState;
/// Processing
const factory SampleState.processing({
required final Data? data,
@Default('Processing') final String message,
}) = ProcessingSampleState;
/// Successful
const factory SampleState.successful({
required final Data data,
@Default('Successful') final String message,
}) = SuccessfulSampleState;
/// An error has occurred
const factory SampleState.error({
required final Data? data,
@Default('An error has occurred') final String message,
}) = ErrorSampleState;
/// Data
Data? get data => map<Data?>(
idle: (state) => state.data,
processing: (state) => state.data,
successful: (state) => state.data,
error: (state) => state.data,
);
/// Has data
bool get hasData => data != null;
/// If an error has occurred
bool get hasError => maybeMap<bool>(orElse: () => false, error: (_) => true);
/// Is in progress state
bool get isProcessing => maybeMap<bool>(orElse: () => false, processing: (_) => true);
}
class SampleBLoC extends BLoC<SampleState> {
SampleBLoC({required ISampleRepository repository})
: _repository = repository,
super(const SampleState.idle());
final ISampleRepository _repository;
Future<void> fetch({required String id}) => handle((setState) async {
try {
setState(SampleState.processing(data: state.data));
final newData = await _repository.fetch(id: id);
setState(SampleState.successful(data: newData));
} on Object catch (error) {
setState(SampleState.error(data: state.data, message: ErrorUtil.formatMessage(error)));
rethrow;
} finally {
setState(SampleState.idle(data: state.data));
}
});
}
That's it, and now we have our own implementation of BLoC on ChangeNotifier
.
Making your own implementation "on the streams" is also not a difficult task. These are two stream controllers (events and states), subscribing to events at the constructor and converting events to states (for example, using asyncExpand
or switchMap
stream transformer).
BLoC implementation with pagination
Let's try to implement typical pagination using a cursor (you can use any other method instead of a cursor, the primary approach will be the same).
First of all, let's create our data classes for the model, pagination chunk, and repository's interface:
typedef TweetID = String;
@immutable
abstract class Tweet with Comparable<Tweet> {
factory Tweet.fromJson(Map<String, Object?> json) => throw UnimplementedError();
abstract final TweetID id;
Map<String, Object?> toJson();
@override
int compareTo(Tweet other) => id.compareTo(other.id);
@override
bool operator ==(Object other) => identical(other, this) || other is Tweet && id == other.id;
@override
int get hashCode => id.hashCode;
}
@immutable
abstract class TweetsChunk {
abstract final List<Tweet> tweets;
abstract final String? cursor;
abstract final bool endOfList;
}
abstract class ITweetsRepository {
@useResult
Future<TweetsChunk> paginate({String? cursor});
}
Now time to create our states for pagination:
/// Business Logic Component Tweets States
@freezed
class TweetsState with _$TweetsState {
const TweetsState._();
/// Idling state
const factory TweetsState.idle({
required final List<Tweet> tweets,
required final String? cursor,
required final bool endOfList,
@Default('Idle') final String message,
}) = IdleTweetsState;
/// Processing
const factory TweetsState.processing({
required final List<Tweet> tweets,
required final String? cursor,
required final bool endOfList,
@Default('Processing') final String message,
}) = ProcessingTweetsState;
/// Successful
const factory TweetsState.successful({
required final List<Tweet> tweets,
required final String? cursor,
required final bool endOfList,
@Default('Successful') final String message,
}) = SuccessfulTweetsState;
/// An error has occurred
const factory TweetsState.error({
required final List<Tweet> tweets,
required final String? cursor,
required final bool endOfList,
@Default('An error has occurred') final String message,
}) = ErrorTweetsState;
static const TweetsState initialState = TweetsState.idle(tweets: <Tweet>[], cursor: null, endOfList: false);
/// If an error has occurred
bool get hasError => maybeMap<bool>(orElse: () => false, error: (_) => true);
/// Is in progress state
bool get isProcessing => maybeMap<bool>(orElse: () => false, processing: (_) => true);
/// Has more data to fetch
bool get hasMore => !endOfList;
}
And, of course, events (let there be only one).
It's a good idea to add mixins that release the next state based on the current state and the event. This will significantly reduce the amount of duplicate code in the BLoC implementation.
/// Business Logic Component Tweets Events
@freezed
class TweetsEvent with _$TweetsEvent {
const TweetsEvent._();
/// Fetch & paginate
@With<_ProcessingStateEmitter>()
@With<_SuccessfulStateEmitter>()
@With<_ErrorStateEmitter>()
@With<_IdleStateEmitter>()
const factory TweetsEvent.paginate() = _PaginateTweetsEvent;
}
mixin _ProcessingStateEmitter on TweetsEvent {
TweetsState processing({
required final TweetsState state,
final String? message,
}) =>
TweetsState.processing(
tweets: state.tweets,
cursor: state.cursor,
endOfList: state.endOfList,
message: message ?? 'Processing',
);
}
mixin _SuccessfulStateEmitter on TweetsEvent {
TweetsState successful({
required final TweetsState state,
required final TweetsChunk chunk,
final String? message,
}) =>
TweetsState.successful(
// Append new tweets to the existing ones
// you can use more tricky logic to get rid of duplicates
// (for example, added from the cache)
tweets: <Tweet>[...state.tweets, ...chunk.tweets]..sort(),
cursor: chunk.cursor,
endOfList: chunk.endOfList,
message: message ?? 'Successful',
);
}
mixin _ErrorStateEmitter on TweetsEvent {
TweetsState error({
required final TweetsState state,
final String? message,
}) =>
TweetsState.error(
tweets: state.tweets,
cursor: state.cursor,
endOfList: state.endOfList,
message: message ?? 'An error has occurred',
);
}
mixin _IdleStateEmitter on TweetsEvent {
TweetsState idle({
required final TweetsState state,
final String? message,
}) =>
TweetsState.idle(
tweets: state.tweets,
cursor: state.cursor,
endOfList: state.endOfList,
message: message ?? 'Idle',
);
}
It's Business Logic Component time.
Please note that I register all events with one transformer handler on
.
And also pay attention to the fact that I do not create additional mutable variables for the BLoC, all data is stored in the state, and new states are released due to the data of the previous one and event.
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:bloc_concurrency/bloc_concurrency.dart' as bloc_concurrency;
/// Business Logic Component TweetsBLoC
class TweetsBLoC extends Bloc<TweetsEvent, TweetsState> implements EventSink<TweetsEvent> {
TweetsBLoC({
required final ITweetsRepository repository,
final TweetsState? initialState,
}) : _repository = repository,
super(initialState ?? TweetsState.initialState) {
// In most cases, all events must be recorded within one stream transformer
on<TweetsEvent>(
(event, emit) => event.map<Future<void>>(
paginate: (event) => _paginate(event, emit),
),
// Choose the transformer that suits your currency needs
transformer: bloc_concurrency.droppable(),
//transformer: bloc_concurrency.sequential(),
//transformer: bloc_concurrency.restartable(),
//transformer: bloc_concurrency.concurrent(),
);
}
final ITweetsRepository _repository;
/// Fetch event handler
Future<void> _paginate(_PaginateTweetsEvent event, Emitter<TweetsState> emit) async {
if (state.isProcessing || state.endOfList) return;
try {
emit(event.processing(state: state));
final chunk = await _repository.paginate(cursor: state.cursor);
emit(event.successful(state: state, chunk: chunk));
} on Object {
emit(event.error(state: state));
rethrow;
} finally {
emit(event.idle(state: state));
}
}
}
It's simple, isn't it?
You got currency, immutability, error handling, four states, and pagination without any boilerplate.
Homework: add restoration from cache and filtering.
This completes the series of articles about the bloc.
I will try to update articles occasionally and keep them up to date.
Good luck in mastering the endless expanses of dart and flutter.