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.

How to use Fluro with Flutter (Custom Routing/Transitions)

Paul Halliday
Paul Halliday

Routing is one of the most important parts of an application. It's easy to overlook, 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 the current context and you're navigated to somewhere else? I have. That's because I didn't respect the fact that the user may want to navigate directly to a page (either from the favourites or directly).

This is otherwise known as deep linking and makes sense when you think of it like this:

I should be able to go directly to a contact details page:

https://developer.school/contacts/1 (link doesn't work of course)

That means I need to gather the current id from as a parameter and provide the user with a Contact with the id of 1.

Welcome to our project! We'll be building this contact list application with Flutter and Fluro.

Project Setup

Ensure you've got Flutter installed correctly on your machine and open your terminal. Run the following:

$ flutter create ds_fluro_contact_list
$ cd ds_fluro_contact_list
$ code . (or open this in your favourite editor)

Pubspec.yaml

Next up, we'll need to update pubspec.yaml with the fluro package:

dependencies:
  flutter:
    sdk: flutter

  fluro: ^1.7.7

dev_dependencies:
  flutter_test:
    sdk: flutter

Enable Flutter Web

If you're following along and want to use Flutter Web like me, then you may need to enable Flutter Web. This requires you to be on the beta channel and run the --enable-web  command:

$ flutter channel beta
$ flutter upgrade
$ flutter config --enable-web

You can find more information about building web applications with Flutter here.

Now that we've enabled Flutter Web, run your application inside of Chrome by selecting Chrome from the devices list inside of VS Code:

Finally, start the debug process and it'll automatically open Chrome with our project:

NOTE: If you're not using VS Code and instead you're using the terminal, run flutter run -d chrome

Building the Contact Application

We're all set up to investigate Fluro! We'll start by building out the ContactListPage / ContactDetailPage without fluro (just using standard Navigator.of(context).push(Page.route()) and then we'll convert it page routes.

Why do this first? It allows us to build out our Master > Detail screens, and allows us to directly compare the two approaches.

Here's what we'll be building:

We'll be building this without Fluro first.

Contact Model

Let's start by defining our Contact model with a couple of properties:

@immutable
class Contact {
  final String id;
  final String name;

  const Contact({
    @required this.id,
    @required this.name,
  });
}
src/models/contact.dart
Want to learn how to make powerful classes with freezed? Check out my article on that topic.

Next up, we'll create a constant list of contacts that we can use throughout our application.

final List<Contact> contactList = [
  Contact(id: "1", name: "Paul"),
  Contact(id: "2", name: "Eric"),
  Contact(id: "3", name: "Alex"),
  Contact(id: "4", name: "Sarah"),
  Contact(id: "5", name: "Ivory"),
];
src/constants/contact_constants.dart

ContactService

In order to get the list of contacts to be displayed, we'll be using a ContactService that either:

a. Throws an error 50% of the time (using random.nextBool())
b. Returns a Contact or List<Contact> after two seconds.

This is done to model a real API and throw errors often enough for us to ensure we're happy with how they look in the UI.

class ContactService {
  Future<List<Contact>> getContacts() async {
    try {
      await _shouldError("Can't get contact list.");
      return contactList;
    } catch (e) {
      return Future.error(e.toString());
    }
  }

  Future<Contact> getContactById(String id) async {
    try {
      await _shouldError("Cannot find contact with the ID of $id.");

      return contactList.firstWhere((Contact contact) => contact.id == id);
    } catch (e) {
      return Future.error(e.toString());
    }
  }

  Future<void> _shouldError(String errorMessage) async {
    Random random = Random();
    final shouldError = random.nextBool();

    if (shouldError) {
      return await Future.delayed(
        Duration(seconds: 2),
        () => throw Exception(errorMessage),
      );
    }

    return Future.delayed(Duration(seconds: 2));
  }
}
src/services/contact_service.dart

Feel free to set shouldError to false or omit it entirely after testing it a few times.

ContactListPage

The ContactListPage is where we display the list of contacts to the user. We're using a FutureBuilder and the user is able to refresh the FutureBuilder if:

a. The Future returns an Exception. We trigger this rebuild by calling setState(() {});
b. Using the Pull to Refresh dropdown on our list.

class ContactListPage extends StatefulWidget {
  static Route<ContactListPage> route() =>
      MaterialPageRoute(builder: (context) => ContactListPage());

  @override
  _ContactListPageState createState() => _ContactListPageState();
}

class _ContactListPageState extends State<ContactListPage> {
  final ContactService _contactService = ContactService();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Contact List"),
      ),
      body: FutureBuilder(
        future: _contactService.getContacts(),
        builder: (BuildContext context, AsyncSnapshot<List<Contact>> snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            return Center(child: CircularProgressIndicator());
          }

          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(snapshot.error),
                  TextButton(
                    onPressed: _refreshList,
                    child: const Text("Try Again"),
                  )
                ],
              ),
            );
          }

          if (snapshot.hasData) {
            final _contactList = snapshot.data;
            return RefreshIndicator(
              onRefresh: _refreshList,
              child: ListView.builder(
                itemCount: _contactList.length,
                itemBuilder: (BuildContext context, int index) {
                  final _contact = _contactList[index];

                  return ListTile(
                    title: Text(_contact.name),
                    subtitle: Text(_contact.id),
                    onTap: () => Navigator.of(context)
                        .push(ContactDetailPage.route(_contact)),
                  );
                },
              ),
            );
          }

          return Center(
            child: const Text("No Contacts Found"),
          );
        },
      ),
    );
  }

  Future<void> _refreshList() async {
    print("Reloading...");
    setState(() {});
  }
}
src/pages/contact_list_page.dart

