responsive_reflow

Responsive & adaptive layout utilities for Flutter.

Pub Version Pub Points GitHub License GitHub Repo melos

Provides Material 3 window size-class breakpoints, spacing tokens, responsive builders, an adaptive navigation scaffold, input-density helpers, foldable awareness, and safe-area utilities — all built on official Flutter guidance. It reflows layouts by available width; it never pixel-scales like flutter_screenutil or responsive_framework's AutoScale.

Contents

Why reflow, not scale

  • Width drives layout, never device type. Decisions branch on the window width (and capabilities), so the same screen adapts on phones, foldables, tablets, desktop, and split-screen.
  • No flutter_screenutil (.w/.h) and no AutoScale. Scaling fakes responsiveness and breaks text scaling/accessibility. Use real constraints, Flexible/Expanded, Wrap, and LayoutBuilder.
Pixel scaling (.w/.h/.sp, AutoScale) Reflow (this package)
Bigger window shows The same UI, zoomed More content (more columns, extra panes, sidebar)
System text scaling (a11y) Often broken — fights .sp math Respected — text and layout reflow naturally
Desktop window resize Stretched/blurry proportions Layout re-classifies per width, stays crisp
Split-screen & foldables Wrong — scales to a size that isn't there Correct — branches on the actual window/constraints
Reusable components Coupled to a "design size" Adapt to parent constraints anywhere
Aligned with official guidance No Yes — M3 window size classes + Flutter adaptive docs

A design mockup is a starting point for one size class — not a fixed canvas to scale. This package gives you the primitives to express what should change between size classes.

Material 3 breakpoints

Window size class Width (logical px) ReflowBreakpoint Typical navigation
Compact < 600 ReflowBreakpoint.compact Bottom navigation bar
Medium 600 – 839 ReflowBreakpoint.medium Navigation rail (icons)
Expanded 840 – 1199 ReflowBreakpoint.expanded Full sidebar
Large 1200 – 1599 ReflowBreakpoint.large Full sidebar
Extra large ≥ 1600 ReflowBreakpoint.extraLarge Full sidebar

Thresholds are configurable — see Configurable breakpoints.

Features

  • BreakpointsReflowBreakpoint enum with comparison operators (>=, >, <=, <) and helpers (isCompact, isExpanded, showsSidebar, isMobileLayout, isDesktopLayout).
  • Context extensioncontext.reflow for ergonomic access: breakpoint checks, M3 margins/gutters, page padding, fold detection, input mode.
  • Configurable thresholdsReflowBreakpoints (immutable config) + ReflowBreakpointsTheme (inherited override for a subtree).
  • Spacing tokensReflowSpacing, ReflowEdgeInsets, ReflowGap, ReflowRadius.
  • Responsive buildersReflowResponsiveBuilder (window) and ReflowConstraintResponsiveBuilder (parent constraints), both with smaller-to-larger cascade fallback and ReflowBreakpointsTheme support.
  • Responsive values & widgetsReflowResponsiveValue<T>, ReflowResponsiveVisibility, ReflowResponsiveRowColumn.
  • Adaptive scaffoldReflowAdaptiveScaffold auto-switches bottom nav → rail → sidebar, with FAB, optional desktop app bar, animated transitions, and an optional secondaryBody split view (list–detail, fold-aware).
  • Constrained contentConstrainedContent, ReflowPageContent (max-width + responsive padding + safe area + scroll-position restoration).
  • Responsive gridReflowResponsiveGrid and ReflowResponsiveGrid.builder (lazy) with width-derived column counts, minColumns/maxColumns clamping, and per-breakpoint columns overrides.
  • Input densityReflowDensity / ReflowPointerModeDetector for touch-vs-pointer VisualDensity.
  • Policy & capabilitiesReflowPolicy (size-based decisions) and ReflowCapability (hardware/runtime facts).
  • Foldable awarenessReflowDisplayFeatures (hinge/fold detection).
  • Safe area & insetsReflowSafeArea, ReflowInsets.

Which tool do I reach for?

I want to… Use
Switch a whole screen's layout by window size ReflowResponsiveBuilder
Adapt a reusable component to its own space ReflowConstraintResponsiveBuilder
Pick a value (columns, padding, flex) per breakpoint ReflowResponsiveValue<T>
Quickly check the size class in build context.reflow.isCompact, .atLeast(…)
Show/hide something above or below a breakpoint ReflowResponsiveVisibility
Stack on phones, sit side-by-side on wider windows ReflowResponsiveRowColumn
App shell: bottom bar → rail → sidebar ReflowAdaptiveScaffold
List–detail two-pane layout on wide windows ReflowAdaptiveScaffold(secondaryBody: …)
Stop content stretching on ultra-wide displays ConstrainedContent / ReflowPageContent
A grid that grows columns with width ReflowResponsiveGrid
Consistent spacing without magic numbers ReflowSpacing / ReflowGap / ReflowEdgeInsets
Page margins that follow the M3 spec context.reflow.margin / .pagePadding
Denser UI for mouse users ReflowDensity / ReflowPointerModeDetector
Avoid rendering under a foldable's hinge ReflowDisplayFeatures (or secondaryBody, which is fold-aware)

