flutter_command 3.0.0 flutter_command: ^3.0.0 copied to clipboard
flutter_command is a way to manage your state based on `ValueListenable` and the `Command` design pattern.
flutter_command #
flutter_command is a way to manage your state based on ValueListenable
and the Command
design pattern. Sounds scary uh? Ok lets try it a different way. A Command
is an object that wraps a function that can be executed by calling the command, therefore decoupling your UI from the wrapped function.
It's not that easy to define what exactly state management is (see https://medium.com/super-declarative/understanding-state-management-and-why-you-never-will-dd84b624d0e ). For me it's how the UI triggers processes in the model/business layer of your app and how to get back the results of these processes to display them. For both aspects flutter_command
offers solution plus some nice extras. So in a way it offers the same that BLoC does but in a more logical way.
This readme might seem very long, but it will guide you easily step by step through all features of
flutter_command
.
Breaking Change A
Command
by default will always notify changes unlike,ValueNotifier
which only notifies its liteners when the value it holds changes. For more details check this section.
Why Commands #
When I started Flutter the most often recommended way to manage your state was BLoC
. What never appealed to me was that in order to execute a process in your model layer you had to push an object into a StreamController
which just didn't feel right. For me triggering a process should feel like calling a function.
Coming from the .Net world I was used to use Commands for this, which had an additional nice feature that the Button that triggered the command would automatically disable for the duration, the command was running and by this, preventing a double execution at the same time. I also learned to love a special breed of .Net commands called ReactiveCommands
which emitted the result of the called function on their own Stream interface (the ReactiveUI community might oversee that I don't talk of Observables here.) As I wanted to have something similar I ported ReactiveCommands
to Dart with my rx_command. But somehow they did not get much attention because 1. I didn't call them state management and 2. they had to do with Streams
and even had that scary rx
in the name and probably the readme wasn't as good to start as I thought.
Remi Rousselet talked to me about that ValueNotifier
and how much easier they are than using Streams. So what you have here is my second attempt to warm the hearts of the Flutter community for the Command
metaphor absolutely free of Streams
A first careful encounter #
Let's start with the (in)famous counter example but by using a Command
. As said before a Command
wraps a function and can publish the result in a way that can be consumed by the UI. It does this by implementing the ValueListenable
interface which means a command behaves like ValueNotifier
. A command has the type:
Command<TParam,TResult>
at which TParam
is the type of the parameter which the wrapped function expects as argument and TResult
is the type of the result of it, which means the Command
behaves like a ValueNotifier<TResult>
.
A Command always notifies (by default) #
A Command
by default will always notify changes unlike, ValueNotifier
which only notifies its listeners when the value it holds changes. This behavior is opted to enable the widgets to always rebuild whenever a command is executed. If you prefer the Command to behave exactly like a ValueNotifier
then the default behaviour can be turned of by setting the notifyOnlyWhenValueChanges
parameter to true
.
In the included project counter_example
the command is defined as:
class _MyHomePageState extends State<MyHomePage> {
int counter = 0;
/// This command does not expect any parameters when called therefore TParam
/// is void and publishes its results as String
Command<void, String> _incrementCounterCommand;
_MyHomePageState() {
_incrementCounterCommand = Command.createSyncNoParam(() {
counter++;
return counter.toString();
}, '0');
}
To create a Command
the Command
class offers different static functions depending on the signature of the wrapped function. In this case we want to use a synchronous function without any parameters.
Our widget tree now looks like this:
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
ValueListenableBuilder<String>(
valueListenable: _incrementCounterCommand,
builder: (context, val, _) {
return Text(
val,
style: Theme.of(context).textTheme.headline4,
);
}),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounterCommand,
tooltip: 'Increment',
child: Icon(Icons.add),
), // This trailing comma makes auto-formatting nicer for build methods.
);
As Command
is a callable class, so we can pass it directly to the onPressed
handler of the FloatingActionButton
and it will execute the wrapped function. The result of the function will get assigned to the Command.value
so that the ValueListenableBuilder
updates automatically.
This is a very basic demo! In a real all you wouldn't place a command in a Widgets State
Commands in full power mode #
So far the command did not do more than what you could do with BLoC, besides that you could call it like a function and didn't need a Stream. But Command
can do more than that. It allows us to:
- Update the UI based on if the
Command
is executing - React on Exceptions in the wrapped functions
- Control when a
Command
can be executed
Let's explore this features by examining the included example
app which queries an open weather service and displays a list of cities with the current weather.
The app uses a WeatherManager
which contains the Command
to update the ListView
by making a REST call:
Command<String, List<WeatherEntry>> updateWeatherCommand;
The updateWeatherCommand
expects a search term and will return a list of WeatherEntry
.
The Command
gets initialized in the constructor of the WeatherManager
:
updateWeatherCommand = Command.createAsync<String, List<WeatherEntry>>(
update, // Wrapped function
[], // Initial value
restriction: setExecutionStateCommand, //please ignore for the moment
)
update
is the asynchronous function that queries the weather service, therefore we create an async version of Command
using the createAsync
constructor.
Updating the ListView #
In listview.dart
:
class WeatherListView extends StatelessWidget {
WeatherListView();
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<List<WeatherEntry>>(
valueListenable: weatherManager.updateWeatherCommand,
builder: (BuildContext context, List<WeatherEntry> data, _) {
// only if we get data
return ListView.builder(
itemCount: data.length,
....
Reacting on changes of the function execution state #
Command
has a property
ValueListenable<bool> isExecuting;
that has the value of false
while the wrapped function isn't executed and true
when it is.
So we use this in the UI in homepage.dart
to display a progress indicator while the app waits for the result of the REST call:
child: ValueListenableBuilder<bool>(
valueListenable:
weatherManager.updateWeatherCommand.isExecuting,
builder: (BuildContext context, bool isRunning, _) {
// if true we show a buys Spinner otherwise the ListView
if (isRunning == true) {
return Center(
child: SizedBox(
width: 50.0,
height: 50.0,
child: CircularProgressIndicator(),
),
);
} else {
return WeatherListView();
}
},
),
🚩 As it's not possible to update the UI while a synchronous function is being executed
Commands
that wrap a synchronous function don't supportisExecuting
and will throw an assertion if you try to access it.
Update the UI on change of the search field #
As we don't want to send a new HTTP request on every keypress in the search field we don't directly wire the onChanged
event to the updateWeatherCommand
. Instead we use a second Command
to convert the onChanged
event to a ValueListenable
so that we can use the debounce
and listen
function of my extension function package functional_listener
:
For this a synchronous Command
is sufficient:
// in weather_viewmodel.dart:
Command<String, String> textChangedCommand;
// and in the constructor:
// Will be called on every change of the searchfield
textChangedCommand = Command.createSync((s) => s, '');
//
// make sure we start processing only if the user make a short pause typing
textChangedCommand.debounce(Duration(milliseconds: 500)).listen(
(filterText, _) {
// I could omit the execute because Command is a callable
// class but here it makes the intention clearer
updateWeatherCommand.execute(filterText);
},
);
In the homepage.dart
:
child: TextField(
/// I omitted some properties from the example here
onChanged: weatherManager.textChangedCommand,
),
Restricting command execution #
Sometimes it is desirable to make the execution of a Command
depending on some other state. For this you can pass a ValueListenable<bool>
as restriction
parameter, when you create a command. If you do so the command will only be executed if the value of the passed listenable is true
.
In the example app we can restrict the execution by changing the state of a Switch
. To handle changes of the Switch
we use..., you guessed it, another command in the WeatherManager
:
WeatherManager() {
// Command expects a bool value when executed and sets it as its own value
setExecutionStateCommand = Command.createSync<bool, bool>((b) => b, true);
// We pass the result of switchChangedCommand as restriction to the updateWeatherCommand
updateWeatherCommand = Command.createAsync<String, List<WeatherEntry>>(
update, // Wrapped function
[], // Initial value
restriction: setExecutionStateCommand,
);
...
To update the Switch
we use again a ValueListenableBuilder
:
ValueListenableBuilder<bool>(
valueListenable:
weatherManager.setExecutionStateCommand,
builder: (context, value, _) {
return Switch(
value: value,
onChanged:
weatherManager.setExecutionStateCommand,
);
})
Disabling the update button while another update is in progress #
The update button should not be active while an update is running or when the
Switch
deactivates it. We could achieve this, again by using the isExecuting
property of Command
but we would have to somehow combine it with the value of setExecutionStateCommand
which is cumbersome. Luckily Command
has another property canExecute
which reflects a combined value of !isExecuting && restriction
.
So we can easily solve this requirement with another....wait for it...ValueListenableBuilder
child: ValueListenableBuilder<bool>(
valueListenable: weatherManager
.updateWeatherCommand
.canExecute,
builder: (BuildContext context, bool canExecute, _) {
// Depending on the value of canExecute we set or clear the handler
final handler = canExecute
? weatherManager.updateWeatherCommand
: null;
return RaisedButton(
child: Text("Update"),
color: Color.fromARGB(255, 33, 150, 243),
textColor: Color.fromARGB(255, 255, 255, 255),
onPressed: handler,
);
},
),
Error Handling #
If the wrapped function inside a Command
throws an Exception
the Command
catches it so your App won't crash.
Instead it will wrap the caught error together with the value that was passed when the command was executed in a CommandError
object and assign it to the Command's
thrownExeceptions
property which is a ValueListenable<CommandError>
.
So to react on occurring error you can register your handler with addListener
or use my listen
extension function from functional_listener
as it is done in the example:
/// in HomePage.dart
@override
void didChangeDependencies() {
errorSubscription ??= weatherManager
.updateWeatherCommand
.thrownExceptions
.where((x) => x != null) // filter out the error value reset
.listen((error, _) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('An error has occured!'),
content: Text(error.toString()),
));
});
super.didChangeDependencies();
}
Unfortunately its not possible to reset the value of a ValueNotifier
without triggering its listeners. So if you have registered a listener you will get it called at every start of a Command
execution with a value of null
and clear all previous errors. If you use functional_listener
you can do it easily by using the where
extension.
Error handling the fine print #
You can tweak the behaviour of the error handling by passing a catchAlways
parameter to the factory functions. If you pass false
Exceptions will only be caught if there is a listener on thrownExceptions
or on results
(see next chapter). You can also change the default behaviour of all Command
in your app by changing the value of the catchAlwaysDefault
property. During development its a good idea to set it to false
to find any non handled exception. In production, setting it to true
might be the better decision to prevent hard crashes. Note that catchAlwaysDefault
property will be implicitly ignored if the catchAlways
parameter for a command is set.
Command
also offers a static global Exception handler:
static void Function(String commandName, CommandError<Object> error) globalExceptionHandler;
If you assign a handler function to it, it will be called for all Exceptions thrown by any Command
in your app independent of the value of catchAlways
if the Command
has no listeners on thrownExceptions
or on results
.
The overall work flow of exception handling in flutter_command is depicted in the following diagram.
Getting all data at once #
isExecuting
and thrownExceptions
are great properties but what if you don't want to use separate ValueListenableBuilders
for each of them plus one for the data?
Command
got you covered with the results
property that is an ValueListenable<CommandResult>
which combines all needed data and is updated several times during a Command
execution.
/// Combined execution state of an `Command`
/// Will be updated for any state change of any of the fields
/// 1. If the command was just newly created `results.value` has the value:
/// `param data,null, null, false` (paramData,data, error, isExecuting)
/// 2. When calling execute: `param data, null, null, true`
/// 3. When execution finishes: `param data, the result, null, false`
/// If an error occurs: `param data, null, error, false`
/// `param data` is the data that you pass as parameter when calling the command
class CommandResult<TParam, TResult> {
final TParam paramData;
final TResult data;
final Object error;
final bool isExecuting;
bool get hasData => data != null;
bool get hasError => error != null;
/// This is a stripped down version of the class. Please see the source
}
You can find a Version of the Weather app that uses this approach in example_command_results
. There the homepage.dart
looks like:
child: ValueListenableBuilder<
CommandResult<String, List<WeatherEntry>>>(
valueListenable:
weatherManager.updateWeatherCommand.results,
builder: (BuildContext context, result, _) {
if (result.isExecuting) {
return Center(
child: SizedBox(
width: 50.0,
height: 50.0,
child: CircularProgressIndicator(),
),
);
} else if (result.hasData) {
return WeatherListView(result.data);
} else {
assert(result.hasError);
return Column(
children: [
Text('An Error has occurred!'),
Text(result.error.toString()),
if (result.error != null)
Text('For search term: ${result.paramData}')
],
);
}
},
),
Even if you use results
the other properties are updated as before, so you can mix both approaches as you need it. For instance use results
as above but additionally listening to thrownExceptions
for logging.
If you want to be able to always display data (while loading or in case of an error) you can pass includeLastResultInCommandResults=true
, the last successful result will be included as data
unless a new result is available.
CommandBuilder, reducing boilerplate #
flutter_command
includes a CommandBuilder
widget which makes the code above a bit nicer:
child: CommandBuilder<String, List<WeatherEntry>>(
command: weatherManager.updateWeatherCommand,
whileExecuting: (context, _) => Center(
child: SizedBox(
width: 50.0,
height: 50.0,
child: CircularProgressIndicator(),
),
),
onData: (context, data, _) => WeatherListView(data),
onError: (context, error, param) => Column(
children: [
Text('An Error has occurred!'),
Text(error.toString()),
if (error != null) Text('For search term: $param')
],
),
),
toWidget() extension method on Command Result #
I you are using a package get_it_mixin
, provider
or flutter_hooks
you probably don't want to use the CommandBuilder
for you there is an extension method for the CommandResult
type that you can use like this:
return result.toWidget(
whileExecuting: (lastValue, _) => Center(
child: SizedBox(
width: 50.0,
height: 50.0,
child: CircularProgressIndicator(),
),
),
onResult: (data, _) => WeatherListView(data),
onError: (error, lastValue, paramData) => Column(
children: [
Text('An Error has occurred!'),
Text(result.error.toString()),
if (result.error != null)
Text('For search term: ${result.paramData}')
],
),
);
How to create Commands #
´Command´ offers different static factory functions for the different function types you want to wrap:
/// for syncronous functions with no parameter and no result
static Command<void, void> createSyncNoParamNoResult(
void Function() action, {
ValueListenable<bool> restriction,
bool catchAlways,
String debugName
})
/// for syncronous functions with one parameter and no result
static Command<TParam, void> createSyncNoResult<TParam>(
void Function(TParam x) action, {
ValueListenable<bool> restriction,
bool catchAlways,
String debugName
})
/// for syncronous functions with no parameter and but a result
static Command<void, TResult> createSyncNoParam<TResult>(
TResult Function() func,
TResult initialValue, {
ValueListenable<bool> restriction,
bool includeLastResultInCommandResults = false,
bool catchAlways,
String debugName
})
/// for syncronous functions with one parameter and result
static Command<TParam, TResult> createSync<TParam, TResult>(
TResult Function(TParam x) func,
TResult initialValue, {
ValueListenable<bool> restriction,
bool includeLastResultInCommandResults = false,
bool catchAlways,
String debugName
})
/// and for Async functions:
static Command<void, void> createAsyncNoParamNoResult(
Future Function() action, {
ValueListenable<bool> restriction,
bool catchAlways,
String debugName
})
static Command<TParam, void> createAsyncNoResult<TParam>(
Future Function(TParam x) action, {
ValueListenable<bool> restriction,
bool catchAlways,
String debugName
})
static Command<void, TResult> createAsyncNoParam<TResult>(
Future<TResult> Function() func,
TResult initialValue, {
ValueListenable<bool> restriction,
bool includeLastResultInCommandResults = false,
bool catchAlways,
String debugName
})
static Command<TParam, TResult> createAsync<TParam, TResult>(
Future<TResult> Function(TParam x) func,
TResult initialValue, {
ValueListenable<bool> restriction,
bool includeLastResultInCommandResults = false,
bool catchAlways,
String debugName
})
For detailed information on the parameters of these functions consult the API docs or the source code documentation.
Reacting on Functions with no results #
Even if your wrapped function doesn't return a value, you can react on the end of the function execution by registering a listener to the Command
. The command Value will be void but your handler is ensured to be called.
Logging #
If you are not sure what's going on in your App you can register an handler function to
static void Function(String commandName, CommandResult result) loggingHandler;
It will get executed on every Command
execution in your App. commandName
is the optional debugName
that you can pass when creating a command.
Awaiting Commands #
In general you shouldn't await a command as it goes against the reactive philosophy. Your UI should react to the result of the command by "listening" to one of its ValueListenable
interfaces.
In case you really need to await the completion of a command you can use the executeWithFuture()
function of the Command. executeWithFuture
starts the execution of the Command and returns a Future<T>
that completes when the function that it wraps.
The main reason that this function exists is that you can use RefreshIndicator
directly with a command like:
return RefreshIndicator(
onRefresh: () => updateMovieCmd.executeWithFuture(),
child: GridView.extent(
maxCrossAxisExtent: 200,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.7,
children: movies.data.map((movie) => _MovieBox(movie: movie)).toList(),
),
);
Commands and the get_it_mixin #
If you want to use Commands as comfortable as possible, check out the get_it_mixin with its watchX
function. With it you can use Commands without any Builders in a very intuitive way.