Part one showed us how to create a static UI. Now we'll make it dynamic.
Given our previous example, let's say we want the button to be disabled on startup because our app needs to talk to an API before enabling it. To achieve this we'll need a few steps:
1/ The enabled state of a Button
Widget
is determined by whether or not there's an action set on the onPressed
property. At present there is one set:
RaisedButton(
child: Text("A Button"),
onPressed: () => print("Pressed"),
),
Let's change that:
RaisedButton(
child: Text("A Button"),
onPressed: buttonEnabled ? () => print("Pressed") : null,
),
buttonEnabled
is a variable that currently doesn't exist. So how do we pass this in? Via a Builder class (not a type - just a concept), in this case a FutureBuilder
.
2/ We wrap the RaisedButton
inside this other FutureBuilder
Widget
.
FutureBuilder<bool>(
future: null,
builder: (context, AsyncSnapshot<bool> snapshot) {
final buttonEnabled = snapshot.hasData && snapshot.data;
return RaisedButton(
child: Text("A Button"),
onPressed: buttonEnabled ? () => print("Pressed") : null,
);
}
),
We don't have a Future<bool>
yet, hence it's been left blank future: null
, but let's explain the rest. To build the UI, the FutureBuilder
's builder
function will be called (at least) once before the future has completed, then once it has completed. Whether it's completed and, if it has, its result will be delivered in the snapshot. We enable or disable the button based on this snapshot.
A good way of thinking of a Builder is:
I need a small piece of logic to determine which Widget to show, or what property to set on a Widget. So I need a Builder to put the condition in.
Or even simpler:
I need an if statement, so I need a Builder.
Now we'll create a Future
that the FutureBuilder
can listen to.
3/ To do this, we'll need to change our Widget
to be one that has state. Currently, it's a StatelessWidget
, which is for Widget
s that don't need to know anything about state. As we're introducing a Future
, and we only want that Future
to execute once, we'll need the Widget
to have state.
Change MyApp
to extend StatefulWidget
rather than StatelessWidget
via Android Studio's alt+enter:
The eagle-eyed may notice that, in terms of text, nothing much changed. Our build
function moved to a new inner class, and MyApp
now just creates an instance of this inner class.
4/ So now let's add the Future
as a property of _MyAppState
and create it in the _MyAppState
's initState
method:
class _MyAppState extends State<MyApp> {
Future<bool> future;
@override
void initState() {
super.initState();
future = Future.delayed(Duration(seconds: 2), () => true);
}
@override
Widget build(BuildContext context) {
5/ Now hook it up to the FutureBuilder
:
FutureBuilder<bool>(
future: future,
builder: (context, AsyncSnapshot<bool> snapshot) {
Run the app:
Other Builders
Just as frequently used as FutureBuilder
, there is StreamBuilder
(actually far more often used in my case). This is very similar with the exception that it listens to a stream of data that can change multiple times. E.g. visibility of a progress indicator, or the contents of a list.
A good practice is to keep all of the state of your screen in a data class, and have your business logic update this state and send out the updates via a stream. Your UI (Widget
s) can listen to this using a StreamBuilder
, and update the UI to reflect whatever is in this state.
In this example we'll adapt Android Studio's default Flutter project to use a stream and perform asynchronous code whenever the button is tapped, like this:
Let's define a simple class to hold our state:
class MyHomePageState {
final int counter;
final bool showSpinner;
MyHomePageState(this.counter, this.showSpinner);
}
Now we need a plain Dart class (i.e. not Flutter code) to perform business logic:
class Logic {
// private stream controller that will manage our stream
final _streamController = StreamController<MyHomePageState>();
// expose the stream publicly
Stream<MyHomePageState> get homePageState => _streamController.stream;
MyHomePageState _currentState;
Logic() {
_updateState(MyHomePageState(0, false));
}
// update the state and broadcast it via the stream controller
void _updateState(MyHomePageState newState) {
_currentState = newState;
_streamController.sink.add(_currentState);
}
// increment the counter after waiting 1 second, to simulate a network call for example.
Future<void> incrementCounter() async {
_updateState(MyHomePageState(_currentState.counter, true));
await Future.delayed(Duration(seconds: 1));
_updateState(MyHomePageState(_currentState.counter + 1, false));
}
}
The UI will now play dumb and simply use this Logic
class by sending events to it (button taps), and listening for what to display (the contents of the Stream).
class MyHomePage extends StatelessWidget {
final Logic logic = Logic();
Widget build(BuildContext context) {
return StreamBuilder<MyHomePageState>(
stream: logic.homePageState,
builder: (context, AsyncSnapshot<MyHomePageState> snapshot) {
print("snapshot: ${snapshot?.data ?? "null"} ");
final asyncInProgress = snapshot.data?.showSpinner ?? true;
return Scaffold(
appBar: AppBar(title: Text("Stream Example")),
body: Center(
child: asyncInProgress
? CircularProgressIndicator()
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('You have pushed the button this many times:'),
Text(
'${snapshot.data.counter}',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: asyncInProgress ? null : logic.incrementCounter,
tooltip: 'Increment',
child: Icon(Icons.add),
),
);
},
);
}
}
Note that the floating action button is only enabled when the progress spinner isn't shown, so it can't be pressed again until the asynchronous operation is complete.
The final part of this short series will cover navigation, which is a simple pattern that can be applied across any app.
Top comments (0)