Usage

import 'package:responsive_reflow/responsive_reflow.dart';

Responsive builder

ReflowResponsiveBuilder(
  compact: (context) => MobileLayout(),
  expanded: (context) => DesktopLayout(),
)

Context extension

final reflow = context.reflow;

if (reflow.isCompact) { /* … */ }
if (reflow.atLeast(ReflowBreakpoint.medium)) { /* … */ }

Padding(padding: reflow.pagePadding, child: content);      // breakpoint-scaled
SizedBox(width: reflow.gutter);                            // M3 16/24 gutter
if (reflow.hasVerticalFold) { /* two-pane layout */ }

Responsive value

final columns = const ReflowResponsiveValue<int>(
  compact: 1,
  medium: 2,
  expanded: 3,
).resolve(context);

Responsive visibility & row/column

ReflowResponsiveVisibility(
  visibleFrom: ReflowBreakpoint.expanded,
  child: SecondaryPanel(),
)

ReflowResponsiveRowColumn(
  rowFrom: ReflowBreakpoint.medium,
  spacing: ReflowSpacing.lg,
  children: [LabelField(), ValueField()],
)

Spacing tokens

Padding(padding: ReflowEdgeInsets.allLg, child: content)
Column(children: [widget1, ReflowGap.verticalSm, widget2])

Adaptive scaffold

ReflowAdaptiveScaffold(
  destinations: const [
    ReflowDestination(icon: Icons.home, label: 'Home'),
    ReflowDestination(icon: Icons.calendar_month, label: 'Schedule'),
  ],
  currentIndex: navigationShell.currentIndex,
  onDestinationSelected: navigationShell.goBranch,
  body: navigationShell,
  floatingActionButton: const FloatingActionButton(...),
)

Split view (list–detail)

secondaryBody appears next to body on expanded+ windows and is hidden on compact/medium. When a foldable's vertical hinge splits the window, the panes sit on either side of the fold automatically.

ReflowAdaptiveScaffold(
  destinations: destinations,
  currentIndex: index,
  onDestinationSelected: onSelect,
  body: InboxList(),
  secondaryBody: MessageDetail(),
  bodyRatio: 0.4, // 40% list, 60% detail
)

Page content

ReflowPageContent(
  maxWidth: 1000,
  child: Column(children: [...]),
)

Responsive grid (lazy)

ReflowResponsiveGrid.builder(
  maxItemWidth: 300,
  itemCount: items.length,
  itemBuilder: (context, i) => ItemCard(items[i]),
)

Clamp or fix the column count when you need tighter control:

// Never fewer than 2, never more than 4 columns:
ReflowResponsiveGrid(
  maxItemWidth: 300,
  minColumns: 2,
  maxColumns: 4,
  children: cards,
)

// Explicit columns per breakpoint:
ReflowResponsiveGrid(
  columns: const ReflowResponsiveValue(compact: 1, medium: 2, expanded: 4),
  children: cards,
)

Input density

Theme(
  data: Theme.of(context).copyWith(visualDensity: ReflowDensity.density),
  child: child,
)

Configurable breakpoints

ReflowBreakpointsTheme(
  breakpoints: const ReflowBreakpoints(medium: 560, expanded: 900),
  child: app,
)

Recipes

Real-world wiring for the widgets above. The go_router snippets are illustrative — this package has no dependency on go_router; it works with any routing solution (or none) because the scaffold only needs an index and a callback.

Every recipe here is also available as a runnable app in example/: example/lib/main.dart (basics, no router), example/lib/go_router_app/main.dart (deep-linkable adaptive shell), and example/lib/auto_route_app/main.dart (AutoTabsRouter).

go_router: adaptive shell with StatefulShellRoute

ReflowAdaptiveScaffold was designed to be the shell of a StatefulShellRoute.indexedStack. Each branch keeps its own navigator stack and scroll state; the scaffold swaps navigation chrome per breakpoint around the shell.

final router = GoRouter(
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) => AppShell(
        navigationShell: navigationShell,
      ),
      branches: [
        StatefulShellBranch(routes: [
          GoRoute(path: '/home', builder: (_, __) => const HomePage()),
        ]),
        StatefulShellBranch(routes: [
          GoRoute(path: '/schedule', builder: (_, __) => const SchedulePage()),
        ]),
        StatefulShellBranch(routes: [
          GoRoute(path: '/settings', builder: (_, __) => const SettingsPage()),
        ]),
      ],
    ),
  ],
);

