adaptive_layout_kit

Responsive Flutter layouts for resizable windows, tablets, and constrained containers. Use configurable breakpoints with box widgets, native slivers, responsive values, and column layouts.

Build compact or split-pane interfaces without imposing a visual theme or state management framework. The package depends only on the Flutter SDK.

Breakpoints and sizing

Breakpoints use the width available to the layout, in logical pixels.

Breakpoint Default width
Breakpoint.xs Below 640
Breakpoint.sm 640 ≤ width < 768
Breakpoint.md 768 ≤ width < 1024
Breakpoint.lg 1024 ≤ width < 1280
Breakpoint.xl 1280 and above

Place a responsive builder at the window's content boundary for a window-wide decision, or inside a constrained panel for a decision based on that panel. Vertical sliver builders use the available cross-axis extent. Horizontal sliver builders use the viewport's main-axis extent, so the decision still follows width.

ResponsiveStrategy.width is the default: a narrow desktop window receives the same breakpoint as any other area of that width. ResponsiveStrategy.adaptive also limits the breakpoint in compact Android and iOS windows. By default, a window with a shortest side below 600 logical pixels is capped at sm, helping keep a compact mobile composition when the window rotates. Desktop layouts continue to follow their available width.

On ResponsiveLayout and ResponsiveGrid, use mobileMaxShortestSide and mobileMaxBreakpoint to configure that policy. The threshold describes the app window, not the physical device.

Configure thresholds consistently across the layouts that share them:

const breakpoints = ResponsiveBreakpoints(
  sm: 600,
  md: 840,
  lg: 1200,
  xl: 1600,
);

Share layout policy

Set a policy once for an app or a subtree. Explicit widget options override the nearest scope; omitting the scope preserves the default width-based behavior.

ResponsiveLayoutScope(
  data: const ResponsiveLayoutData(
    breakpoints: ResponsiveBreakpoints(sm: 600, md: 840, lg: 1200, xl: 1600),
  ),
  child: const CatalogBody(),
)

A nested scope replaces the policy. To override only some values, derive its data with ResponsiveLayoutScope.of(context).copyWith(...). Box builders, sliver builders, grids, and visibility widgets share this policy. Size-class names remain xs, sm, md, lg, and xl; their thresholds are configurable.

Choose a box layout

Import the public library:

import 'package:adaptive_layout_kit/adaptive_layout_kit.dart';

Provide an xs layout and override larger breakpoints as needed. An omitted layout uses the closest supplied smaller layout.

ResponsiveLayout(
  xs: const CompactCatalog(),
  md: const TwoPaneCatalog(),
  xl: const ExpandedCatalog(),
)

Use ResponsiveLayout.builder when the structure stays similar:

ResponsiveLayout.builder(
  builder: (context, constraints, breakpoint) {
    final padding = const ResponsiveValue<double>(
      xs: 16,
      md: 24,
      lg: 32,
    ).resolve(breakpoint);

    return Padding(
      padding: EdgeInsets.all(padding),
      child: const CatalogBody(),
    );
  },
)

ResponsiveValue<T> resolves values using the same nearest-smaller-breakpoint fallback. Use it for spacing, counts, dimensions, or other layout choices.

For a compact mobile landscape override, provide mobileLandscape with strategy: ResponsiveStrategy.adaptive on ResponsiveLayout.

Compose slivers

Choose the constructor that matches the callback's output:

Constructor Callback returns Placement
ResponsiveLayout.builder Box widget Box layout
ResponsiveLayout.sliverBuilder One sliver CustomScrollView.slivers
ResponsiveLayout.sliverBoxBuilder Box widget CustomScrollView.slivers
ResponsiveLayout.sliversBuilder List of slivers CustomScrollView.slivers

Sliver callbacks receive SliverConstraints. sliverBoxBuilder adapts a normal widget for a sliver parent; sliversBuilder groups its slivers with SliverMainAxisGroup.

A pinned header inside sliversBuilder stays within that group's scroll extent. Keep it outside the group if it must remain pinned beyond the group.

Box builders require a bounded width. Inside a Row, use a constrained child such as Expanded or SizedBox. A box returned by sliverBoxBuilder receives unbounded space along the scroll axis, like a SliverToBoxAdapter; give flex layouts a suitable bound before using Expanded in that direction.

CustomScrollView(
  slivers: [
    ResponsiveLayout.sliverBoxBuilder(
      builder: (context, constraints, breakpoint) {
        return const CatalogHeading();
      },
    ),
    ResponsiveLayout.sliverBuilder(
      builder: (context, constraints, breakpoint) {
        return SliverPadding(
          padding: EdgeInsets.all(breakpoint == Breakpoint.xs ? 16 : 24),
          sliver: const CatalogSliverList(),
        );
      },
    ),
  ],
)

Arrange columns

ResponsiveGrid eagerly lays out a small collection, such as a form or a group of settings. Item spans follow the nearest supplied smaller breakpoint (or the smallest defined span when no smaller one exists). Each child keeps its own height; this wrapping layout does not stretch cards to equal row heights.

