Chapters

Hide chapters

Flutter Apprentice

Fourth Edition · Flutter 3.16.9 · Dart 3.2.6 · Android Studio 2023.1.1

Section II: Everything’s a Widget

Section 2: 5 chapters
Show chapters Hide chapters

Section IV: Networking, Persistence & State

Section 4: 6 chapters
Show chapters Hide chapters

14. Working With Streams
Written by Kevin D Moore

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

Imagine yourself sitting by a creek, having a wonderful time. While watching the water flow, you see a piece of wood or a leaf floating down the stream and decide to take it out of the water. You could even have someone upstream purposely float things down the creek for you to grab.

You can imagine Dart streams in a similar way: as data flowing down a creek, waiting for someone to grab it. That’s what a stream does in Dart — it sends data events for a listener to grab.

With Dart streams, you can send one data event at a time while other parts of your app listen for those events. Such events can be collections, maps or any other type of data you’ve created.

Streams can send errors in addition to data; you can also stop the stream if you need to.

In this chapter, you’ll update Recipe Finder to use streams in two different locations. You’ll use one for bookmarks to let the user mark favorite recipes and automatically update the UI to display them. You’ll also use one to update your ingredient and grocery lists.

But before you jump into the code, you’ll learn more about how streams work.

Types of Streams

Streams are part of Dart, and Flutter inherits them. There are two types of streams in Flutter: single subscription streams and broadcast streams.

Widget Widget Widget Widget Stream Broadcast Stream

Single subscription streams are the default. They work well when you’re only using a particular stream on one screen.

A single subscription stream can only be listened to once. It doesn’t start generating events until it has a listener, and it stops sending events when the listener stops listening, even if the source of events could still provide more data.

Single subscription streams are useful for downloading a file or for any single-use operation. For example, a widget can subscribe to a stream to receive updates about a value, like the progress of a download, and update its UI accordingly.

If you need multiple parts of your app to access the same stream, use a broadcast stream, instead.

A broadcast stream allows any number of listeners. It fires when its events are ready, whether there are listeners or not.

To create a broadcast stream, you simply call asBroadcastStream() on an existing single subscription stream.

final broadcastStream = singleStream.asBroadcastStream();

You can differentiate a broadcast stream from a single subscription stream by inspecting its Boolean property isBroadcast.

In Flutter, some key classes are built on top of Stream that simplify programming with streams.

The following diagram shows the main classes used with streams:

StreamController StreamSubscription Stream StreamSink listen() StreamBuilder widget

Next, you’ll take a deeper look at each one.

StreamController and Sink

When you create a stream, you usually use StreamController, which holds both the stream and StreamSink.

final _recipeStreamController = StreamController<List<Recipe>>();
final _stream = _recipeStreamController.stream;
_recipeStreamController.sink.add(_recipesList);
_recipeStreamController.close();

StreamSubscription

Using listen() on a stream returns a StreamSubscription. You can use this subscription class to cancel the stream when you’re done, like this:

StreamSubscription subscription = stream.listen((value) {
    print('Value from controller: $value');
});
...
...
// You are done with the subscription
subscription.cancel();

StreamBuilder

StreamBuilder is handy when you want to use a stream. It takes two parameters: a stream and a builder. As you receive data from the stream, the builder takes care of building or updating the UI.

final repository = ref.watch(repositoryProvider);
return StreamBuilder<List<Recipe>>(
  stream: repository.recipesStream(),
  builder: (context, AsyncSnapshot<List<Recipe>> snapshot) {
    // extract recipes from snapshot and build the view
  }
)
...

Adding Streams to Recipe Finder

You’re now ready to start working on your recipe project. If you’re following along with your app from the previous chapters, open it and keep using it with this chapter. If not, just locate the projects folder for this chapter and open starter in Android Studio.

FugabuXaxv Elm Teulvahl Mutuve Magaipk Coawpatpt Xvozopaan Vatetis Ciihhuflay Bigoopud Avkfamaohdv BihpiwPohanaciijJeg

Adding Futures and Streams to the Repository

Open data/repositories/repository.dart and change all of the return types to return a Future, except for the init and close methods. For example, change the existing findAllRecipes() to:

Future<List<Recipe>> findAllRecipes();
Future<List<Recipe>> findAllRecipes();

Future<Recipe> findRecipeById(int id);

Future<List<Ingredient>> findAllIngredients();

Future<List<Ingredient>> findRecipeIngredients(int recipeId);

Future<int> insertRecipe(Recipe recipe);

Future<List<int>> insertIngredients(List<Ingredient> ingredients);

Future<void> deleteRecipe(Recipe recipe);

Future<void> deleteIngredient(Ingredient ingredient);

Future<void> deleteIngredients(List<Ingredient> ingredients);

Future<void> deleteRecipeIngredients(int recipeId);

Future init();

void close();
// 1
Stream<List<Recipe>> watchAllRecipes();
// 2
Stream<List<Ingredient>> watchAllIngredients();

