Tutorial

Customizing the UI

Restyle the columns, resize handle and breadcrumbs, and add overlay routes such as a photo viewer.

Use builders to replace structure, value parameters to tweak numbers. Theme colors still come from Theme.of(context). 1-column page transitions use Flutter's ThemeData.pageTransitionsTheme.

Viewport (SlidingPaneViewport / AdaptiveShellRoute)

ParameterDefaultRole
slideDuration280msColumn slide
slideCurve Curves.easeOutCubic Column slide
paneBuilder 2-col card / 1-col flat Wrap each column. index == panes.length is the empty right slot
resizeHandleBuilder rounded animated bar Widget Function(BuildContext context, bool isHovered) ; highlights with colorScheme.primary on hover / drag
resizeHandleWidth 4 (shell route) Hit-test width of the handle; required on SlidingPaneViewport
resizeHandleMargin — Insets the handle independently of its hit area
placeholder outline icon Shell-level empty right pane; AdaptiveBranch.placeholder wins if set

Default pane split: left fraction 0.4, minimum left / right fraction 0.35.

Apply it to the tutorial app

Start from the previous step's lib/main.dart and give its AdaptiveShellRoute these arguments (keep branches as they are). Every value here is a real parameter; the code is checked with flutter analyze.

dart
AdaptiveShellRoute(
  breakpoints: const LayoutBreakpoints(expandedMinWidth: 840),
  showBreadcrumbs: true,
  resizable: true,
  initialLeftPaneFraction: 0.4,
  placeholder: (context) => const Center(child: Text('Pick a product')),
  slideDuration: const Duration(milliseconds: 280),
  resizeHandleBuilder: (context, isHovered) {
    final colorScheme = Theme.of(context).colorScheme;
    return AnimatedContainer(
      duration: const Duration(milliseconds: 150),
      curve: Curves.easeInOut,
      height: double.infinity,
      margin: const EdgeInsets.symmetric(vertical: 8),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(8),
        color: isHovered ? colorScheme.primary : colorScheme.surfaceDim,
      ),
    );
  },
  breadcrumbsBuilder: (context, panes, onSelect) {
    return AdaptiveBreadcrumbs(
      panes: panes,
      onSelect: onSelect,
      height: 32,
      backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
    );
  },
  paneBuilder: (context, index, child) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
      child: KeyedSubtree(key: const Key('pane-flat'), child: child),
    );
  },
  builder: (context, shell, child) => AppShell(shell: shell, child: child),
  branches: [/* unchanged */],
)
ParameterDefaultRole
height32Strip height
paddinghorizontal 12Strip padding
backgroundColor surfaceContainerLow Strip color
itemBuilderInkWell + TextOne crumb
separatorBuilderchevronBetween crumbs
AdaptiveShellRoute.breadcrumbsBuilder AdaptiveBreadcrumbs Replace the whole strip (showBreadcrumbs still gates it)
escapePops true Escape calls maybePop

Bottom bar behavior

On medium / expanded, nested pages keep the host chrome. On compact, pages below the branch root hide the bottom bar by default (hidesBottomBarWhenPushed; set false to keep it). A bottom sheet on the tab root covers the bar when the shell uses AdaptiveShellChrome (useRootNavigator: false). Scaffold.bottomNavigationBar sits outside the branch navigator, so the same sheet leaves the bar visible.

Overlay routes (AdaptiveRoute)

ParameterDefaultRole
hidesBottomBarWhenPushed true Compact only: the pushed page covers the host bottom bar. Two panes on desktop are untouched
fullscreen false Root Navigator at every width, normal transition
fullscreenDialog false Root Navigator at every width as a Material fullscreen dialog
opaque true With transitionsBuilder: transparent photo viewer
barrierColor null With transitionsBuilder
barrierDismissible false Tap the barrier to pop
dart
// Add as a child of the ':id' route, next to 'reviews'.
AdaptiveRoute(
  path: 'photo',
  title: (_) => 'Photo',
  fullscreen: true,
  opaque: false,
  barrierColor: Colors.black,
  barrierDismissible: true,
  transitionDuration: const Duration(milliseconds: 200),
  transitionsBuilder: (context, animation, secondaryAnimation, child) {
    return FadeTransition(opacity: animation, child: child);
  },
  builder: (context, state) => const Center(child: Icon(Icons.image, size: 200)),
)

Open it from the detail page with router.pushNamed('/products/1/photo'). It covers the whole window at every width with a fade; tap the black barrier to close it.

Next: Reading state.