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 widthBandWhat 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

  1. Stack [products]: list on the left, the placeholder on the right.
  2. pushNamed('/products/1') → [products, 1]: the detail fills the right column.
  3. Push reviews → [products, 1, reviews]: everything slides one column left; the detail is now on the left, reviews on the right.
  4. 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.

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