ResponsiveGrid(
  columns: 12,
  gap: 16,
  children: [
    ResponsiveGridItem(
      span: {Breakpoint.xs: 12, Breakpoint.md: 6},
      child: const NameField(),
    ),
    ResponsiveGridItem(
      span: {Breakpoint.xs: 12, Breakpoint.md: 6},
      child: const EmailField(),
    ),
  ],
)

Use ResponsiveSliverGrid.builder for lazily built scrolling content:

ResponsiveSliverGrid.builder(
  itemCount: items.length,
  columns: const ResponsiveValue<int>(xs: 2, md: 3, lg: 4, xl: 5),
  mainAxisSpacing: 16,
  crossAxisSpacing: 16,
  childAspectRatio: 1,
  padding: const EdgeInsets.all(16),
  itemBuilder: (context, index) => CatalogCard(item: items[index]),
)

Use mainAxisExtent when items need a fixed extent along the scroll direction. The grid resolves its breakpoint after its own padding is applied.

For an approximate item width instead of fixed breakpoint counts, use targetCrossAxisExtent:

ResponsiveSliverGrid.builder(
  itemCount: items.length,
  targetCrossAxisExtent: 180,
  minColumns: 1,
  maxColumns: 6,
  crossAxisSpacing: 16,
  mainAxisSpacing: 16,
  padding: const EdgeInsets.all(16),
  itemBuilder: (context, index) => CatalogCard(item: items[index]),
)

The count is rounded to the nearest target count after padding and spacing, then clamped to the supplied limits. This is an approximate size, not a hard minimum or maximum item extent. The target is width for vertical grids and height for horizontal grids. Target sizing replaces columns and uses the actual local cross-axis extent rather than the breakpoint or mobile cap.

resolveResponsiveGridCount(crossAxisExtent: ..., targetItemExtent: ..., spacing: ..., minCount: ..., maxCount: ...) exposes the same calculation for custom delegates and paginated grids that own their scrolling implementation.

When combining indexed lists or grids in one scroll view, set each grid's semanticIndexOffset to the number of preceding indexed children. Use semanticIndexCallback to omit decorative items or assign contiguous local indices, and addSemanticIndexes: false if the children provide their own indexing. The outer scroll view owns the overall semanticChildCount.

Show content by breakpoint

ResponsiveVisibility(
  visible: const ResponsiveValue<bool>(xs: false, md: true),
  maintainState: true,
  child: const FilterPanel(),
)

Use ResponsiveSliverVisibility(visible: ..., sliver: ...) in a sliver slot. Hidden content takes no layout or scroll space and does not receive focus or accessibility exposure. State retention is opt-in for these widgets; hidden tickers pause, but media, streams, and network activity remain app-owned.

Compact and resizable panes

ResponsivePaneLayout shows two panes when its local width can accommodate their minimum widths and divider. Otherwise it shows compactPane. It requires bounded width and height; place it in a page body, Expanded, or SizedBox, not directly in an unbounded scrolling column.

ResponsivePaneLayout(
  minPrimaryExtent: 280,
  minSecondaryExtent: 360,
  expandedMinWidth: 840,
  compactPane: ResponsivePane.primary,
  resizable: true,
  dividerExtent: 12,
  divider: const ColoredBox(color: Color(0xffdddddd)),
  resizeSemanticsLabel: 'Resize list and details',
  primary: const ItemList(),
  secondary: const ItemDetails(),
)

An optional ResponsivePaneController stores the preferred primary fraction:

final paneController = ResponsivePaneController(primaryFraction: 0.35);
// Supply controller: paneController to ResponsivePaneLayout.
// Update it from another control:
paneController.primaryFraction = 0.5;
// Dispose it when its owning State is disposed.

The preference is retained when minimum widths clamp the rendered split, the window becomes compact, or a hardware hinge determines the layout. An optional expandedMinWidth can delay expansion but never override the minimum widths. The primary pane follows reading direction, including RTL.

For a list with a preferred pixel width and a flexible detail pane:

ResponsivePaneLayout(
  minPrimaryExtent: 280,
  preferredPrimaryExtent: 340,
  maxPrimaryExtent: 380,
  minSecondaryExtent: 360,
  resizable: true,
  dividerExtent: 1,
  dividerHitExtent: 32,
  divider: const ColoredBox(color: Color(0xffdddddd)),
  primary: const ItemList(),
  secondary: const ItemDetails(),
)

preferredPrimaryExtent applies until the first user resize when the layout owns its controller. An explicit controller takes precedence from the start; removing it preserves its last fraction. Pane minimums and maxPrimaryExtent always constrain a software split. Compact and hardware-separated layouts use their available region instead of these preferred and maximum pixel widths.

dividerExtent reserves layout space and sizes the visible divider. dividerHitExtent can provide a wider drag target without increasing that space. The interaction area is centered on the divider, clipped to the usable region, and never narrower than the visual extent. Controls immediately beside the divider should leave room for this interaction area. Omitting it preserves the original target size.

