DEV Community

Neil Wilkinson
Neil Wilkinson

Posted on

Flutter Crash Course - Step 2

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"),
            ),
Enter fullscreen mode Exit fullscreen mode

Let's change that:

            RaisedButton(
              child: Text("A Button"),
              onPressed: buttonEnabled ? () => print("Pressed") : null,
            ),
Enter fullscreen mode Exit fullscreen mode

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,
                );
              }
            ),
Enter fullscreen mode Exit fullscreen mode

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 Widgets 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:

Stateless to Stateful

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) {
Enter fullscreen mode Exit fullscreen mode

5/ Now hook it up to the FutureBuilder:

              FutureBuilder<bool>(
                future: future,
                builder: (context, AsyncSnapshot<bool> snapshot) {
Enter fullscreen mode Exit fullscreen mode

Run the app:

App starts up with button disabled, then enabled 2 seconds later

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 (Widgets) 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:

Each button tap increments the counter after 1 second

Let's define a simple class to hold our state:

class MyHomePageState {
  final int counter;
  final bool showSpinner;

  MyHomePageState(this.counter, this.showSpinner);
}
Enter fullscreen mode Exit fullscreen mode

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
          ),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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)