Connect Flutter Widgets to Dart Streams! In Flutter, there's a wonderful distinction between StatefulWidgets
and StatelessWidgets
. When used well, StatefulWidgets
provide a convenient way to encapsulate your data coordination needs in one component, and keep the UI rendering in various "passive" StatelessWidgets
. In React terms, this is often called the "Smart Component / Dumb Component" pattern, and is similar to the "Active Presenter / Passive View" pattern in MVP.
However, what if you've got slightly more advanced data needs, such as loading
data from a database or web server? Furthermore, you may need to listen to a continuous stream of updates from a Store or EventBus. Finally, you may require more powerful control over your event-handling, such as being able to debounce
or buffer
the events passing through an event-handler. For these use cases, Streams provide a great way to manage the events and data needs of a StatefulWidget
!
In general: what if we could combine the power of StatefulWidgets
with the elegance of Streams
? That's just what this library aims to help with.
In order to understand the concept, let's compare the default usage of StatefulWidget
to a StreamBuilder
version. This library used to provide a StreamWidget
, but we now recommend using the new StreamBuilder
widget provided by the Flutter framework.
Let's start with the simple counter example that comes out of the box when you create a new Flutter app. The important parts are:
- Create a
StatefulWidget
with a correspondingState
object - Within the
State
object, create widget state and event handlers - The event handlers are responsible for updating the local state of the widget
- Use these pieces of state within the
build
method.
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
// This widget is the home page of your application. It is stateful,
// meaning that it has a State object (defined below) that contains
// fields that affect how it looks.
// This class is the configuration for the state. It holds the
// values (in this case the title) provided by the parent (in this
// case the App widget) and used by the build method of the State.
// Fields in a Widget subclass are always marked "final".
final String title;
@override
_MyHomePageState createState() => new _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() {
// This call to setState tells the Flutter framework that
// something has changed in this State, which causes it to rerun
// the build method below so that the display can reflect the
// updated values. If we changed _counter without calling
// setState(), then the build method would not be called again,
// and so nothing would appear to happen.
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This method is rerun every time setState is called, for instance
// as done by the _incrementCounter method above.
// The Flutter framework has been optimized to make rerunning
// build methods fast, so that you can just rebuild anything that
// needs updating rather than having to individually change
// instances of widgets.
return new Scaffold(
appBar: new AppBar(
// Here we take the value from the MyHomePage object that
// was created by the App.build method, and use it to set
// our appbar title.
title: new Text(config.title),
),
body: new Center(
child: new Text(
'Button tapped $_counter time${ _counter == 1 ? '' : 's' }.',
),
),
floatingActionButton: new FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: new Icon(Icons.add),
), // This trailing comma tells the Dart formatter to use
// a style that looks nicer for build methods.
);
}
}
Now, let's take a look at the version using streams! This code will produce the exact same UI, but the way it manages state is a bit different. Rather than relying on local state within a State
object, using handlers to setState
, we use the power of the Dart Stream
to continually listen to and deliver new information to the Widget in response to button presses!
How it works:
- Create a Stateless widget that contains a
StreamBuilder
- The
StreamBuilder
takes astream
parameter. Instead of creating aState
object to manage the counter state, we'll create aStream
instead that will deliver the current count. - The
Stream
we build contains aVoidStreamCallback
that acts as both theonPressed
handler on thefloatingActionButton
and as the stream we'll listen to so we know when the button is pressed. - Then, as the button is pressed, the Stream will deliver the latest value to the
Now that we've chatted a bit about how it works, let's see the code!
class MyApp extends StatelessWidget {
static String appTitle = "Flutter Stream Friends";
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: appTitle,
theme: new ThemeData(
primarySwatch: Colors.purple,
),
home: new StreamBuilder(
stream: new CounterScreenStream(appTitle),
builder: (context, snapshot) => buildHome(
context,
snapshot.hasData
// If our stream has delivered data, build our Widget properly
? snapshot.data
// If not, we pass through a dummy model to kick things off
: new CounterScreenModel(0, () {}, appTitle))),
);
}
// The latest value of the CounterScreenModel from the CounterScreenStream is
// passed into the this version of the build function!
Widget buildHome(BuildContext context, CounterScreenModel model) {
return new Scaffold(
appBar: new AppBar(
title: new Text(model.title),
),
body: new Center(
child: new Text(
'Button tapped ${ model.count } time${ model.count == 1
? ''
: 's' }.',
),
),
floatingActionButton: new FloatingActionButton(
// Use the `StreamCallback` here to wire up the events to the Stream.
onPressed: model.onFabPressed,
tooltip: 'Increment',
child: new Icon(Icons.add),
),
);
}
}
class CounterScreenStream extends Stream<CounterScreenModel> {
final Stream<CounterScreenModel> _stream;
CounterScreenStream(String title,
[VoidStreamCallback onFabPressed, int initialValue = 0])
: this._stream = createStream(
title, onFabPressed ?? new VoidStreamCallback(), initialValue);
@override
StreamSubscription<CounterScreenModel> listen(
void onData(CounterScreenModel event),
{Function onError,
void onDone(),
bool cancelOnError}) =>
_stream.listen(onData,
onError: onError, onDone: onDone, cancelOnError: cancelOnError);
// The method we use to create the stream that will continually deliver data
// to the `buildHome` method.
static Stream<CounterScreenModel> createStream(
String title, VoidStreamCallback onFabPressed, int initialValue) {
return new Observable(onFabPressed) // Every time the FAB is clicked
.map((_) => 1) // Emit the value of 1
.scan(
(int a, int b, int i) => a + b, // Add that 1 to the total
initialValue)
// Before the button is clicked, kick everything off by emitting 0
.startWith(initialValue)
// Convert the latest count and the event handler into the Widget Model
.map((int count) => new CounterScreenModel(count, onFabPressed, title));
}
}
class CounterScreenModel {
final String title;
final int count;
final VoidCallback onFabPressed;
CounterScreenModel(this.count, this.onFabPressed, this.title);
// If you've got a custom data model for your widget, it's best to implement
// the == method in order to take advantage the performance optimizations
// offered by the `Streams#distinct()` method. This will ensure the Widget is
// repainted only when the Model has truly changed.
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CounterScreenModel &&
runtimeType == other.runtimeType &&
title == other.title &&
count == other.count &&
onFabPressed == other.onFabPressed;
@override
int get hashCode => title.hashCode ^ count.hashCode ^ onFabPressed.hashCode;
@override
String toString() =>
'CounterScreenModel{title: $title, count: $count, onFabPressed: $onFabPressed}';
}
You might ask: Why would you do this? The second version is so much more code! And you're right, for a super simple example, such as a counter, this is indeed much more code.
However, there are some important advantages: First, separation of concerns. The state logic is now properly encapsulated as a Stream is easily testable. This should not be undervalued.
Second, it makes your state management fundamentally reactive! That means your Widgets can stay up to date with a variety of data sources that emit state changes (think Firebase or WebSockets or Redux). For example:
- You may have more complex data needs, such as:
- calling a local database, file system, or web service when your Widget initializes
- Keeping your Widgets up to date with a reactive data source, such as a Firebase Database, WebSocket, or Redux Store
- No longer make manual calls to
setState
. Just set up your stream and theStreamWidget
handles the rest. - You can use the power of Streams to reduce the number redraws your UI performs. By using
Stream#distinct
under the hood, setState will only be called when data is truly fresh. - No need to worry about manually canceling any StreamSubscriptions.
- Helpful when you have more advanced event handling needs, such as needing to
debounce
orbuffer
the events.
You can check out the example
directory showing the code above implemented as a real Flutter app.
Another project is being worked on that also demonstrates this concept when listening to a Redux store!