Software dividers support dragging, keyboard arrows, Home/End, and accessibility increase/decrease actions. Localize resizeSemanticsLabel in your app. Supply dividerBuilder: (context, focused, hovered) => ... instead of divider to provide focus and hover feedback matching your design system. No colors, icons, navigation controls, or routes are imposed.

Both pane slots remain mounted by default, preserving their state across compact, expanded, and folded arrangements while their type and key stay stable. Hidden panes cannot receive focus, are excluded from semantics, and have paused tickers. Set maintainState: false to dispose hidden content. Retained widgets still consume memory, and this does not suspend audio, video, or background tasks. The app decides which pane to show in compact mode and provides its own navigation/back behavior.

Set lazyMount: true to delay mounting a pane until it first becomes visible. With the default maintainState: true, it remains mounted afterward. With maintainState: false, hidden content is disposed regardless of lazy mounting. Enabling lazy mounting does not discard already retained content. This controls widget mounting, not the evaluation of constructor arguments; keep expensive initialization inside the pane's lifecycle rather than its constructor.

Foldable and dual-screen regions

ResponsiveDisplayRegions resolves geometry from Flutter DisplayFeature data, not model names, screen diagonals, or guessed hinge positions. It supports:

  • Vertical hinges and half-open book folds.
  • Horizontal hinges and half-open tabletop folds.
  • Zero-thickness half-open folds, multiple separators, and inset containers.
  • Separate metadata for cutouts and partial occlusions.

Use a Rect describing the layout's bounds in the same coordinate system as the supplied display features. The returned regions are local to that rect.

final regions = ResponsiveDisplayRegions.fromMediaQuery(
  MediaQuery.of(context),
  bounds: containerBoundsInMediaQuery,
);

ResponsivePaneLayout(
  displayRegions: regions,
  primary: const ItemList(),
  secondary: const ItemDetails(),
)

containerBoundsInMediaQuery must describe the actual container, including its origin after app bars, padding, safe areas, or split-window offsets. Its size must match the pane layout constraints. Omit bounds only when the layout occupies the whole MediaQuery viewport. Recompute regions when the window or reported features change; cached physical screen dimensions are not sufficient.

The example's reported display features page demonstrates this with a full-viewport route, a fixed-height app bar, safe-area padding, and content padding. It captures the original window MediaQuery before those widgets remove padding, then resolves reported features against the inset container's actual constraints. Open this page as a route, not inside another inset layout. If the platform reports no display features, the same page falls back to ordinary responsive panes.

Pane children receive local constraints; their inherited MediaQuery still describes the surrounding viewport. Use LayoutBuilder or a responsive box builder for pane-local dimensions instead of treating MediaQuery.sizeOf as the pane size. Keyboard insets and safe areas remain application-owned.

Hardware-separated regions override the software split and cannot be dragged. If more than two regions exist, the pane layout uses the two largest usable regions without spanning a separator. A fully obstructed area exposes neither pane. regionFor(anchor) selects the nearest local region for custom layouts.

Only full-span hinges and half-open folds divide the bounds. A flat crease does not force a split. Cutouts and partial obstructions are exposed through occludingFeatures; the pane layout does not automatically move individual controls around them. Use appropriate SafeArea, keyboard insets, and explicit control positioning. Pass anchor points in the coordinate system expected by the app's dialog, menu, or overlay API rather than reusing local points blindly.

Platform support

Window and container resizing work on every Flutter platform. Fold posture and hinge avoidance require actual feature metadata. Flutter currently documents DisplayFeature population on Android; do not assume every platform or device reports it. When no features are available, ordinary width-based layout still works, but cannot infer a physical hinge or posture.

The package does not include native sensor code or an iOS reserved-region bridge. A platform integration can supply DisplayFeature values to ResponsiveDisplayRegions.resolve. In particular, iPhone Duo-specific fold and camera-region reporting needs a compatible Flutter engine or a separately validated native integration; responsive resizing alone is not equivalent to verified posture support.

See Flutter display features and Apple adaptive layout guidance.

Bound the content width

ResponsiveContent(
  maxWidth: 1200,
  padding: const EdgeInsets.symmetric(horizontal: 24),
  alignment: Alignment.topCenter,
  child: const CatalogBody(),
)

Keep state stable during resizing

The app owns field state, selection, and scroll state. Replacing a layout with a different widget tree can dispose stateful children. For a form that changes direction, keep one Flex and a stable child structure, changing only its direction. Keep controllers in the surrounding State when their values must outlive a layout change.

The example demonstrates window and container sizing, both responsive strategies, box and native sliver callbacks, eager and lazy grids, stable form controllers, resizable panes, and simulated book/tabletop folds. Simulations exercise layout behavior; they are not a substitute for physical-device checks.

Libraries

adaptive_layout_kit
Responsive layout primitives for box and sliver widget trees.