Cleaning Up the Repository Code

Before updating the code to use streams and futures, there are some minor housekeeping updates.

import 'dart:async';
//1
late Stream<List<Recipe>> _recipeStream;
late Stream<List<Ingredient>> _ingredientStream;
// 2
final StreamController _recipeStreamController =
    StreamController<List<Recipe>>();
final StreamController _ingredientStreamController =
    StreamController<List<Ingredient>>();
MemoryRepository() {
  // 1
  _recipeStream = _recipeStreamController.stream.asBroadcastStream(
    // 2
    onListen: (subscription) {
      // 3
      // This is to send the current recipes to new subscriber
      _recipeStreamController.sink.add(state.currentRecipes);
    },
  ) as Stream<List<Recipe>>;
  _ingredientStream = _ingredientStreamController.stream.asBroadcastStream(
    onListen: (subscription) {
      // This is to send the current ingredients to new subscriber
      _ingredientStreamController.sink.add(state.currentIngredients);
    },
  ) as Stream<List<Ingredient>>;
}
// 3
@override
Stream<List<Recipe>> watchAllRecipes() {
  return _recipeStream;
}

// 4
@override
Stream<List<Ingredient>> watchAllIngredients() {
   return _ingredientStream;
}

Updating the Existing Repository

MemoryRepository is full of red squiggles. That’s because all the methods use the old signatures, and everything’s now based on Futures.

@override
// 1
Future<List<Recipe>> findAllRecipes() {
  // 2
  return Future.value(state.currentRecipes);
}
@override
Future init() {
  return Future.value();
}
@override
void close() {
  _recipeStreamController.close();
  _ingredientStreamController.close();
}

Sending Recipes Over the Stream

As you learned earlier, StreamController’s sink property adds data to streams. Since this happens in the future, you need to change the return type to Future and then update the methods to add data to the stream.

@override
// 1
Future<int> insertRecipe(Recipe recipe) {
  if (state.currentRecipes.contains(recipe)) {
    return Future.value(0);
  }
  // 2
  state = state.copyWith(currentRecipes: [...state.currentRecipes, recipe]);
  // 3
  _recipeStreamController.sink.add(state.currentRecipes);
  // 4
  final ingredients = <Ingredient>[];
  for (final ingredient in recipe.ingredients) {
    ingredients.add(ingredient.copyWith(recipeId: recipe.id));
  }
  insertIngredients(ingredients);
  // 5
  return Future.value(0);
}

Exercise

Convert the remaining methods like you did with insertRecipe(). You’ll need to do the following:

return Future.value();

Switching Between Services

In an earlier chapter, you used a MockService to provide local data that never changes, but you also have access to SpoonacularService.

BicwaniUczolvuwa VroijahicutQaghaxu RattHezyeco

abstract class ServiceInterface {
  /// Query recipes with the given query string
  /// offset is the starting point
  /// number is the number of items
  Future<RecipeResponse> queryRecipes(
    String query,
    int offset,
    int number,
  );

  /// Get the details of a specific recipe
  Future<Response<Result<Recipe>>> queryRecipe(
    String id,
  );
}

Adding Streams to Bookmarks

The Bookmarks page uses Consumer, but you want to change it to a stream so it can react when a user bookmarks a recipe. To do this, you need to replace the reference to MemoryRepository with Repository and use a StreamBuilder widget.

late Stream<List<Recipe>> recipeStream;
@override
void initState() {
  super.initState();
  final repository = ref.read(repositoryProvider.notifier);
  recipeStream = repository.watchAllRecipes();
}
// 1
return StreamBuilder<List<Recipe>>(
  // 2
  stream: recipeStream,
  // 3
  builder: (context, AsyncSnapshot<List<Recipe>> snapshot) {
    // 4
    if (snapshot.connectionState == ConnectionState.active) {
      // 5
      recipes = snapshot.data ?? [];
    }
  },
);

Adding Streams to Groceries

To add streams to the grocery list, you’ll need to watch the ingredient stream.

final repository = ref.read(repositoryProvider.notifier);
final ingredientStream = repository.watchAllIngredients();
ingredientStream.listen(
  (ingredients) {
    setState(() {
      currentIngredients = ingredients;
    });
  },
);
final repository = ref.watch(repositoryProvider);
currentIngredients = repository.currentIngredients;
void startSearch(String searchString) {
  searching = searchString.isNotEmpty;
  searchIngredients = currentIngredients
      .where((element) => true == element.name?.contains(searchString))
      .toList();
  setState(() {});
}

Key Points

  • Streams are a way to asynchronously send data to other parts of your app.
  • You usually create streams by using StreamController.
  • Use StreamBuilder to add a stream to your UI.
  • Abstract classes, or interfaces, are a great way to abstract functionality.

Where to Go From Here?

In this chapter, you learned how to use streams. If you want to learn more about the topic, visit the Dart documentation at https://dart.dev/tutorials/language/streams.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now