File based routing and nested layouts for Flutter
Flutter File Based Routing
I was inspired by the routing in remix.run with nested layouts and server side components so I decided to experiment with flutter.
Since this needs to be at compile time I wrote a generator to parse the pages directory for file based routing path names to define the regex like regex_router.
Installation
You need to install dart locally on your machine then you can run the following at your project directory:
dart generator/bin/main.dart
This will generate a generated.g.dart
which can be used to import the generated widget to run the application.
import 'package:flutter/material.dart';
import 'generated.g.dart';
void main() {
runApp(GeneratedApp(
themeMode: ThemeMode.system,
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
));
}
I also included a router.dart
that is needed by the generator and all local widgets.
Defining a base layout
You can define a base layout with the root name. For example: about.dart
import 'package:flutter/material.dart';
import '../generated.g.dart';
class AboutPage extends UiRoute<void> {
@override
Widget builder(BuildContext context, void data, Widget? child) {
return Scaffold(
appBar: AppBar(
title: const Text('About'),
),
body: child,
);
}
}
If you notice the child can be null and is used for the nested layout.
This is not a widget but instead a class we can use to optionally load data in.
Defining the index route
You can define the index route for when there are no args needed. For example: about/index.dart
import 'package:flutter/material.dart';
import '../../generated.g.dart';
class AboutDetails extends UiRoute<void> {
@override
Widget builder(BuildContext context, void data, Widget? child) {
return const Center(
child: Text('About'),
);
}
}
Since this is a nested layout all you need to do is provide the component and it will inherit from the parent layout (about.dart).
Defining a named arg
You can define a named arg for a route if there is something that does not need data fetched for. For example: /about/guest.dart
import 'package:flutter/material.dart';
import '../../generated.g.dart';
class GuestPage extends UiRoute<void> {
@override
Widget builder(BuildContext context, void data, Widget? child) {
return const Center(
child: Text('Guest'),
);
}
}
This is also just a component.
Defining a dynamic arg
Sometimes the arg is generated at runtime or needs to be pulled from a database. For example: about/:id.dart
import 'package:flutter/material.dart';
import '../../generated.g.dart';
class AccountPage extends UiRoute<Map<String, String>> {
@override
loader(route, args) => args;
@override
Widget builder(
BuildContext context, Map<String, String> data, Widget? child) {
return Center(
child: Text('ID: ${data['id']}'),
);
}
}
You can see we set the file name with a prefix of :
to define an arg to look for and match against. This will be provided in a map.
The loader can be used to pull data from a database but in this case it returns the arg map. By default it returns null.
The loader runs before the widget is built.
Routing
To navigate to another page, instead of using Navigator.of(context)
you will need to dispatch the following event:
RoutingRequest('ROUTE_HERE').dispatch(context)
ROUTE_HERE
should be the named of your route like /about/30
.
Storing state
Everything should be stateless, but in the example you can see that even bottom tab navigation index can be done just with the route.
Conclusion
This solves a variety of layout issues and can provide pretty urls while also only loading the data once and caching if needed.