Rethink Flutter Navigation with the Flow Builder Package

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 {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Home"),
      ),
      body: Center(
        child: TextButton(
          onPressed: () async {
          },
          child: Text("Go to Contact List Flow"),
        ),
      ),
    );
  }
}

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

class HomePage extends StatelessWidget {
  
  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';


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


abstract class ContactState with _$ContactState {
  const factory ContactState({
    @Default([]) List<Contact> contactList,
    Contact newContact,
    Contact selectedContact,
  }) = _ContactState;
}

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();
  }
}

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(),
      );

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

    return FlowBuilder<ContactState>(
      state: contactState,
      onGeneratePages: onGenerateContactPages,
    );
  }
}

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

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

  
  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());

  
  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 {
  
  _ContactFormState createState() => _ContactFormState();
}

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

  
  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());

  
  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!

Paul Halliday's avatar
Paul Halliday's avatar

Paul Halliday

Creator ● developer.school

Passionate about cross-platform web and mobile development.

developer.school

© 2021 developer.school. All rights reserved.

© 2021 developer.school | All rights reserved

Subscribe to our newsletter

The latest news, articles, and resources, sent to your inbox weekly.