Tutorial
Adding Tabs
Wrap the routes in an AdaptiveShellRoute with one AdaptiveBranch per tab.
Three new types
| Type | What it is |
|---|---|
AdaptiveShellRoute |
The app chrome around the tabs.
One per app, top-level only.
Its
builder(context, shell, child)
returns your chrome with
child
(the current tab) inside. It is also what enables the two-column layout.
|
AdaptiveBranch |
One tab: a list of routes with its own page stack. |
AdaptiveShellState |
The
shell
argument:
currentIndex
,
goBranch(index)
, and the width band —
isCompact
,
isMedium
,
isExpanded
.
|
On compact widths wrap the tab in `AdaptiveShellChrome` and pass the bar as a builder. The bar then lives inside the tab page, so pushed pages (and bottom sheets) can cover it.
The complete file
The /products route moves, unchanged, into the first branch. A second branch holds
/account. A new AppShell widget draws the bar.
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(
builder: (context, shell, child) => AppShell(shell: shell, child: child),
branches: [
AdaptiveBranch(
routes: [
AdaptiveRoute(
path: '/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
builder: (context, state) => ProductDetailPage(id: state.pathParameters['id']),
routes: [
AdaptiveRoute(
path: 'reviews', // /products/:id/reviews
builder: (context, state) => const ReviewsPage(),
),
],
),
],
),
],
),
AdaptiveBranch(
routes: [
AdaptiveRoute(path: '/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
- Phone width: a bottom bar. Open a product, switch to Account, switch back — the product is still open. Each branch remembers its stack.
-
Tap Products again while on it:
goBranch(index, initialLocation: true)returns that tab to its first page. -
On a phone the bottom bar hides on pages pushed above the tab root (
AdaptiveRoute.hidesBottomBarWhenPushed, defaulttrue). - Make the window wider than 600 px: the rail replaces the bar.
- Wider than 840 px and open a product… the list and the detail appear side by side. You did not write anything for that — the next page explains it.
Next: Two columns & breakpoints.