You've successfully subscribed to developer.school
Great! Next, complete checkout for full access to developer.school
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info is updated.
Billing info update failed.

Rethink Flutter Navigation with the Flow Builder Package

Paul Halliday
Paul Halliday

Okay. I'll admit it. I've wanted to play around with Navigator 2.0, but I haven't got around to it yet. This abstraction over Navigator 2.0 did catch my eye though - it's called flow_builder and was built by the folks over at Very Good Ventures.

Want a super quick introduction with fewer words? Check out the article over at Very Good Ventures

In this article we're going to build a simple Contact List page which has the following functionality:

  1. Contact List - view contacts in a ListView
  2. Add Contact - add a new contact.
  3. View Contact - view a contact

And the best part? We just have to call update or complete on the flow context extension and it'll automatically route us to the correct "Flow". Take this array for example. If we had a selectedContact, which page should it return for us?

return [
  ContactListPage.route(),
  if (state.newContact != null) AddContactPage.route(),
  if (state.selectedContact != null) ViewContactPage.route(),
];

As you may have (rightly) guessed, it's the ViewContactPage. This makes it simple for us to create declarative routes based upon our state. The aspect of state here is also flexible, as we'll come to find out soon.

Project Setup

As always, we'll be starting with a blank Flutter project. Run the following in your terminal:

$ flutter create ds_flow_builder

$ cd ds_flow_builder

$ code . (or your favourite editor)

We'll also be using Riverpod to manage state, but this is entirely optional. I've also added freezed to generate our models.

Haven't used freezed before? Check out my Getting Started with Freezed article.

Update your pubspec.yaml with the plugins:

Flow Builder

Let's start by creating a HomePage which will be used to start our ContactFlow when a TextButton is tapped:

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Home"),
      ),
      body: Center(
        child: TextButton(
          onPressed: () async {
          },
          child: Text("Go to Contact List Flow"),
        ),
      ),
    );
  }
}
src/home/pages/home_page.dart

Update your main.dart with the HomePage as our home route, as well as adding a ProviderScope over runApp:

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Home"),
      ),
      body: Center(
        child: TextButton(
          onPressed: () async {
            await Navigator.of(context).push(
              ContactFlow.route(),
            );

            showDialog(
              context: context,
              builder: (context) => AlertDialog(
                title: Text("Flow Completed!"),
                content: Text("All done."),
              ),
            );
          },
          child: Text("Go to Contact List Flow"),
        ),
      ),
    );
  }
}

State

Next up, let's start the process of defining a Flow. This starts by defining the state that governs our Flow. Create a simplistic Contact and ContactState model which represents possible states that our UI can be in:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'contact_model.freezed.dart';

@freezed
abstract class Contact with _$Contact {
  const factory Contact({
    String name,
    String phoneNumber,
  }) = _Contact;
}

@freezed
abstract class ContactState with _$ContactState {
  const factory ContactState({
    @Default([]) List<Contact> contactList,
    Contact newContact,
    Contact selectedContact,
  }) = _ContactState;
}
src/contact_flow/models/contact_model.dart

You can generate the models for this by running the build_runner command in the terminal (from the project directory):

$ flutter pub run build_runner watch --delete-conflicting-outputs

ContactNotifier

Let's define a StateNotifier<ContactState> so that we can rely on Riverpod to manage our application state.

final contactNotifier = StateNotifierProvider(
  (ref) => ContactStateNotifier(),
);

class ContactStateNotifier extends StateNotifier<ContactState> {
  ContactStateNotifier([ContactState state]) : super(ContactState());

  void addContact() {
    state = state.copyWith(
      contactList: [...state.contactList, state.newContact],
    );

    resetContactAddViewState();
  }

  void setNewContact() => state = state.copyWith(newContact: Contact());

  void setSelectedContact(Contact contact) => state = state.copyWith(
        selectedContact: contact,
        newContact: null,
      );

  void setName(String name) =>
      state = state.copyWith(newContact: state.newContact.copyWith(name: name));
  void setPhoneNumber(String phoneNumber) => state = state.copyWith(
      newContact: state.newContact.copyWith(phoneNumber: phoneNumber));

  void resetContactAddViewState() {
    state = state.copyWith(selectedContact: null, newContact: null);
  }

  void resetContactState() {
    state = ContactState();
  }
}
src/contact_flow/notifiers/contact_notifier.dart

