Tutorial
Two Columns & Breakpoints
What happens at 840 px, how pages slide, the empty right column and the resize handle.
Width bands
Breakpoints use the window width, not the device type:
| Window width | Band | What the shell shows |
|---|---|---|
< 600 (compactMaxWidth) |
compact | One column: a classic Navigator |
| 600 – 839 | medium | Still one column; your chrome can react via shell.isMedium |
≥ 840 (expandedMinWidth) |
expanded | Two columns: the last two pages of the stack |
How pages move
-
Stack
[products]: list on the left, the placeholder on the right. -
pushNamed('/products/1')→[products, 1]: the detail fills the right column. -
Push
reviews→[products, 1, reviews]: everything slides one column left; the detail is now on the left, reviews on the right. - Back slides them back. Drag the handle between the columns to resize them (left starts at 40%, each side keeps at least 35%).
The complete file
Only the AdaptiveShellRoute changes: an explicit breakpoints
(840 is the default — write it when you want another value) and a placeholder.
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).
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',
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
- At 1200 px: products on the left, “Pick a product” on the right. Tap a product: it opens on the right, the list stays visible.
- Rate it: the detail slides to the left and the rating page appears on the right.
- Narrow the window below 840 px: the same stack is shown as one column, top page only. Widen it again: two columns.
-
A placeholder per tab is possible too:
AdaptiveBranch(placeholder: ...)wins over the shell-level one.
Next: Titles & breadcrumbs.