lazy_wrap 1.1.0
lazy_wrap: ^1.1.0 copied to clipboard
A Flutter widget that mimics Wrap layout behavior with lazy rendering and zero layout jumps.
lazy_wrap #
A performant Flutter widget that combines the layout of Wrap with lazy rendering. Perfect for large lists with variable-sized items.
✨ Features #
| Feature | Fixed Mode | Dynamic Mode |
|---|---|---|
| Lazy rendering | ✅ | ✅ |
| Zero layout jumps | ✅ | ✅ (Offstage measurement) |
| Horizontal scroll | ✅ | ✅ |
| Fade-in animation | ❌ | ✅ |
| Custom loading indicator | ❌ | ✅ |
📦 Installation #
dependencies:
lazy_wrap: ^1.1.0
🆕 What's New in 1.1.0 #
LazyWrap.dynamicnow supports engine selection viaLazyWrapEngine.- New opt-in experimental engine:
LazyWrapEngine.sliverV2. - Fix for dynamic mode when
itemCountshrinks while measurements are pending (prevents stale queued builds/out-of-range indices). - Default behavior remains unchanged:
offstageV1is still the default engine (no breaking changes).
🧭 Engine Status (Dynamic Mode) #
| Engine | Status | Recommended for |
|---|---|---|
offstageV1 |
Stable (default) | Production / general use |
sliverV2 |
Experimental opt-in | Controlled trials / benchmarking |
Notes:
sliverV3exists as an internal deep exploration and is not part of the public API contract/recommended release path.
🥊 Why lazy_wrap (vs Wrap / ListView.builder) #
This package targets a specific gap in Flutter UI primitives:
Wrap: gives you a 2D wrap layout, but it is not lazy (builds eagerly).ListView.builder: gives you lazy rendering, but it is 1D (not a wrap layout).lazy_wrap: gives you a wrap-like 2D layout + lazy rendering, which is the missing combination for large chip/tag/card collections.
Quick positioning:
| Widget | 2D Wrap-like layout | Lazy rendering | Best for |
|---|---|---|---|
Wrap |
✅ | ❌ | Small collections / simple UIs |
ListView.builder |
❌ (1D) | ✅ | Feeds/lists (1D) |
LazyWrap.dynamic |
✅ | ✅ | Large wrap-like collections |
LazyWrap.fixed |
✅ | ✅ | Large uniform grids/cards |
📊 Benchmark Snapshot (lazy_wrap vs Wrap vs ListView.builder) #
Local sample benchmark (Linux profile mode, 2000 chip items, single run):
| Implementation | p95_build_ms | p95_raster_ms | % jank | peak_memory_mb | TTI ms |
|---|---|---|---|---|---|
lazy_wrap.dynamic (2D lazy) |
0.76 |
7.54 |
3.99% |
174.08 |
231.48 |
SingleChildScrollView + Wrap (2D eager) |
20.18 |
12.19 |
9.09% |
412.30 |
1158.74 |
ListView.builder (1D lazy) |
7.00 |
13.07 |
4.68% |
168.95 |
223.74 |
What this shows:
lazy_wrapdramatically outperforms eagerWrapon large wrap-like collections.ListView.builderremains a great choice for 1D lists (different problem).lazy_wrapis the better fit when you need wrap semantics + lazy rendering.
Benchmark caveats:
Wrapis a 2D eager baseline (same layout family, different rendering model).ListView.builderis a 1D lazy baseline (same rendering model, different layout family).- Results vary by device, widget complexity, and scroll pattern.
Re-run locally:
dart run benchmarks/run_chip_competitor_comparison.dart --device linux
Generated report/example:
benchmarks/results/experiments/20260222_071200Z_chip_competitor_compare_report.mdbenchmarks/results/experiments/20260222_071200Z_chip_competitor_compare.json
🚀 Usage #
Fixed Mode (Best Performance) #
Use when all items have the same size:
LazyWrap.fixed(
itemCount: 10000,
estimatedItemWidth: 120,
estimatedItemHeight: 100,
itemBuilder: (context, index) => ProductCard(index),
spacing: 8,
runSpacing: 8,
)
Dynamic Mode (Variable Sizes) #
Use when items have different sizes:
LazyWrap.dynamic(
itemCount: 10000,
itemBuilder: (context, index) => VariableCard(index),
spacing: 8,
runSpacing: 8,
// Optional customization
fadeInItems: true, // Smooth fade-in animation
fadeInDuration: Duration(milliseconds: 200),
batchSize: 50, // Items per batch
loadingBuilder: (ctx) => MyLoader(), // Custom loading indicator
)
Dynamic Mode (Engine Opt-In) #
LazyWrap.dynamic now supports selecting an engine:
LazyWrap.dynamic(
itemCount: 50000,
engine: LazyWrapEngine.sliverV2, // Experimental opt-in
itemWidthBuilder: (i) => 140 + ((i % 6) * 18),
itemHeightBuilder: (i) => 80 + ((i % 5) * 22),
itemBuilder: (context, index) => Card(child: Text('Card $index')),
)
Notes:
offstageV1remains the default and fully backward compatible.sliverV2is currently experimental and supports vertical scrolling only.- For
sliverV2,itemWidthBuilderanditemHeightBuilderare required. sliverV2is best evaluated with deterministic item sizes (benchmarks/trials).
Migration Guide (offstageV1 -> sliverV2 opt-in) #
No migration is required for existing users. This remains valid and unchanged:
LazyWrap.dynamic(
itemCount: 1000,
itemBuilder: (context, index) => Chip(label: Text('Tag $index')),
)
To evaluate sliverV2, add explicit engine selection + deterministic size
builders:
LazyWrap.dynamic(
itemCount: 1000,
engine: LazyWrapEngine.sliverV2,
itemWidthBuilder: (index) => 120 + (index % 4) * 12,
itemHeightBuilder: (index) => 48 + (index % 3) * 8,
itemBuilder: (context, index) => Card(child: Text('Item $index')),
)
Roll back is immediate by removing engine (or setting
engine: LazyWrapEngine.offstageV1).
Current limitations of sliverV2:
- Experimental quality level (recommended for benchmarking/adoption trials).
- Vertical scroll only (
scrollDirection: Axis.vertical). - Requires
itemWidthBuilderanditemHeightBuilder. batchSize/loadingBuilderare currently not used bysliverV2.
🎯 When to Use Which #
| Scenario | Recommended |
|---|---|
| Grid of cards (same size) | LazyWrap.fixed |
| Tags/chips (variable width) | LazyWrap.dynamic (offstageV1) |
| Mixed content | LazyWrap.dynamic (offstageV1) |
| Engine experiments / A-B tests | LazyWrap.dynamic(engine: LazyWrapEngine.sliverV2) |
| Maximum performance | LazyWrap.fixed |
Practical rule:
- Start with
LazyWrap.fixedwhen possible. - Use
LazyWrap.dynamicwith default engine for variable-size production UIs. - Opt into
sliverV2only when you want to benchmark/test a specific screen.
🌀 Demo #