class AppShell extends StatelessWidget {
  const AppShell({super.key, required this.navigationShell});

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return ReflowAdaptiveScaffold(
      destinations: const [
        ReflowDestination(icon: Icons.home_outlined, label: 'Home'),
        ReflowDestination(icon: Icons.calendar_month_outlined, label: 'Schedule'),
        ReflowDestination(icon: Icons.settings_outlined, label: 'Settings'),
      ],
      currentIndex: navigationShell.currentIndex,
      onDestinationSelected: (index) => navigationShell.goBranch(
        index,
        // Re-tapping the active destination resets that branch to its root.
        initialLocation: index == navigationShell.currentIndex,
      ),
      body: navigationShell,
    );
  }
}

Notes:

  • The scaffold is stateless with respect to navigation — it only reflects currentIndex and reports taps. All navigation state lives in the router.
  • Because each branch is preserved by indexedStack, switching bottom bar ↔ rail ↔ sidebar on window resize does not lose branch state.
  • Give branch pages a ReflowPageContent root so their content stays readable when the window is wide.

Dashboards often mix primary destinations (shell branches) with secondary pages that live inside a branch (profile, help, …). Use ReflowAdaptiveScaffold.sectioned: branches participate in selection and the compact bottom bar, links just navigate.

ReflowAdaptiveScaffold.sectioned(
  sections: [
    const ReflowNavSection(
      title: 'MAIN',
      items: [
        ReflowNavItem.branch(
          icon: Icons.dashboard_outlined,
          label: 'Dashboard',
          branchIndex: 0,
          showInBottomBar: true,
        ),
        ReflowNavItem.branch(
          icon: Icons.bar_chart_outlined,
          label: 'Reports',
          branchIndex: 1,
          showInBottomBar: true,
        ),
      ],
    ),
    ReflowNavSection(
      title: 'ACCOUNT',
      items: [
        ReflowNavItem.link(
          icon: Icons.person_outline,
          label: 'Profile',
          // Highlight the link when its page is the current location.
          selected: GoRouterState.of(context).uri.path == '/settings/profile',
          onTap: () => context.go('/settings/profile'),
        ),
      ],
    ),
  ],
  currentIndex: navigationShell.currentIndex,
  onDestinationSelected: navigationShell.goBranch,
  body: navigationShell,
)

Notes:

  • Only branch items with showInBottomBar: true appear in the compact bottom bar; links never do. If nothing opts in, the scaffold falls back to all branch items (and hides the bar entirely if fewer than two exist).
  • Derive a link's selected: from the current location so the sidebar highlight follows deep links and back navigation, not just taps.

Adaptive list–detail (route on phones, pane on desktop)

The canonical adaptive pattern: selecting an item pushes a route on compact windows but fills the secondary pane on expanded+ windows. Keep the selection in state; branch in the tap handler.

class InboxScreen extends StatefulWidget {
  const InboxScreen({super.key});

  @override
  State<InboxScreen> createState() => _InboxScreenState();
}

class _InboxScreenState extends State<InboxScreen> {
  Message? _selected;

  @override
  Widget build(BuildContext context) {
    return ReflowAdaptiveScaffold(
      destinations: destinations,
      currentIndex: 0,
      onDestinationSelected: onSelect,
      body: MessageList(
        selected: _selected,
        onMessageTap: (message) {
          if (context.reflow.isDesktopLayout) {
            // Wide window → show it in the detail pane.
            setState(() => _selected = message);
          } else {
            // Phone-sized window → navigate to a full detail page.
            context.push('/inbox/${message.id}');
          }
        },
      ),
      secondaryBody: _selected == null
          ? const EmptyDetailPlaceholder()
          : MessageDetail(message: _selected!),
      bodyRatio: 0.4, // 40% list, 60% detail
    );
  }
}

Notes:

  • Also register the /inbox/:id route — it serves compact windows and makes detail pages deep-linkable on every size.
  • secondaryBody is automatically hidden below expanded, and on foldables the panes split at the hinge instead of bodyRatio.
  • If the user resizes from compact to expanded while a pushed detail page is open, pop it and promote the selection to the pane (listen to the breakpoint in didChangeDependencies or compare in build).

Multiple shells (auth vs app)

Different areas of an app can use different shells. Only the signed-in area needs adaptive navigation:

final router = GoRouter(
  routes: [
    // Auth flow: plain pages, no navigation chrome.
    GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
    GoRoute(path: '/signup', builder: (_, __) => const SignupPage()),

    // Signed-in area: adaptive shell.
    StatefulShellRoute.indexedStack(
      builder: (context, state, shell) => AppShell(navigationShell: shell),
      branches: [/* … */],
    ),
  ],
);

