adaptive_layout_kit 0.2.0
adaptive_layout_kit: ^0.2.0 copied to clipboard
Responsive box and sliver layouts, scoped breakpoints, resizable panes with state retention, and foldable display-region geometry for Flutter.
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.