Here's an example of when our contact list API call has an error:

Now that we've got the ContactListPage, go ahead and make it the home of your main.dart file:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Fluro Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      debugShowCheckedModeBanner: false,
      home: ContactListPage(),
    );
  }
}
main.dart

ContactDetailPage

The ContactDetailPage allows us to see information about the selected Contact. We're passing the contact via the route(Contact contact) function that we defined:

static Route<ContactDetailPage> route(Contact contact) => 	MaterialPageRoute(
      builder: (context) => ContactDetailPage(contact),
    );

This then gets passed to our ContactDetailPage constructor, allowing us to initialise a _contact variable to be used in our build method:

final Contact _contact;
const ContactDetailPage(Contact contact) : _contact = contact;

Here's the entire ContactDetailPage for you to use:

class ContactDetailPage extends StatelessWidget {
  static Route<ContactDetailPage> route(Contact contact) => MaterialPageRoute(
        builder: (context) => ContactDetailPage(contact),
      );

  final Contact _contact;
  const ContactDetailPage(Contact contact) : _contact = contact;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Contact Detail"),
      ),
      body: ListView(
        children: [
          ListTile(
            title: Text(_contact.name),
            subtitle: Text(_contact.id),
          ),
        ],
      ),
    );
  }
}
src/pages/contact_detail_page.dart

As we have no way to directly get to the ContactDetail page without going through the user interface, we can pass the Contact directly to this page. Building on this in our next example, we'll switch this to passing an id which gets a Contact from our "database".

Using Fluro

We've established how to create a Master > Detail view in the standard manner with Navigator, let's migrate it to use fluro. We'll be able to deep-link into our contacts and have it be searchable by a search engine.

AppRouter

Let's go ahead and create an AppRouter class which contains our FluroRouter and other route-specific information:

@immutable
class AppRouter {
  static FluroRouter router = FluroRouter.appRouter;

  final List<AppRoute> _routes;
  final Handler _notFoundHandler;

  List<AppRoute> get routes => _routes;

  const AppRouter({
    @required List<AppRoute> routes,
    @required Handler notFoundHandler,
  })  : _routes = routes,
        _notFoundHandler = notFoundHandler;

  void setupRoutes() {
    router.notFoundHandler = _notFoundHandler;
    routes.forEach(
      (AppRoute route) => router.define(route.route, handler: route.handler),
    );
  }
}
src/models/app_router.dart

AppRoutes

Next, we'll need to define some AppRoute instances which I've enclosed inside of an AppRoutes class. Each AppRoute needs a route and a handler, as well as a variety of optional parameters allowing for custom transitions.

class AppRoutes {
  static final routeNotFoundHandler = Handler(
      handlerFunc: (BuildContext context, Map<String, List<String>> params) {
    debugPrint("Route not found.");

    return RouteNotFoundPage();
  });

  static final rootRoute = AppRoute(
    '/',
    Handler(
      handlerFunc: (context, parameters) => HomePage(),
    ),
  );

  static final contactListRoute = AppRoute(
    '/contacts',
    Handler(
      handlerFunc: (context, parameters) => ContactListPage(),
    ),
  );

  static final contactDetailRoute = AppRoute(
    '/contacts/:id',
    Handler(
        handlerFunc: (BuildContext context, Map<String, List<String>> params) {
      final String contactId = params["id"][0];

      return ContactDetailPage(contactId);
    }),
  );

  /// Primitive function to get one param detail route (i.e. id).
  static String getDetailRoute(String parentRoute, String id) {
    return "$parentRoute/$id";
  }

