Flutter: Building a Widget with StreamBuilder

Just about every application has some sort of state they are always in.  For example the application can be loading, processing a request such as an online payment, or waiting for user input.  Well designed applications decouple the state of the application from the actual user interface.  One of the things that makes this especially tricky is if the application is asynchronous in nature.

Well, what if you could listen to an asynchronous pipeline of events, and based on what is coming through that pipe, dynamically create a user interface?  In this article I’ll show you how to do just that with 3 things:

  1. A state object that you define and update
  2. Stream that you can subscribe to
  3. StreamBuilder that you can use to build your user interface.

For our example application, we’ll build a simple audio player using my favorite (and maybe only?) audio plugin for flutter – audio player 0.4.0.  I have a brief introduction to this plugin in an earlier blog post – Playing Audio From the Web and HTTP

Let’s get started!

Creating the app

If you are not yet familiar with the basics of creating a flutter app, please see my first post in this series on flutter – From Zero to App with Flutter.

Note – for this post we’ll be using VS Code although other IDEs may be used.  Open up VS Code and from the command palette (CTRL+SHIFT+P) choose ‘Flutter:New Project’.  Enter a new project name and hit Enter.  VS Code will setup your new project, which we will be heavily modifying next…

Setting up our dependencies

Open up your pubspec.yaml file and edit the dependencies section so that it appears as follows:

dependencies:
  flutter:
    sdk: flutter
  audioplayer: "^0.4.0"
  path_provider: "^0.3.1"

Warning – if you are not familiar with yaml, be aware that indentation is important so follow the example above closely.

The audioplayer dependency was mentioned earlier and is what we will be using to play audio files.  The other dependency, path_provider, allows us easy access to common folders on a device.  We will take advantage of this in order to store an .mp3 file on the device in order to play it.

Save your pubspec.yaml file and VS Code will automagically pull down the packages for you.  Open up the output window within VS Code and you will see confirmation of this:

Alternatively, if you are outside of VS Code, you can always execute that command manually.  Either way in the end you will now have your plugins ready for use.

What state am I in?

No, this is not me being lost – BTW I happen to be in California if you are wondering….

But seriously, since we are building an audio player based on the state we read from a stream, we should take a moment to document what our various states will be.

  1. initial – meaning we have not yet done anything – the app has just started up…
  2. playing – The audio player is in a playing state
  3. paused – The audio player is in a paused state
  4. stopped – The audio player is in a stopped state

Let’s put that into code.  Create a new dart file named player_state.dart and add it under lib.  Define your various states as follow:

class PlayerState {
  final bool isInitial;
  final bool isPlaying;
  final bool isPaused;
  final bool isStopped;

  PlayerState.initial(
      {this.isInitial = true, this.isPlaying = false, this.isPaused = false, this.isStopped = false});

  PlayerState.playing(
      {this.isInitial = false, this.isPlaying = true, this.isPaused = false, this.isStopped = false});

  PlayerState.paused(
      {this.isInitial = false, this.isPlaying = false, this.isPaused = true, this.isStopped = false});

  PlayerState.stopped(
    {this.isInitial = false, this.isPlaying = false, this.isPaused = false, this.isStopped = true});

}

In the PlayerState class we defined all of our states, and then created several named constructors to make it easier for us to set a specific state.  You’ll see how these are used when we build the actual user interface.

What shall we play?

Of course what you play is up to you, but I have chosen one of my favorite 80’s movie theme songs – Top Gun!  The audio file for this can be downloaded here.  Download that file and place it under a new folder named assets in your project and rename it to “Top Gun Anthem.mp3”.  Mine looks like this:

Next up, it’s time to revisit your pubspec.yaml file.  We have to let flutter know about our assets folder.  Open pubspec.yaml, locate the assets section, and modify it so that your assets are defined:

assets:
     - assets/Top Gun Anthem.mp3

Creating the player widget

Finally, with all of the setup behind us, we get to the exciting part – using StreamBuilder!  We’ll start out by defining our player widget.  It will be responsible for instantiating an audio player, reading from the stream of events, and for building out a user interface based on what it reads from that stream.  Begin by creating a new dart file under your lib folder named player.dart.  Here is a simple example that you can use to get started:

import 'package:flutter/material.dart';
import 'dart:async';
import 'package:audioplayer/audioplayer.dart';
import 'player_state.dart';

class Player extends StatelessWidget {
  final AudioPlayer audioPlayer = new AudioPlayer();
  final StreamController<PlayerState> _streamController =
      new StreamController<PlayerState>();

  Player() {
    audioPlayer.setCompletionHandler(() {
      _streamController.add(PlayerState.stopped());
    });
  }

  Widget buildUi(BuildContext context, PlayerState ps) {
  return null;
  }

  @override
  Widget build(BuildContext context) {
    return new StreamBuilder<PlayerState>(
        stream: _streamController.stream,
        initialData: new PlayerState.initial(),
        builder: (BuildContext context, AsyncSnapshot<PlayerState> snapshot) {
          final playerState = snapshot.data;
          return buildUi(context, playerState);
        });
  }
}

That is not a huge chunk of code, but there are some head scratchers in there – especially if some of these classes such as StreamBuilder are new to you.  Let’s go through the code section by section.

First off, we start by taking care of our imports – of note is that we added the audioplayer package as well as our own player_state.dart.  In addition we have the material.dart package and the dart:async library included to support our StreamBuilder usage.

import 'package:flutter/material.dart';
import 'dart:async';
import 'package:audioplayer/audioplayer.dart';
import 'player_state.dart';

Next up, we create the Player class – notice that it extends from StatelessWidget?  These types of widgets are immutable, which is why all of our class variables must be final.  This works well for us because our widget does not have to maintain state.