As you can see, we've got some basic add/set/reset methods which allow us to modify the list of contacts, set a selected contact and set a new contact.

State -> Page Definitions

With our ContactNotifier ready, we can define a ContactFlow which requires us to define:

  1. The state that the FlowBuilder<T> widget can use to build our UI
  2. An onGeneratePages method which returns the UI based on state:
List<Page> onGenerateContactPages(ContactState state, List<Page> pages) {
  return [
    ContactListPage.route(),
    if (state.newContact != null) AddContactPage.route(),
    if (state.selectedContact != null) ViewContactPage.route(),
  ];
}

class ContactFlow extends ConsumerWidget {
  static Route<ContactState> route() => MaterialPageRoute(
        builder: (BuildContext context) => ContactFlow(),
      );

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final contactState = watch(contactNotifier.state);

    return FlowBuilder<ContactState>(
      state: contactState,
      onGeneratePages: onGenerateContactPages,
    );
  }
}
src/contact_flow/contact_flow.dart

The only thing left to do is define our pages that we added to the onGenerateContactPages method:

ContactListPage

The ContactListPage uses our ContactNotifier to perform actions on the ContactState which in turn navigates the user to the AddContactPage or ViewContactPage:

class ContactListPage extends ConsumerWidget {
  static MaterialPage<ContactListPage> route() =>
      MaterialPage(child: ContactListPage());

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final contact = watch(contactNotifier);
    final contactState = watch(contactNotifier.state);

    return Scaffold(
      appBar: AppBar(
        title: Text("Contact List"),
        actions: [
          IconButton(
            icon: Icon(Icons.check),
            onPressed: () {
              contact.resetContactState();
              context.flow<ContactState>().complete((_) => contactState);
            },
          )
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => contact.setNewContact(),
        child: Icon(Icons.add),
      ),
      body: Visibility(
        visible: contactState.contactList.length > 0,
        replacement: Center(
          child: const Text("No contacts found."),
        ),
        child: ListView.builder(
          itemCount: contactState.contactList.length,
          itemBuilder: (BuildContext context, int index) => ListTile(
            title: Text(contactState.contactList[index].name),
            subtitle: Text(contactState.contactList[index].phoneNumber),
            onTap: () =>
                contact.setSelectedContact(contactState.contactList[index]),
          ),
        ),
      ),
    );
  }
}

Notice how there's zero navigation code inside of this example? That's because whenever our contactState updates, the FlowBuilder widget is updating our navigator accordingly.

Inside of the IconButton within the AppBar you may have seen:

// You need to import 'flow_builder' to access the extensions on `context`:
// import 'package:flow_builder/flow_builder.dart';

context.flow<ContactState>().complete((_) => contactState);

This is used to "complete" the flow and have the user navigate back to the page that initiated the Flow. In our case, this is the HomePage.

AddContactPage

When a user clicks the FloatingActionButton on the ContactListPage, they'll be navigated to the AddContactPage because of the contact.setNewContact().  Here's what this looks like:

class AddContactPage extends ConsumerWidget {
  static MaterialPage<AddContactPage> route() =>
      MaterialPage(child: AddContactPage());

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final contact = watch(contactNotifier);
    final isContactFormValid = watch(isContactFormValidProvider);

    return WillPopScope(
      onWillPop: () async {
        contact.resetContactAddViewState();
        isContactFormValid.state = false;

        return true;
      },
      child: Scaffold(
        appBar: AppBar(
          title: Text("Add Contact"),
        ),
        floatingActionButton: FloatingActionButton(
            onPressed: () async {
              if (isContactFormValid.state) {
                isContactFormValid.state = false;
                contact.addContact();
              } else {
                await showDialog(
                  context: context,
                  builder: (context) => AlertDialog(
                    title: Text("Form Not Valid"),
                    content: Text(
                        "Can't add a new Contact as the form is not valid"),
                  ),
                );
              }
            },
            child: Icon(Icons.check)),
        body: Padding(
          padding: const EdgeInsets.all(16.0),
          child: ContactForm(),
        ),
      ),
    );
  }
}

As you can see, this requires the ContactForm widget which defines the isContactFormValid Provider. This is updated any time that any of the TextFormFields inside of the ContactForm change:

final isContactFormValidProvider = StateProvider<bool>((ref) => false);

class ContactForm extends StatefulWidget {
  @override
  _ContactFormState createState() => _ContactFormState();
}