  static final List<AppRoute> routes = [
    rootRoute,
    contactListRoute,
    contactDetailRoute,
  ];
}
src/constants/router_constants.dart

As you read through this class you'll see that it's relatively self explanatory. By defining a Handler for a route, we're able to run code prior to the route being resolved.  We're using this to get the contactId and passing this to the ContactDetailPage, as seen in the contactDetailRoute.

You'll have also noticed that we're moving away from passing a Contact to a secondary route, and instead, moving to a String which allows us to query a Contact from a database on command.

This is important, because when navigating directly to contacts/3, we're able to ask the fake database for the Contact as it doesn't currently exist in any of our contexts.

HomePage

We've set the root of our application as the HomePage widget.

This is a simple StatelessWidget which contains a link to our /contacts page:

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              "Home Page",
              style: Theme.of(context).textTheme.headline6,
            ),
            SizedBox(
              height: 6,
            ),
            Text(
              "Fluro routing examples",
              style: Theme.of(context).textTheme.bodyText2,
            ),
            SizedBox(
              height: 10,
            ),
            TextButton(
              onPressed: () => AppRouter.router.navigateTo(
                context,
                AppRoutes.contactListRoute.route,
              ),
              child: const Text("Contact List"),
            )
          ],
        ),
      ),
    );
  }
}
src/pages/home_page.dart

RouteNotFoundPage

As shown by the routeNotFoundHandler that is assigned inside of our AppRouter().setupRoutes() function, whenever a route cannot be matched with a defined route we can set up a "not found" page.

class RouteNotFoundPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text("Route not found"),
            TextButton(
              onPressed: () => AppRouter.router.navigateTo(
                context,
                AppRoutes.contactListRoute.route,
                replace: true,
                clearStack: true,
                transition: TransitionType.none,
              ),
              child: const Text("Go Home"),
            )
          ],
        ),
      ),
    );
  }
}
src/pages/route_not_found_page.dart

Initialising Fluro

Now that we have our AppRouter and AppRoutes, we can go ahead and change our MyApp widget inside of main.dart to a StatefulWidget. This is because we'll be calling our setupRoutes function inside of initState():

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    
    AppRouter appRouter = AppRouter(
      routes: AppRoutes.routes,
      notFoundHandler: AppRoutes.routeNotFoundHandler,
    );

    appRouter.setupRoutes();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Fluro Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      onGenerateRoute: AppRouter.router.generator,
      debugShowCheckedModeBanner: false,
    );
  }
}
main.dart

We've also removed the home from MaterialApp and replaced it with onGenerateRoute that points to the AppRouter.router.generator instead.

Instead of using Navigator.of(context).push(Page.route()), we're now able to use AppRouter.router.navigateTo(context, route). One of the first areas we need to update this is inside of our ContactListPage.

Update the ListView.builder to instead pass a String instead of the Contact:

ListView.builder(
  itemCount: _contactList.length,
  itemBuilder: (BuildContext context, int index) {
    final _contact = _contactList[index];

    return ListTile(
      title: Text(_contact.name),
      subtitle: Text(_contact.id),
      onTap: () => AppRouter.router.navigateTo(
        context,
        AppRoutes.getDetailRoute(
          AppRoutes.contactListRoute.route,
          _contact.id,
        ),
      ),
    );
  },
),
src/pages/contact_list_page.dart

For our demo purposes we're using AppRoutes.getDetailRoute here, but you could just as easily do:

AppRouter.router.navigateTo(
  context,
  "contacts/${_contact.id}",
);

ContactDetailPage Updates

This still won't work until we update our ContactDetailPage to get the Contact from an id instead.

Initially, you may feel that this is a bit redundant, as surely we don't always want to be asking the database the contact when we may have it locally.  In that case, you should check your local app state prior to asking the network.

Either way, we can't completely rely on local state as we could be in a position where a user has navigated to a detail view for the first time.

Update your ContactDetailPage to be a StatefulWidget and use a FutureBuilder to call getContactById(widget._contactId):

class ContactDetailPage extends StatefulWidget {
  final String _contactId;
  const ContactDetailPage(String contactId) : _contactId = contactId;

  @override
  _ContactDetailPageState createState() => _ContactDetailPageState();
}

