
How to use Fluro with Flutter (Custom Routing/Transitions)
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:

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,
});
}
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"),
];
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));
}
}
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(() {});
}
}
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(),
);
}
}
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),
),
],
),
);
}
}
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),
);
}
}
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,
];
}
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"),
)
],
),
),
);
}
}
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"),
)
],
),
),
);
}
}
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,
);
}
}
We've also removed the home
from MaterialApp
and replaced it with onGenerateRoute
that points to the AppRouter.router.generator
instead.
Navigation
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,
),
),
);
},
),
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(() {});
}
}
Here are some things to note about our updated ContactDetailPage
:
- 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 theContact Detail
page, so by doing a quick check withNavigator.canPop()
, we can show a back button or home button. - I've added
replace: true
andclearStack: true
for the home route, as this is an example of how to replace the route and clear the navigation stack programatically.
Navigating Through our App
If we navigate through our application starting from the HomePage
, we now get:
- http://localhost:50418/#/ - (HomePage)
- http://localhost:50418/#/contacts -(Contacts List Page)
- http://localhost:50418/#/contacts/1 - (Contacts Detail Page - ID 1)
- http://localhost:50418/#/contacts/3 - (Contacts Detail Page - ID 3)
- 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.