Tutorial

Adding Tabs

Wrap the routes in an AdaptiveShellRoute with one AdaptiveBranch per tab.

Three new types

TypeWhat 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.

dart lib/main.dart
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, default true).
  • 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.