class _ContactDetailPageState extends State<ContactDetailPage> {
  final ContactService _contactService = ContactService();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Contact Detail"),
        leading: Navigator.of(context).canPop()
            ? IconButton(
                icon: Icon(Icons.chevron_left),
                onPressed: () => AppRouter.router.pop(context),
              )
            : IconButton(
                icon: Icon(Icons.home),
                onPressed: () => AppRouter.router.navigateTo(
                  context,
                  AppRoutes.contactListRoute.route,
                  replace: true,
                  clearStack: true,
                ),
              ),
      ),
      body: FutureBuilder(
        future: _contactService.getContactById(widget._contactId),
        builder: (BuildContext context, AsyncSnapshot<Contact> snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            return Center(child: CircularProgressIndicator());
          }

          if (snapshot.hasError) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(snapshot.error),
                  TextButton(
                    onPressed: _refreshList,
                    child: const Text("Try Again"),
                  )
                ],
              ),
            );
          }

          if (snapshot.hasData) {
            final _contact = snapshot.data;
            return ListTile(
              title: Text(_contact.name),
              subtitle: Text(_contact.id),
            );
          }

          return Center(
            child: const Text("Couldn't find contact."),
          );
        },
      ),
    );
  }

  Future<void> _refreshList() async {
    print("Reloading...");
    setState(() {});
  }
}
src/pages/contact_detail_page.dart

Here are some things to note about our updated ContactDetailPage:

  1. I've changed the leading for times when there are no routes in the navigation stack. I'm not sure whether this is intended, but certain times I'm not able to go back from the Contact Detail page, so by doing a quick check with Navigator.canPop(), we can show a back button or home button.
  2. I've added replace: true and clearStack: true for the home route, as this is an example of how to replace the route and clear the navigation stack programatically.

If we navigate through our application starting from the HomePage, we now get:

  1. http://localhost:50418/#/ - (HomePage)
  2. http://localhost:50418/#/contacts  -(Contacts List Page)
  3. http://localhost:50418/#/contacts/1 - (Contacts Detail Page - ID 1)
  4. http://localhost:50418/#/contacts/3 - (Contacts Detail Page - ID 3)
  5. http://localhost:50418/#/asdf - (Route Not Found Page)

Transitions

Now that we're able to navigate with Fluro, we can customise the experience by selecting a transition per route. Let's have fun by selecting a variety of transitions for each call to navigateTo.

HomePage

Update the HomePage's TextButton that sends the user to the ContactList with the transitionType of TransitionType.cupertino:

TextButton(
  onPressed: () => AppRouter.router.navigateTo(
    context,
    AppRoutes.contactListRoute.route,
    transition: TransitionType.cupertino,
  ),
  child: const Text("Contact List"),
)

ContactListPage

Next up, update the ListTile inside of the ContactListPage to use TransitionType.fadeIn:

return ListTile(
  title: Text(_contact.name),
  subtitle: Text(_contact.id),
  onTap: () => AppRouter.router.navigateTo(
    context,
    AppRoutes.getDetailRoute(
      AppRoutes.contactListRoute.route,
      _contact.id,
    ),
    transition: TransitionType.fadeIn,
  ),
);

Custom Transitions

We're also able to use custom transitions by setting the TransitionType to custom and providing a transitionBuilder. Here's an example of where a page grows from the center of the screen:

return ListTile(
  title: Text(_contact.name),
  subtitle: Text(_contact.id),
  onTap: () => AppRouter.router.navigateTo(
    context,
    AppRoutes.getDetailRoute(
      AppRoutes.contactListRoute.route,
      _contact.id,
    ),
    transition: TransitionType.custom,
    transitionBuilder:
        (context, animation, secondaryAnimation, child) =>
            ScaleTransition(
      scale: animation,
      child: child,
      alignment: Alignment.center,
    ),
  ),
);

You can even use custom transitions from the animations package by Google. If you'd like to try it out, add the following to your pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter

  fluro: ^1.7.7
  animations: ^1.1.2

We can then update our transitionsBuilder to use the SharedAxisTransition:

return ListTile(
  title: Text(_contact.name),
  subtitle: Text(_contact.id),
  onTap: () => AppRouter.router.navigateTo(
    context,
    AppRoutes.getDetailRoute(
      AppRoutes.contactListRoute.route,
      _contact.id,
    ),
    transition: TransitionType.custom,
    transitionBuilder:
        (context, animation, secondaryAnimation, child) =>
            SharedAxisTransition(
      animation: animation,
      secondaryAnimation: secondaryAnimation,
      transitionType: SharedAxisTransitionType.scaled,
      child: child,
    ),
  ),

Summary

We've looked at how to take our small Master > Detail application using Navigator.push and changed it to be route based. Along the way, we've investigated various built-in Fluro transitions, built our own transition and used the animations package to spice it up a little.

You can find the code for this article here: https://github.com/PaulHalliday/ds_fluro_contact_list

I hope this has been worthwhile. If you'd like to read more about Fluro, I'd recommend checking the documentation: https://pub.dev/packages/fluro

Let me know in the comments if I've missed anything that you'd like to see.

Flutter

Paul Halliday

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