📋 API Reference #
Common Parameters #
| Parameter | Type | Default | Description |
|---|---|---|---|
itemCount |
int |
required | Total number of items |
itemBuilder |
Widget Function(BuildContext, int) |
required | Builds each item |
spacing |
double |
8 |
Horizontal space between items |
runSpacing |
double |
8 |
Vertical space between rows |
padding |
EdgeInsets |
zero |
Padding around content |
scrollDirection |
Axis |
vertical |
Scroll direction |
cacheExtent |
double |
300 |
Pre-render buffer in pixels |
Fixed Mode Only #
| Parameter | Type | Default | Description |
|---|---|---|---|
estimatedItemWidth |
double |
required | Width of each item |
estimatedItemHeight |
double |
required | Height of each item |
Dynamic Mode Only #
| Parameter | Type | Default | Description |
|---|---|---|---|
batchSize |
int |
50 |
OffstageV1: items loaded per batch (sliverV2: no-op actual) |
fadeInItems |
bool |
true |
Enable fade-in animation |
fadeInDuration |
Duration |
200ms |
Fade animation duration |
loadingBuilder |
Widget Function(BuildContext)? |
null |
OffstageV1 custom loading indicator (sliverV2: no-op actual) |
engine |
LazyWrapEngine |
offstageV1 |
Dynamic engine selection |
itemWidthBuilder |
double Function(int)? |
null |
Required when engine: sliverV2 |
itemHeightBuilder |
double Function(int)? |
null |
Required when engine: sliverV2 |
Dynamic Engine Contract Summary #
offstageV1- Full dynamic feature set (
fadeInItems, batching,loadingBuilder) - Default and backward-compatible path
- Full dynamic feature set (
sliverV2- Experimental opt-in path
- Vertical scrolling only
- Requires
itemWidthBuilder+itemHeightBuilder batchSizeandloadingBuilderare currently not used
☕ Support #
If this package helps you, consider supporting: