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.

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(
      // 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:

dart
// e.g. in didChangeDependencies or after your data loads:
AdaptivePaneScope.maybeOf(context)?.title.value = product.name;
  • A static title is 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's lookupAppLocalizations(locale) or Intl.defaultLocale.
  • Restyle the strip with breadcrumbsBuilder (see Customizing the UI) or hide it with showBreadcrumbs: false.

Next: Redirects, sign-in & onExit.