response_builder 0.0.6
response_builder: ^0.0.6 copied to clipboard
Work with asynchronously loaded data with ease. Mixins to enable developer to create widget that builds UI with data from different data source. And request/result listenable as high level obvserable [...]
response_builder #
Why create this library #
FutureBuilder StreamBuilder are the foundation to consume any data that loaded from network or persistent storage. FutureBuilder, StreamBuilder are designed to be robust, flexible but it is not designed to be convenient. In fact handling with AsyncSnapshot in the callback is actually kind of complicated and error-prone, especially when you needs busy state, initial value, or switching to observe new future/stream from the old ones. Any mistake in the callback could cause bug in UI.
To reduce the complexity and avoid repetitive work to implement AsyncWidgetBuilder a thousand times, this library introduce some mixins, which help developer to consume data from future, stream, value listenable or other kind of observable sources in a nice and easy way.
What response_builder can do #
It enables flutter developer to implement following features with minimum effort:
- Loading Screen: Show a loading view when data not comming back
- Error Screen: Show an error screen when something goes wrong
- Empty Screen: Some something useful when API returns an empty list (As
ListViewand others don't support to render empty collection) - Refresh/Retry: Fire the same request again when something goes wrong or just want to refresh the data
- Optimal update: update the UI optimally first, and refresh UI again when API returns.
What's in library #
Key types #
- Data Source
- [Request] - Listenable data source for 3-state asynchronous data
- [ResultListenable] and [ResultNotifier] - Listenable data source for 2-state synchronized data
- Build Protocols
- [BuildAsyncSnapshot] - This protocol enable [BuildAsyncSnapshotActions] to consume 3-state data from [Future], [Stream] or [Request]
- [BuildResult] - This protocol enable enables [BuildResultListenable] to consume 2-state data from [ResultListenable]
- [BuildValue] - This protocol enable enables [BuildValueListenable] to consume value from [ValueListenable]
- [WithEmptyValue] - Protocol implement [BuildValue.buildValue] contract, which enables building actions to handle empty value
- Build Actions
- [BuildAsyncSnapshotActions] - Actions run on [BuildAsyncSnapshot] protocol to consume 3-state
AsyncResultdata from [Future], [Stream] or [Request], - [BuildResultListenable] - Actions run on [BuildResult] protocol, to consume 2-state
Resultfrom [ResultListenable] - [BuildValueListenable] - Actions run on [BuildValue] protocol, to consume value from [ValueListenable]
- [BuildAsyncSnapshotActions] - Actions run on [BuildAsyncSnapshot] protocol to consume 3-state
Consume AsyncResult from Future #
To consume AsyncResult is easy:
- Create widget, could be either stateless or stateful, them both works
- Implement
BuildAsyncResultprotocol by addBuildAsyncSnapshot<T>mixin to the widget class forStatelessWidgetor to theStateclass forStatefulWidget.Tis the the data type hold byFuture. - Implement a
Widget buildValue(BuildContext context, T value)method, which is the contract to build widget when value is loaded fromStream,Future. - Calling
buildFuturein widget'sbuildmethod. - You're done.
class MyWidget extends StatelessWidget with BuildAsyncSnapshot<String> {
final Future<T> dataSource;
MyWidget(this.dataSource);
@override
Widget build(BuildContext context) {
// Calling buildFuture is dataSource is Future
return buildFuture(dataSource);
}
@override
Widget buildValue(BuildContext context, String value) {
// Implement buildValue contract to render UI when value is successfully fetched
return Center(
child: Text(value),
);
}
}
As Future holds AsyncResult, which could be Loading, Value or Error, so MyWidget also has 3 different state, based on AsyncResult:
- Renders a
CircularLoadingIndicatorin the middle of parent before result is ready. - Render the value in the middle of the screen, when
Futureyields value - Render
!icon with message inerror colorof current theme in the middle of parent whenFutureyields error.
No more direct mess around the AsyncSnapshot or FutureBuilder.
Customize Error View / Loading View #
Default loading view / error view is convenient when it is more the focus of future. But sometimes you might do want to have granular control on how they looks like. To do so, you can
- Override
buildErrormethod, which is the contract being used to build error view. - Override
buildLoadingmethod, which is the contract being used to build loading view.
Here is some example, it builds UI in Cupertino style instead of Material style:
class MyWidget extends StatelessWidget with BuildAsyncSnapshot<List<String>> {
final Future<T> dataSource;
MyWidget(this.dataSource);
@override
Widget build(BuildContext context) {
// Calling buildFuture is dataSource is Future
return buildFuture(dataSource);
}
@override
Widget buildLoading(BuildContext context) {
// Build Cupertino style UI instead of default Material style
return Center(
child: CupertinoActivityIndicator(),
);
}
@override
Widget buildError(BuildContext context, Object error) {
// Build Cupertino style UI instead of default Material style
final errorColor = CupertinoColors.systemRed;
return Center(
child: Row(children: [
Icon(CupertinoIcons.exclamationmark_circle, color: errorColor),
Text(error.toString(), style: TextStyle(color: errorColor)),
]),
);
}
@override
Widget buildValue(BuildContext context, List<String> value) {
return ListView.builder(
itemCount: value.length,
builder: (context, index) => Text(value[index]),
);
}
}
Customize Error View / Loading View across the whole app #
Overriding buildError and buildLoading contract provides detailed control of how error view or loading view would like. But it is done on a case by case manner.
Sometimes, we want to change them across the whole app. This can be done by registering DefaultLoadingBuilder and DefaultErrorBuilder.
Firstly, register the default builders somewhere convenient, such as in the main method
void main () {
DefaultBuildActions.registerDefaultLoadingBuilder((context) {
return Center(
child: CupertinoActivityIndicator(),
);
});
DefaultBuildActions.registerDefaultErrorBuilder((context, error) {
final errorColor = CupertinoColors.systemRed;
return Center(
child: Row(children: [
Icon(CupertinoIcons.xmark_circle, color: errorColor),
Text(error.toString(), style: TextStyle(color: errorColor)),
]),
);
});
runApp(MyApp());
}
Then remove the overrides of buildError and buildLoading, so it can use the default builders.
class MyWidget extends StatelessWidget with BuildAsyncSnapshot<List<String>> {
final Future<T> dataSource;
MyWidget(this.dataSource);
@override
Widget build(BuildContext context) {
// Calling buildFuture is dataSource is Future
return buildFuture(dataSource);
}
@override
Widget buildValue(BuildContext context, List<String> value) {
return ListView.builder(
itemCount: value.length,
builder: (context, index) => Text(value[index]),
);
}
}
WithEmptyValue protocol #
MyWidget above might not work in every case, it would complain if the Future returns an Empty Value, in example, it is a empty list.
And ListView can't build empty list, so it complains.
WithEmptyValue is the protocol designed to address this particular issue:
class MyWidget extends StatelessWidget with BuildAsyncSnapshot<List<String>>, WithEmptyValue<List<String>> {
final Future<T> dataSource;
MyWidget(this.dataSource);
@override
Widget build(BuildContext context) {
// Calling buildFuture is dataSource is Future
return buildFuture(dataSource);
}
// Instead of implement buildValue, you should implement buildContent as minimal implementation
@override
Widget buildContent(BuildContext context, List<String> value) {
return ListView.builder(
itemCount: value.length,
builder: (context, index) => Text(value[index]),
);
}
}
There are 2 changes in this example from the one above:
- Add
WithEmptyValue<List<String>>toMyWidget. - Instead of implement
buildValuecontract, it implementsbuildContentcontract, with exactly same implementation.
With this code, when Future yield empty list, MyWidget just build an empty Container instead of ListView, so from user's perspective, UI renders "nothing".
Customize Empty Screen #
By Default, WithEmptyValue renders an empty Container for empty value is received, but it can be customized by override buildEmpty contract:
class MyWidget extends StatelessWidget with BuildAsyncSnapshot<List<String>>, WithEmptyValue<List<String>> {
final Future<T> dataSource;
MyWidget(this.dataSource);
@override
Widget build(BuildContext context) {
// Calling buildFuture is dataSource is Future
return buildFuture(dataSource);
}
// Instead of implement buildValue, you should implement buildContent as minimal implementation
@override
Widget buildContent(BuildContext context, List<String> value) {
return ListView.builder(
itemCount: value.length,
builder: (context, index) => Text(value[index]),
);
}
@override
Widget buildEmpty(BuildContext context, List emptyContent) {
// Override Empty Screen
return Center(
child: Text("Hooray! No more remaining todo for today!"),
);
}
}
Handle "empty model" #
WithEmptyValue understands common data types:
- Anything is
Iterable- Most of the built-in collection types, such as
ListSetis covered. - Majority of the 3rd party collection types should works too, such as the popular
BuiltListorKtList.
- Most of the built-in collection types, such as
- Any kind of
Map, which might be used to render form or data sheet or so. nullis always considered asemptyby default
To deal with anything not covered by those 3 rules, an UnsupportedError would be thrown. In this case, checkIsValueEmpty contract need to be implemented manually to make it work.
For example, MyTableView build a table from a Future<List<List<String>>>, the row length is fixed to be 5, but columns not determined, which can be none.
In this case, the default checkIsValueEmpty logic won't work, as there are always 5 rows. Instead of checking the rows, we need to check columns.
class MyTableView extends StatelessWidget with BuildAsyncSnapshot<List<List<String>>>, WithEmptyValue<List<List<String>>> {
final Future<List<List<String>>> future;
const MyTableView({Key key, this.future}) : super(key: key);
@override
Widget build(BuildContext context) {
return buildFuture(future);
}
// Instead of implement buildValue, you should implement buildContent as minimal implementation
@override
Widget buildContent(BuildContext context, List<List<String>> content) {
return Table(
children: content.map((r) => _buildRow(r)).toList(growable: false),
)
}
Widget _buildRow(List<String> rowData) {
return TableRow(
children: rowData.map((c) => Text(c)).toList(growable: false),
);
}
@override
bool checkIsValueEmpty(List<List<String>> value) {
// Every row in table should have same number of columns, so check first row should be enough
return value.first.isEmpty;
}
}
"Universal" data builder #
This library organised the code with a protocol/contract/actions based approach, so it actually enables the code to be flexible but not losing control. Here is a not very useful but interesting example to explain the idea:
This is is a universal widget that can consume a list of screen from:
List<String>: static data, UI won't update once buildValueListenable: observable sync data source, UI freshes when data source changedResultListenable: observable sync 2-state data source, error view would be shown if result is an errorFuture: one-time observable async 3-state data source, a loading screen would be shown before result is ready, then a value view or error view would shownStream: on-going observable async 3-state data source, a loading screen would be shown before result is ready, UI would update if new result is sent by remote sourceRequest: on-going observable async 3-state data source, a loading screen would be shown before result is ready, UI would update by either controlled by remotely or locally.
class UniversalDataList extends StatelessWidget with BuildAsyncSnapshot<List<String>>, WithEmptyValue<List<String>> {
final dynamic dataSource;
MyWidget(this.dataSource);
@override
Widget build(BuildContext context) {
if(dataSource is ValueListenable<List<String>>) {
return buildValueListenable(dataSource); // action from `BuildValueListenable`, depends on BuildValue protocol
} else if(dataSource is ResultListenable<List<String>>) {
return buildResultListenable(dataSource); // action from `BuildResultListenable`, depends on BuildResult protocol
} else if(dataSource is Future<List<String>>) {
return buildFuture(dataSource); // action from `BuildAsyncSnapshotActions, depends on BuildAsyncSnapshot protocol
} else if(dataSource is Stream<List<String>>) {
return buildStream(dataSource); // action from `BuildAsyncSnapshotActions, depends on BuildAsyncSnapshot protocol
} else if(dataSource is Request<List<String>>) {
return buildRequest(dataSource); // action from `BuildAsyncSnapshotActions, depends on BuildAsyncSnapshot protocol
} else if(dataSource is List<String>) {
return buildValue(context, dataSource); // Calling buildValue contract from BuildValue protocol
} else {
throw UnsupportedError("Unsupported data source ${dataSource.runtimeType}");
}
}
// Instead of implement buildValue, you should implement buildContent as minimal implementation
@override
Widget buildContent(BuildContext context, List<String> value) {
return ListView.builder(
itemCount: value.length,
builder: (context, index) => Text(value[index]),
);
}
}
What is Request and ResultListenable, and what they used for #
To better explain wha Request and ResultListable is, some terminologies should be explained first
Some concepts #
- Value - The most common type of
data, which is aresultthat holds a piece of information can be used to render UI - Error - A special type of
datawhich indicatesresultis ready, something went wrong during the process.Erroris exclusive tovalue. - Loading - A special type of
datawhich indicates theresultis not yet available. It is supposed to be temporary, which eventually replaced by eithervalueor anerror. - Result - A 2-state
data, which could be eithervalueorerror.Resultis synchronousdata. - AsyncResult - A 3-state
data, which can bevalue,error, orloading,AsyncResultis asynchronousdata. - Data - The general concept of information used by app.
- Empty - A special type of
Value, which is a legalvalue, but contains no information, such as[]ofList<String>.
Different type of data sources #
With the concepts above, the data sources can be categories as:
List<String>: Thedataitself, it is static, its value won't change.ValueListenable: A synchronous observable data source, which providesvalue.ResultListenable: A synchronous 2-state observable data source, which providesresult.Future: A one-time-use asynchronous 3-state observable data source, which providesasync resultFutureisone-time-use, soloadingwould only appears once, then its value fixed in either avalueorerror. Can't changed afterward.
Stream: An on-going asynchronous 3-state observable data source, which providesasync result.Streamison-goingdata source, whose value could changing across time as many times as it needs to be.Stream's data is typically controlled by remote source, such as a file or remote server.Stream's data can only be accessed by listener in asynchronous way.Streamneeds to be listened before data is feeding, or previous values are lost.
Request: An on-going asynchronous 3-state observable data source that providesasync result.Requestison-goingdata source, whose value could changing across time as many times as it needs to be.Request's value can be accessible in a synchronous by any one who has its instance.Request's value can be manipulated locally.Requestcan be listened as many times as it needs to be.Requestcan be listened by as many listeners simultaneously as it needs to be.Requestcan be fed withfuture,async resultyielded byfuturegoes intorequest.
Conclusion #
So with this comparison, it is clear that:
Requestis designed to be used as app state manager, whilefutureorstreamis more a low-level data source.
ResultListenableis used to fill the gap in flutter framework, which is being lack of 2-state synchronous data source.
Loading data from network with Request #
Using Request is much easier than explaining what Request is and what is is for.
Here is an example that demonstrates loading some data from a search API:
class MySearchRequest extends Request<List<SearchItem>> {
final String keywords;
MySearchRequest(this.keywords);
Future<List<SearchItem>> load() async {
final response = await searchApi.search(keywords: keywords);
if (response.statusCode != 200) {
throw NetworkException("Failed to execute search, please retry");
}
return response.parseBody();
}
}
Every request should implement at least load method, which is the contract used to specify how the data should be loaded.
Use Request in widget #
Build widget with Request can be easily done with BuildAsyncSnapshot protocol and buildRequest action:
class SearchResultView extends StatelessWidget with BuildAsyncSnapshot<List<SearchItem>>, WithEmptyValue<List<SearchItem>> {
final MySearchRequest request;
SearchResultView(this.request);
@override
Widget build(BuildContext context) {
// use method from BuildAsyncSnapshot to render request
return buildRequest(request);
}
@override
Widget buildError(BuildContext context, Object error) {
return Center(
child: Row(children: [
Text(error.toString()),
TextButton(
child: Text("Retry"),
onPressed: () => request.reload(), // Retry to do search again
)
]),
);
}
@override
Widget buildContent(BuildContext context, List<SearchItem> content) {
return ListView.builder(
itemCount: content.length,
itemBuilder: (context, index) => SearchItemView(content[index]),
);
}
}
Besides being used to async result data source, Request also provides APIs to do other common tasks, such as re-execute load and feed its result to request, aka retry when request holds an error or refresh when request holds a value.
Use Request as business logic controller instead of using StatefulWidget. #
Request provides a bunch of API to work with 3-state async result with ease, and every changes on its value would notifies UI, eventually have UI updated with contracts defined by BuildAsyncResult. So Request can be used a logical controller that manages app state, instead of using a StatefulWidget.
Using Request as business logic controller has a few benefits:
- Decoupling the business logic from UI
- Business logic can be tested independently without UI
- UI can be stateless, which is cheapter to build and easier to maintain.
As Request is highly optimized for the scenario dealing with data loading, so in a few particular cases,Request could be a much-easier-to-use replacement of other complicated solutions, such as bloc, mobx or redux style reducer.
An example to make request do something more than just load:
class MySearchRequest extends Request<List<SearchItem>> {
final String keywords;
MySearchRequest(this.keywords);
Future<List<SearchItem>> load() async {
final response = await searchApi.search(keywords: keywords);
if (response.statusCode != 200) {
throw NetworkException("Failed to execute search, please retry");
}
return response.parseBody();
}
Future saveSearchResult(SearchResultFile file) async {
if (this.hasValue) { // Check whether request holds a value
// write search result to file if it exists
await file.writes(currentValue);
} else if (this.hasError) {
// writes error to file with writeError method
await file.writeError(currentError);
}
// Skip if request is loading.
}
Future loadSearchResult(SearchResultFile file) async {
Future<List<SearchItem>> Function() loadAction = file.read;
// Feed a future into request, it updates UI just as the UI is listening to the future.
await execute(loadAction);
}
void clearSearchResult() {
// update request's data with given value
putValue([]);
}
void trimResult(int limit) {
// Update request's value based on current value
// Exception happened during the updating is caught by request and rendered on UI automatically.
updateValue((current) => current.take(limit).toList());
}
Future appendFromFile(SearchResultFile file) {
// Update request's value based on current value in asynchronous way
// Exception happened during the updating is caught by request and rendered on UI automatically.
return updateValueAsync((current) async => current + await file.read());
}
}
handle 2-state synchronous result with ResultNotifier #
ResultNotifier is a parallel type to ValueNotifier but holds 2-state result instead of just value.
2-state result is represented with Result from async package
ResultNotifier could be useful when dealing with the case that error in data is not fatal.
Here is a simple example:
FormData holds a list of string form-field values, which can be either valid or invalid.
class FormData {
final Map<String, ResultNotifier<String>> fields;
FormData(Map<String, String> initialValues)
: fields = Map.fromIterables(
initialValues.keys, // field keys
initialValues.values.map(
// wrap initial with ResultNotifier
(initialValue) => ResultNotifier(initialValue),
),
);
void invalidField(String fieldName) {
// String can be thrown
// Notifier would treat thrown string as error
// updateValue would only call its callback when it holds value
fields[fieldName].updateValue((current) => throw current);
}
void validField(String fieldName) {
// Fix error only execute when notifier holds error
// the returned value is used as value
// fixError would only call its callback when it holds error
fields[fieldName].fixError((error) => error);
}
}
Build widget with ResultNotifier/ ResultListenable #
ResultListenable can be consumed with buildResultListenable action on any type implemented BuildResult protocol:
class FormFieldView extends StatelessWidget with BuildResult<String> {
final String fieldName;
final ResultListenable<String> listenable;
const FormFieldView({Key key, this.fieldName, this.listenable}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(fieldName),
buildResultListenable(listenable),
],
);
}
@override
Widget buildValue(BuildContext context, String value) {
return Text(value);
}
@override
Widget buildError(BuildContext context, Object error) {
final errorColor = Theme.of(context).errorColor;
final badData = error as String;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Icon(Icons.error_outline, color: errorColor),
),
Text(badData, style: TextStyle(color: errorColor)),
],
);
}
}
HINT WithEmptyValue can be used withBuildResultListenable to handle empty content too.
Consume ValueListenable with BuildValueListenable #
Similar to BuildResultListenable, built-in ValueListenable can be consumed with BuildValueListenable with compatible manner.
HINT WithEmptyValue can be used withBuildValueListenable to handle empty content too.
Implement Undo and Redo with HistoryValueNotifier #
HistoryValueNotifier is an implementation of ValueListenable<T> with undo and redo support built-in.
// Create a HistoryValueNotifier that remembers past 30 changes
final userInputValue = HistoryValueNotifier<String>(31, initialValue: "");
// Use userInputValue as normal `ValueListenable`
buildValueListener(userInput);
// Undo last change
userInputValue.undo();
// Redo last user change
userInputValue.redo();
License #
The MIT License (MIT)