Tutorial
Titles & Breadcrumbs
Give routes titles so the breadcrumb strip above the two columns can show where you are.
Route titles
AdaptiveRoute.title is a String Function(AdaptiveRouteState state). It runs
once, when the route is matched, and has no BuildContext
— derive the title from state.pathParameters or state.uri.
In two-column mode the shell draws AdaptiveBreadcrumbs above the columns (showBreadcrumbs: true
is the default). Tapping an earlier crumb pops back to it.
The complete file
Every route gets a title; the detail title is looked up from the id.
import 'package:material_ui/material_ui.dart';
import 'package:xue_hua_adaptive_sliding_layout/xue_hua_adaptive_sliding_layout.dart';
class Product {
const Product(this.id, this.name, this.price);
final int id;
final String name;
final double price;
}
const products = [
Product(1, 'Coffee mug', 12),
Product(2, 'Notebook', 8),
Product(3, 'Desk lamp', 39),
];
Product? findProduct(String? id) {
final value = int.tryParse(id ?? '');
for (final product in products) {
if (product.id == value) return product;
}
return null;
}
final router = AdaptiveRouter(
initialLocation: '/products',
routes: [
// One shell per app, at the top level. Each branch is a tab with its own stack.
AdaptiveShellRoute(
// Two columns from 840 logical pixels wide (this is the default; change it here).
showBreadcrumbs: true, // default: a crumb strip above the two columns
breakpoints: const LayoutBreakpoints(expandedMinWidth: 840),
// Shown in the right column while only the list is open.
placeholder: (context) => const Center(child: Text('Pick a product')),
builder: (context, shell, child) => AppShell(shell: shell, child: child),
branches: [
AdaptiveBranch(
routes: [
AdaptiveRoute(
path: '/products',
title: (state) => 'Products',
builder: (context, state) => const ProductsPage(),
routes: [
// Child paths are relative: this matches /products/1, /products/2 ...
AdaptiveRoute(
path: ':id',
name: 'product', // lets you build locations with namedLocation
// Evaluated once at match time, without a BuildContext.
title: (state) => findProduct(state.pathParameters['id'])?.name ?? 'Product',
builder: (context, state) => ProductDetailPage(id: state.pathParameters['id']),
routes: [
AdaptiveRoute(
path: 'reviews', // /products/:id/reviews
title: (state) => 'Rate',
builder: (context, state) => const ReviewsPage(),
),
],
),
],
),
],
),
AdaptiveBranch(
routes: [
AdaptiveRoute(
path: '/account',
title: (state) => 'Account',
builder: (context, state) => const AccountPage(),
),
],
),
],
),
],
);
void main() => runApp(const ShopApp());
class ShopApp extends StatelessWidget {
const ShopApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(title: 'Shop', routerConfig: router);
}
}
/// The host chrome: a bottom bar on phones, a rail on wider windows.
class AppShell extends StatelessWidget {
const AppShell({super.key, required this.shell, required this.child});
final AdaptiveShellState shell;
final Widget child;
void _select(int index) {
// Tapping the current tab again returns to that tab's first page.
shell.goBranch(index, initialLocation: index == shell.currentIndex);
}
@override
Widget build(BuildContext context) {
if (shell.isCompact) {
// AdaptiveShellChrome puts the bar inside the branch page, so pushed pages can cover it.
return AdaptiveShellChrome(
bottomNavigationBar: (context) => NavigationBar(
selectedIndex: shell.currentIndex,
onDestinationSelected: _select,
destinations: const [
NavigationDestination(icon: Icon(Icons.storefront), label: 'Products'),
NavigationDestination(icon: Icon(Icons.person), label: 'Account'),
],
),
child: child,
);
}
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: shell.currentIndex,
onDestinationSelected: _select,
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(icon: Icon(Icons.storefront), label: Text('Products')),
NavigationRailDestination(icon: Icon(Icons.person), label: Text('Account')),
],
),
Expanded(child: child),
],
),
);
}
}
class ProductsPage extends StatelessWidget {
const ProductsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: ListView(
children: [
for (final product in products)
ListTile(
title: Text(product.name),
subtitle: Text('\$${product.price}'),
onTap: () => AdaptiveRouter.of(context).pushNamed('/products/${product.id}'),
),
],
),
);
}
}
class ProductDetailPage extends StatefulWidget {
const ProductDetailPage({super.key, required this.id});
final String? id;
@override
State<ProductDetailPage> createState() => _ProductDetailPageState();
}
class _ProductDetailPageState extends State<ProductDetailPage> {
int? _stars;
@override
Widget build(BuildContext context) {
final product = findProduct(widget.id);
if (product == null) {
return Scaffold(
appBar: AppBar(),
body: Center(child: Text('No product ${widget.id}')),
);
}
final router = AdaptiveRouter.of(context);
final next = products[product.id % products.length]; // wraps around
return Scaffold(
appBar: AppBar(
leading: BackButton(onPressed: () => router.maybePop()),
title: Text(product.name),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text('\$${product.price}', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
// pushNamed returns a Future that completes with the value passed to pop.
FilledButton(
onPressed: () async {
final stars = await router.pushNamed<int>('/products/${product.id}/reviews');
if (stars != null && mounted) setState(() => _stars = stars);
},
child: Text(_stars == null ? 'Rate this product' : 'Your rating: $_stars stars'),
),
// pushReplacementNamed swaps the top page: back still returns to the list.
OutlinedButton(
onPressed: () => router.pushReplacementNamed(
router.namedLocation('product', pathParameters: {'id': '${next.id}'}),
),
child: Text('Next: ${next.name}'),
),
// pushNamedAndRemoveUntil with (_) => false rebuilds the whole stack from a URL.
TextButton(
onPressed: () => router.pushNamedAndRemoveUntil('/products', (_) => false),
child: const Text('Back to the list'),
),
],
),
);
}
}
class ReviewsPage extends StatelessWidget {
const ReviewsPage({super.key});
@override
Widget build(BuildContext context) {
final router = AdaptiveRouter.of(context);
return Scaffold(
appBar: AppBar(
leading: BackButton(onPressed: () => router.maybePop()),
title: const Text('Rate'),
),
body: ListView(
children: [
for (var stars = 5; stars >= 1; stars--)
ListTile(
title: Text('$stars stars'),
// pop(result) completes the pushNamed Future on the previous page.
onTap: () => router.pop(stars),
),
],
),
);
}
}
class AccountPage extends StatelessWidget {
const AccountPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Account')),
body: const Center(child: Text('Signed out')),
);
}
}
What you will see
- Wide window, open Coffee mug and then Rate: the strip reads Products › Coffee mug › Rate.
- Tap Products in the strip: you are back on the list.
- Below 840 px there is no strip — the AppBar title is enough in one column.
Titles that arrive later
If the title is only known after loading (or needs BuildContext, e.g. for localization), set it from the page.
AdaptivePaneScope.maybeOf(context) is non-null only inside a column:
// e.g. in didChangeDependencies or after your data loads:
AdaptivePaneScope.maybeOf(context)?.title.value = product.name;
-
A static
titleis not re-evaluated when the locale changes; use the page-side assignment if titles must follow a live language switch. -
For context-free localization inside
title, use gen-l10n'slookupAppLocalizations(locale)orIntl.defaultLocale. -
Restyle the strip with
breadcrumbsBuilder(see Customizing the UI) or hide it withshowBreadcrumbs: false.
Next: Redirects, sign-in & onExit.