class _ContactFormState extends State<ContactForm> {
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Consumer(
      builder: (BuildContext context,
          T Function<T>(ProviderBase<Object, T>) watch, Widget child) {
        final contact = watch(contactNotifier);
        final isContactFormValid = watch(isContactFormValidProvider);
        return Form(
          key: _formKey,
          autovalidateMode: AutovalidateMode.onUserInteraction,
          onChanged: () =>
              isContactFormValid.state = _formKey.currentState.validate(),
          child: Column(
            children: [
              TextFormField(
                decoration: InputDecoration(
                  labelText: "Full Name",
                ),
                validator: (String value) =>
                    value.isEmpty ? "Name cannot be empty" : null,
                onChanged: (String value) => contact.setName(value),
              ),
              TextFormField(
                decoration: InputDecoration(
                  labelText: "Phone Number",
                ),
                validator: (String value) =>
                    value.isEmpty ? "Phone Number cannot be empty" : null,
                onChanged: (String value) => contact.setPhoneNumber(value),
              ),
            ],
          ),
        );
      },
    );
  }
}

There are a couple of things to note here:

  1. We're using WillPopScope to call the contactResetAddViewState method defined inside of our ContactNotifier. By setting our newContact to null, it'll revert us to the ContactListPage as defined in our flow.
  2. If the ContactForm is valid and we've called contact.addContact(), this will add the contact to the contactList and set the newContact to null, once again, this navigates us back to the ContactListPage.

This just leaves one peice of functionality left - the ViewContactPage.

ContactViewPage

The ContactViewPage is fairly similar to the other examples we've seen thus far, so I won't dwell too long on it:

import 'package:ds_flow_builder/src/contact_flow/notifiers/contact_notifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/all.dart';

class ViewContactPage extends ConsumerWidget {
  static MaterialPage<ViewContactPage> route() =>
      MaterialPage(child: ViewContactPage());

  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final contact = watch(contactNotifier);
    final contactState = watch(contactNotifier.state);

    return WillPopScope(
      onWillPop: () async {
        contact.setSelectedContact(null);
        return true;
      },
      child: Scaffold(
        appBar: AppBar(
          title: Text(contactState.selectedContact?.name ?? ""),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                contactState.selectedContact?.name ?? "",
                style: Theme.of(context).textTheme.headline6,
              ),
              Text(contactState.selectedContact?.phoneNumber ?? ""),
            ],
          ),
        ),
      ),
    );
  }
}

By setting the selectedContact to null whenever the user presses the back button, we're telling the FlowBuilder to show us the ContactListPage, and thus, get navigated back.

Running our Application

With all that in mind, run the application on your simulator/device to see how it plays out:

Flow Builder without Riverpod

Although we used Riverpod for our above application, we can just as easily use the built-in FlowController to manage state.

Do you remember this from earlier?

// You need to import 'flow_builder' to access the extensions on `context`:
// import 'package:flow_builder/flow_builder.dart';

context.flow<ContactState>().complete((_) => contactState);

Well, the context.flow<ContactState> also has a .update method which can be used to update the internal Flow state.

Here's an example comparison of how these two approaches compare:

// Set `newContact` to `Contact` to initiate `AddContactPage`:

//Riverpod
floatingActionButton: FloatingActionButton(
  onPressed: () => contact.setNewContact(),
  child: Icon(Icons.add),
),

//context.flow<T>
floatingActionButton: FloatingActionButton(
  onPressed: () => context.flow<ContactState>().update(
        (contactState) => contactState.copyWith(
          newContact: Contact(),
        ),
      ),
  child: Icon(Icons.add),
),

Summary

I hope you've found this introduction to Flow Builder useful.  You can find the code for this article here.

It's early doors for the package, but I can see a bright future as it makes routing declaritvely pain free.  Whilst the package works on the Web, I'd love to see support for web-based address bar routing, i.e.:

  • /contacts
  • /contacts/add
  • /contacts/1

I'd love to hear your thoughts in the comments below!

Interested in other packages that assist with routing? Check out Fluro:

How to use Fluro with Flutter (Custom Routing/Transitions)
Routing is one of the most important parts of an application. It’s easy tooverlook, but especially as the stable release of Flutter Web gets ever closer;the URL bar should represent the current application state. Have you ever built or used a SPA where, after refreshing the page, you lose thecur…
Flutter

Paul Halliday

👋 Want to see more content? Head over to the YouTube channel: https://youtube.com/c/paulhalliday!