x_router
A simple and powerful routing lib that simplify having multiple child router.
Features
- Easy navigation
- Child routers
- resolvers
- Relative navigation
- Redirects
- Localized browser tab title
- Url matching
- Router history
- Event driven
Notice
This library is susceptible of making breaking changes without deprecation.
![](https://raw.githubusercontent.com/cedvdb/x_router/main/recordings/recording_example.gif)
Core idea
One area that seem to be a point of confusion for developers is the different back buttons. On the web there is the back button, usually using the browser arrow ◀, to navigate chronologically through the pages we visited before. While in an application there is typically also an down button, usually the icon ⬅ at the top of the app bar, to navigate down in the stack of pages that are superimposed on each others. In this doc, the word downstack is used to refer to the stack of pages accessible when pressing ⬅ and popping the current page. (for more information see https://developer.android.com/guide/navigation/navigation-principles)
One of the design decision idea of this package is that the downstack is a function of the url
That is that for an url like /products/123
we have a stack of two pages [ProductsPage, ProductsDetailsPage]
by default.
Let's take this fairly common and complex scenario:
'/sign-in' => user access this page when unauthenticated
'/sign-in/verify_phone' => user can access this nested page when unauthenticated
'/dashboard' => when the user is authenticated & has a profile, the dashboard
'/products' => when the user is authenticated & has a profile and clicked on a menu item to see the products
'/products/:id' => when the user wants to see a specific product
In this scenario it is apparent that the downstack can be defined as a function of the url path where each segment of the path is a screen in the stack. For example when on the '/products/:id' route the downstack will look like this:
- ProductsScreen
- ProductDetailsScreen
This is the approach this library takes to create the downstack by default.
Usage
Navigation
For navigation you can use the goTo(location)
method:
final router = XRouter(routes: []);
router.goTo('/products/:id', params: { 'id': '123' }); // products/123
// Generally you will store your routes somewhere:
router.goTo(AppRoutes.productDetails, params: { 'id': '123' });
All navigation methods
goTo
: goes to location adding the target to historyreplace
: removes current location from history andgoTo
locationback
: go back chronologically
Relative navigation
You can also navigate relative to the current route
// goes to `products/123/info`, we were on /products/123/comments
router.goTo('./info');
// goes to /preferences, when we were on /products/123/info
router.goTo('../../preferences');
Setup
1. Simple setup
The router in its simplest form defines a series of routes with builders and paths associated with them. The first step is to define those routes:
XRouter(
routes: [
XRoute( path: '/sign-in', builder: (ctx, activatedRoute) => SignInPage(),),
XRoute(
path: '/dashboard',
builder: (ctx, activatedRoute) => DashboardPage(),
// tab title
titleBuilder: (ctx, activatedRoute) => AppLocalization.of(ctx).dashboard
),
XRoute(
path: '/products',
builder: (ctx, activatedRoute) => ProductsPage(),
),
XRoute(
path: '/products/:id',
builder: (ctx, activatedRoute) => ProductDetailsPage(activatedRoute.params['id']),
),
],
);
2. Add redirects
The next step is to add a series of redirect so the user on the web are always redirected where you want
XRouter(
resolvers: [
XNotFoundResolver(redirectTo: '/'),
XRedirectResolver(from: '/', to: 'dashboard'),
],
routes: [
// ...
],
);
3. Guard your routes
Usually your app will have authentication where the authentication is in 3 possible state (unauthenticated, authenticated, unknown). You want to protect pages that are not supposed to be accessible.
XRouter(
resolvers: [
XNotFoundResolver(redirectTo: '/'),
XRedirectResolver(from: '/', to: 'dashboard'),
AuthResolver()
],
routes: [
XRoute( path: '/dashboard', builder: (ctx, activatedRoute) => DashboardPage()),
XRoute( path: '/products', builder: (ctx, activatedRoute) => ProductsPage()),
XRoute(
path: '/products/:id',
builder: (ctx, activatedRoute) => ProductDetailsPage(activatedRoute.params['id']),
),
],
);
Resolvers
When a page is accessed via a path ('/path'). That path goes through each resolvers provided to the router sequentially to return a resolved path.
Each resolver can return either a future of Redirect
or Next
.
To create a resolver, just use the mixin XResolver
. Here is an example of a redirect resolver:
// A redirect resolver is provided by the library
class XRedirectResolver with XResolver {
final String from;
final String to;
XRedirectResolver({
required this.from,
required this.to,
});
@override
Future<XResolverAction> resolve(String target) async {
// you can use the XRoutePattern class instead of startsWith
// which will also match for patterns like /products/:id
if (target.startsWith(from)) {
return Redirect(to);
}
return Next();
}
}
resolvers can return 3 type of value:
Next
: proceeds to the next resolver until we reach the endRedirect
: redirects to a target and goes through each resolver again with a new path
! Note: All resolvers are active for all paths
Built-resolvers
A series of resolvers are provided by the library:
- XNotFoundResolver: to redirect when no route is found
- XRedirect: to redirect a specific path
Nested routing
Child Router
First setup your view with flutter's Router
as a child. That is where your child routes will be rendered:
class HomeLayout extends StatelessWidget {
const HomeLayout({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
children: [
const NavRail(),
Expanded(
child: Router(
routerDelegate:
router.childRouterStore.findDelegate(RouteLocations.app),
),
),
],
);
}
}
Next you probably want to react to navigation changes, in the example above we want to animate the rail
class NavRail extends StatefulWidget {
const NavRail({
Key? key,
}) : super(key: key);
@override
State<NavRail> createState() => _NavRailState();
}
class _NavRailState extends State<NavRail> {
final _tabsIndex = <XRoutePattern, int>{
XRoutePattern(RouteLocations.dashboard): 0,
XRoutePattern(RouteLocations.products): 1,
XRoutePattern(RouteLocations.favorites): 2,
};
StreamSubscription? navSubscription;
int _selectedTab = 0;
@override
void initState() {
_selectedTab = _findTabIndex(router.history.currentUrl) ?? 0;
// if the user changes the URL via the browser, you want your rail to react
navSubscription = router.eventStream
.where((event) => event is NavigationEnd)
.listen((nav) => _refreshBottomBar());
super.initState();
}
@override
dispose() {
navSubscription?.cancel();
super.dispose();
}
/// changes the selected tab when the url changes
_refreshBottomBar() {
final foundIndex = _findTabIndex(router.history.currentUrl);
if (foundIndex != null) {
setState(() => _selectedTab = foundIndex);
}
}
/// finds the tab index associated with a path
int? _findTabIndex(String path) {
for (final pattern in _tabsIndex.keys) {
if (pattern.match(path, matchChildren: true)) {
return _tabsIndex[pattern];
}
}
return null;
}
/// when a tab is clicked, navigate to the target location
_navigate(int index) {
router.goTo(_findRoutePath(index));
}
/// finds the url path given a tab index
String _findRoutePath(int index) {
for (final entry in _tabsIndex.entries) {
if (entry.value == index) {
return entry.key.path;
}
}
return _tabsIndex.keys.first.path;
}
@override
Widget build(BuildContext context) {
return NavigationRail(
onDestinationSelected: _navigate,
selectedIndex: _selectedTab,
extended: true,
destinations: const [
// material you
NavigationRailDestination(
label: Text('dashboard'), icon: Icon(Icons.home)),
NavigationRailDestination(
label: Text('products'), icon: Icon(Icons.shopping_bag)),
NavigationRailDestination(
label: Text('favorites'), icon: Icon(Icons.favorite))
],
);
}
}
Finally you have to define your routes:
// this is the main page where the nav rail appears
XRoute(
path: RouteLocations.app,
builder: (ctx, route) => const HomeLayout(),
// those page will be placed inside the home layout page
childRouterConfig: XChildRouterConfig(
routes: [
XRoute(
path: RouteLocations.dashboard,
builder: (ctx, route) => const DashboardPage(),
titleBuilder: (_) => 'dashboard',
),
XRoute(
path: RouteLocations.favorites,
builder: (ctx, route) => const FavoritesPage(),
titleBuilder: (_) => 'My favorites',
),
XRoute(
path: RouteLocations.products,
builder: (ctx, route) {
return const ProductsPage();
},
titleBuilder: (_) => 'products',
),
XRoute(
path: RouteLocations.productDetail,
builder: (ctx, activatedRoute) =>
ProductDetailsPage(activatedRoute.pathParams['id']!),
),
],
),
),
Browser tab titles
To customize your routes a bit you can add tab titles:
XRoute(
path: '/dashboard',
builder: (ctx, activatedRoute) => DashboardPage(),
// tab title
titleBuilder: (ctx, activatedRoute) => AppLocalization.of(ctx).dashboard
)
Url matching
When an user access a path in your application, the url will automatically shrink to find the matching route.
That means that if you defined a router with only a /dashboard
route:
XRoute(
path: '/dashboard',
builder: (ctx, activatedRoute) => DashboardPage(),
),
When the user access the path /dashboard/something-undefined
, the url in the browser will change to /dashboard
and the user
will access /dashboard
.