Nested shells also work: a branch page can host its own TabBar or a nested StatefulShellRouteReflowAdaptiveScaffold only owns the outer chrome (bottom bar / rail / sidebar) and never touches inner navigators. Wrap inner layouts in ReflowConstraintResponsiveBuilder so they adapt to the space that remains after the sidebar takes its width.

Other routers

The scaffold needs only currentIndex + onDestinationSelected, so any state-driven approach works:

  • Navigator 1.0 / no router — keep an index in state and swap bodies (see example/lib/main.dart):

    ReflowAdaptiveScaffold(
      destinations: destinations,
      currentIndex: _index,
      onDestinationSelected: (i) => setState(() => _index = i),
      body: IndexedStack(index: _index, children: pages),
    )
    
  • auto_route — wrap with AutoTabsRouter and map tabsRouter.activeIndexcurrentIndex and tabsRouter.setActiveIndexonDestinationSelected.

  • beamer — use a BeamerDelegate per tab and drive currentIndex from the active delegate, calling beamer.update() on selection.

Smaller building blocks

ReflowPointerModeDetector — install once at the root via MaterialApp.builder so the whole app densifies for mouse users:

MaterialApp.router(
  routerConfig: router,
  builder: (context, child) => ReflowPointerModeDetector(
    builder: (context, mode) => Theme(
      data: Theme.of(context).copyWith(
        visualDensity: ReflowDensity.densityFor(mode),
      ),
      child: child!,
    ),
  ),
)

ReflowPolicy — size-based decisions outside the widget tree (blocs, controllers, tests) without importing widget code:

const policy = ReflowPolicy();
if (policy.shouldUseMultiPane(windowWidth)) { /* preload detail data */ }

// In tests: verify decisions at exact widths, no widget pumping needed.
expect(policy.shouldUseSidebar(840), isTrue);

ReflowInsets — targeted MediaQuery lookups that only rebuild on the relevant change:

// Hide the FAB while the keyboard is open:
floatingActionButton:
    ReflowInsets.keyboardVisible(context) ? null : const AddButton(),

ReflowSafeArea — wrap the body, not the whole Scaffold, so the app bar keeps managing its own insets:

Scaffold(
  appBar: AppBar(title: const Text('Title')),
  body: ReflowSafeArea(top: false, child: content),
)

ReflowDisplayFeatures — manual hinge handling when you are not using secondaryBody:

final fold = ReflowDisplayFeatures.verticalFold(context);
if (fold != null) {
  // Build your own two-pane layout on either side of fold.bounds.
}

Migrating from flutter_screenutil

There is no 1:1 mapping — and that's the point. Replace scaled dimensions with tokens, constraints, and breakpoint decisions:

ScreenUtil habit Reflow replacement
ScreenUtilInit(designSize: …) Delete it. No design-size coupling.
16.w / 16.h padding ReflowSpacing.lg, ReflowEdgeInsets.allLg, or context.reflow.pagePadding
14.sp font sizes Plain TextStyle(fontSize: 14) / theme text styles — let system text scaling work
0.5.sw (half screen width) Expanded/Flexible flex factors, or FractionallySizedBox
Fixed card width 160.w ReflowResponsiveGrid(maxItemWidth: 180) — columns derive from width
ScreenUtil().screenWidth > 600 context.reflow.atLeast(ReflowBreakpoint.medium)
One scaled layout for all sizes ReflowResponsiveBuilder(compact: …, expanded: …)

Migration strategy: work screen by screen. First delete scaling and let the layout break, then fix each break with the right primitive from the cheat sheet — usually Expanded, a spacing token, a max-width wrapper, or a breakpoint branch.

Best practices

  • Screens branch on the window, components branch on constraints. Use ReflowResponsiveBuilder for pages and ReflowConstraintResponsiveBuilder inside reusable widgets so they also work in dialogs, panes, and split views.
  • Don't branch on Platform.isX or "is tablet" for layout. A desktop window can be phone-sized and a tablet can be split-screened. Sizes, not devices.
  • Never lock orientation. It letterboxes on foldables and breaks large format requirements.
  • Let text scale. Don't tie font sizes to screen width; verify layouts at 1.3×+ text scale — reflow-based layouts survive this, scaled ones don't.
  • Constrain reading widths. Wrap long-form content in ConstrainedContent/ReflowPageContent so lines stay readable on desktop.
  • Prefer lazy grids/lists (ReflowResponsiveGrid.builder) for large collections.
  • Test by resizing. Run on desktop and drag the window through all breakpoints; that one habit catches most adaptive bugs.

References

License

MIT — see LICENSE.

Libraries

responsive_reflow
Responsive & Adaptive Layout Utilities