class Player extends StatelessWidget {
  final AudioPlayer audioPlayer = new AudioPlayer();
  final StreamController<PlayerState> _streamController =
      new StreamController<PlayerState>();
}

The two types we are instantiating here are a AudioPlayer (used to actually play our .mp3 audio file) and a StreamController.  A StreamController makes it a snap to send data to a stream using it’s add method.  The type of data sent to the stream depends on the generic type that you use when instantiating the StreamController.  In our case we are using the PlayerState type which will allow us to send in data to the stream such as notifications the player should play, pause, or stop.  We’ll soon see how that all happens…

Next, we created a very simple constructor in which we set a completed handler for the audio player so that when the audio player is finished with our song, it will send a stopped notification to the _streamController using the aforementioned add method:

Player() {
    audioPlayer.setCompletionHandler(() {
      _streamController.add(PlayerState.stopped());
    });
  }

And finally, we have the code to build out the user interface.  When doing so, I tend to use a pattern that breaks out the composition of the UI into separate methods – hence the buildUi method that, at this point, does nothing but we will fix that later.  The build method is however interesting and different than what you may be used to if you have been using StatefulWidget instead.  The build method essentially delegates the building of the Widget to StreamBuilder.

Widget buildUi(BuildContext context, PlayerState ps) {
  return null;
}

  @override
  Widget build(BuildContext context) {
    return new StreamBuilder<PlayerState>(
        stream: _streamController.stream,
        initialData: new PlayerState.initial(),
        builder: (BuildContext context, AsyncSnapshot<PlayerState> snapshot) {
          final playerState = snapshot.data;
          return buildUi(context, playerState);
        });
  }

For our StreamBuilder, we are passing in a stream – using our handy-dandy StreamController instance.  Note that the type used to construct our StreamBuilder is the same as the one used to construct the StreamController – PlayerState.  With the nuts and bolts in place, we are almost ready to start building out the user interface.  But before we do, let’s take a slight detour.

Loading your .mp3

Since we have our .mp3 file in our assets folder, we can load it up and tell the audio player where to find it.  To work with files and paths, we need to first add the following imports:

import 'dart:io';
import 'package:path_provider/path_provider.dart';

Here is a simple load method that loads the .mp3 from the asset bundle, and then writes it to the application documents directory using the path_provider plugin’s getApplicationDocumentsDirectory method.

Future<String> load(BuildContext context) async {
    final dir = await getApplicationDocumentsDirectory();
    final file = new File("${dir.path}/Top Gun Anthem.mp3");
    if (!(await file.exists())) {
      final soundData = await DefaultAssetBundle
          .of(context)
          .load("assets/Top Gun Anthem.mp3");
      final bytes = soundData.buffer.asUint8List();
      await file.writeAsBytes(bytes, flush: true);
    }
    return file.path;
  }

Implement play, pause, and stop

Finally we are to the point where we have all that we need to build out our user interface (yay!).  Let’s start with the implementation of play, pause and stop.  Here are 3 methods you can add to your player.dart file:

play(BuildContext context) async {
    var url = await load(context);
    final result = await audioPlayer.play(url, isLocal: true);

    if (result == 1) {
      _streamController.add(PlayerState.playing());
    }
  }

  pause() async {
    final result = await audioPlayer.pause();
    if (result == 1) {
      _streamController.add(PlayerState.paused());
    }
  }

  stop() async {
    final result = await audioPlayer.stop();
    if (result == 1) {
      _streamController.add(PlayerState.stopped());
    }
  }

All three of the above methods take a similar approach in their implementation – call the appropriate AudioPlayer method and then update the stream controller with the corresponding state.  This has the effect of causing an update to the user interface, which we will put together now.

Locate your buildUI method that was originally coded to return null and replace it with the following code:

Widget buildUi(BuildContext context, PlayerState ps) {
    List<Widget> buttons = new List<Widget>();

    buttons.add(new IconButton(
        onPressed: ps.isPlaying ? null : () => play(context),
        iconSize: 64.0,
        icon: new Icon(Icons.play_arrow),
        color: Colors.cyan));

    buttons.add(new IconButton(
        onPressed: !ps.isPlaying ? null : () => pause(),
        iconSize: 64.0,
        icon: new Icon(Icons.pause),
        color: Colors.cyan));

    buttons.add(new IconButton(
        onPressed: ps.isPlaying ? () => stop() : null,
        iconSize: 64.0,
        icon: new Icon(Icons.stop),
        color: Colors.cyan));

    var c = new Column(children: [
      new Row(
          children: buttons, mainAxisAlignment: MainAxisAlignment.spaceEvenly),
    ]);
    return c;
  }

The above code simply creates a layout of three buttons and hooks each up to the play, pause stop methods that we previously created.  When you are done, your application when ran should look like the following:

Click the play button and you should see the pause and stop button enabled.  Click the pause or stop buttons and you should see the play button enable.  All of this works because of the magic of StreamBuilder and how we can pass in the PlayerState to the code that builds our user interface.  This makes it really simple to update the state of the user interface based on the current state of the player.  Nice stuff if you ask me!

Summary

StreamBuilder is an awesome tool in the Dart tool chest.  However it is not always the right choice.  The rule of thumb as to when to use is if you don’t have to maintain state.  If you do you are probably better off going with a StatefulWidget instead.  I hope you have enjoyed this post.  If you have more use cases for StreamBuilder or want to share your experience with it, please post a comment here.

1 thought on “Flutter: Building a Widget with StreamBuilder”

Leave a Reply

Your email address will not be published. Required fields are marked *