lazy_wrap 1.1.1 copy "lazy_wrap: ^1.1.1" to clipboard
lazy_wrap: ^1.1.1 copied to clipboard

A Flutter widget that mimics Wrap layout behavior with lazy rendering and zero layout jumps.

example/lib/main.dart

import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:lazy_wrap/lazy_wrap.dart';

void main() {
  runApp(const LazyWrapTestApp());
}

/// Interactive test application for the public [LazyWrap] API.
class LazyWrapTestApp extends StatelessWidget {
  /// Creates the test application.
  const LazyWrapTestApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'LazyWrap Test Lab',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6750A4),
          brightness: Brightness.light,
        ),
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFFD0BCFF),
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const _TestLabPage(),
    );
  }
}

class _TestLabPage extends StatelessWidget {
  const _TestLabPage();

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: const Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text('LazyWrap Test Lab'),
              Text(
                'Manual validation for large collections',
                style: TextStyle(fontSize: 12, fontWeight: FontWeight.normal),
              ),
            ],
          ),
          bottom: const TabBar(
            tabs: [
              Tab(icon: Icon(Icons.grid_view_rounded), text: 'Fixed'),
              Tab(
                icon: Icon(Icons.auto_awesome_mosaic_rounded),
                text: 'Dynamic',
              ),
            ],
          ),
        ),
        body: const TabBarView(
          children: [
            _FixedTestPage(),
            _DynamicTestPage(),
          ],
        ),
      ),
    );
  }
}

class _FixedTestPage extends StatefulWidget {
  const _FixedTestPage();

  @override
  State<_FixedTestPage> createState() => _FixedTestPageState();
}

class _FixedTestPageState extends State<_FixedTestPage>
    with AutomaticKeepAliveClientMixin {
  int _itemCount = 100000;
  Axis _axis = Axis.vertical;
  int _configurationId = 0;

  @override
  bool get wantKeepAlive => true;

  void _changeConfiguration(VoidCallback update) {
    setState(() {
      update();
      _configurationId++;
    });
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(
      children: [
        _ControlSurface(
          children: [
            _CountSelector(
              value: _itemCount,
              onChanged: (value) {
                _changeConfiguration(() => _itemCount = value);
              },
            ),
            _AxisSelector(
              value: _axis,
              onChanged: (value) {
                _changeConfiguration(() => _axis = value);
              },
            ),
            const _InfoChip(
              icon: Icons.speed_rounded,
              label: 'Exact fixed geometry',
            ),
          ],
        ),
        Expanded(
          child: ColoredBox(
            color: Theme.of(context).colorScheme.surfaceContainerLowest,
            child: LazyWrap.fixed(
              key: ValueKey('fixed-$_configurationId'),
              itemCount: _itemCount,
              estimatedItemWidth: 128,
              estimatedItemHeight: 88,
              scrollDirection: _axis,
              spacing: 10,
              runSpacing: 10,
              padding: const EdgeInsets.all(16),
              cacheExtent: 640,
              itemBuilder: (context, index) => _FixedTile(index: index),
            ),
          ),
        ),
      ],
    );
  }
}

class _DynamicTestPage extends StatefulWidget {
  const _DynamicTestPage();

  @override
  State<_DynamicTestPage> createState() => _DynamicTestPageState();
}

class _DynamicTestPageState extends State<_DynamicTestPage>
    with AutomaticKeepAliveClientMixin {
  final ScrollController _controller = ScrollController();
  final ValueNotifier<String> _status = ValueNotifier('Ready');

  int _itemCount = 10000;
  int _configurationId = 0;
  int _maxBuiltIndex = -1;
  int _runGeneration = 0;
  bool _fadeInItems = true;
  bool _aggressiveLoading = false;
  bool _isRunning = false;

  int get _batchSize => _aggressiveLoading ? 5000 : 100;
  int get _measureBatchSize => _aggressiveLoading ? 1000 : 20;
  double get _cacheExtent => _aggressiveLoading ? 640 : 400;

  @override
  bool get wantKeepAlive => true;

  @override
  void dispose() {
    _runGeneration++;
    _controller.dispose();
    _status.dispose();
    super.dispose();
  }

  void _changeConfiguration(VoidCallback update) {
    _stopStress(updateStatus: false);
    if (_controller.hasClients) {
      _controller.jumpTo(_controller.position.minScrollExtent);
    }
    setState(() {
      update();
      _configurationId++;
      _maxBuiltIndex = -1;
    });
    _status.value = 'Configuration reset';
  }

  void _jumpTo(double offset) {
    if (!_controller.hasClients) return;
    final position = _controller.position;
    _controller.jumpTo(
      offset.clamp(
        position.minScrollExtent,
        position.maxScrollExtent,
      ),
    );
  }

  void _jumpToTop() {
    if (!_controller.hasClients) return;
    _jumpTo(_controller.position.minScrollExtent);
    _status.value = 'Jumped to first loaded row';
  }

  void _jumpToBottom() {
    if (!_controller.hasClients) return;
    _jumpTo(_controller.position.maxScrollExtent);
    _status.value = 'Jumped to current loaded edge';
  }

  void _stopStress({bool updateStatus = true}) {
    _runGeneration++;
    if (_isRunning && mounted) {
      setState(() => _isRunning = false);
    }
    if (updateStatus) _status.value = 'Stress run stopped';
  }

  Future<void> _runRapidBounce() async {
    if (_isRunning) return;
    final generation = ++_runGeneration;
    setState(() => _isRunning = true);

    for (var step = 0; step < 30; step++) {
      if (!mounted || generation != _runGeneration) return;
      if (_controller.hasClients) {
        final position = _controller.position;
        _jumpTo(
          step.isEven ? position.maxScrollExtent : position.minScrollExtent,
        );
        _status.value = 'Rapid bounce ${step + 1}/30';
      }
      await WidgetsBinding.instance.endOfFrame;
    }

    if (!mounted || generation != _runGeneration) return;
    setState(() => _isRunning = false);
    _status.value = 'Rapid bounce complete — no blank frame expected';
  }

  Future<void> _loadToLastItem() async {
    if (_isRunning) return;
    final generation = ++_runGeneration;
    setState(() => _isRunning = true);
    var cycles = 0;
    final framesPerBatch = (_batchSize / _measureBatchSize).ceil() + 3;
    final maxCycles = ((_itemCount / _batchSize).ceil() * framesPerBatch) + 20;

    while (mounted &&
        generation == _runGeneration &&
        _maxBuiltIndex < _itemCount - 1 &&
        cycles < maxCycles) {
      if (_controller.hasClients) {
        final maxExtent = _controller.position.maxScrollExtent;
        _jumpTo(math.max(0, maxExtent - 1));
        await WidgetsBinding.instance.endOfFrame;
        if (!mounted || generation != _runGeneration) return;
        _jumpTo(_controller.position.maxScrollExtent);
      }

      cycles++;
      _status.value =
          'Loading edge • built through ${_maxBuiltIndex + 1} / $_itemCount';
      await WidgetsBinding.instance.endOfFrame;
    }

    if (!mounted || generation != _runGeneration) return;
    if (_controller.hasClients) {
      _jumpTo(_controller.position.maxScrollExtent);
    }
    setState(() => _isRunning = false);
    _status.value = _maxBuiltIndex >= _itemCount - 1
        ? 'Reached item $_itemCount — try Rapid bounce now'
        : 'Stopped at item ${_maxBuiltIndex + 1}; press Load to last again';
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(
      children: [
        _ControlSurface(
          children: [
            _CountSelector(
              value: _itemCount,
              onChanged: (value) {
                _changeConfiguration(() => _itemCount = value);
              },
            ),
            FilterChip(
              selected: _fadeInItems,
              avatar: const Icon(Icons.animation_rounded, size: 18),
              label: const Text('Fade'),
              onSelected: (value) {
                _changeConfiguration(() => _fadeInItems = value);
              },
            ),
            FilterChip(
              selected: _aggressiveLoading,
              avatar: const Icon(Icons.bolt_rounded, size: 18),
              label: const Text('Stress batches'),
              onSelected: (value) {
                _changeConfiguration(() => _aggressiveLoading = value);
              },
            ),
            OutlinedButton.icon(
              onPressed: _isRunning ? null : _jumpToTop,
              icon: const Icon(Icons.vertical_align_top_rounded),
              label: const Text('Top'),
            ),
            OutlinedButton.icon(
              onPressed: _isRunning ? null : _jumpToBottom,
              icon: const Icon(Icons.vertical_align_bottom_rounded),
              label: const Text('Current end'),
            ),
            FilledButton.tonalIcon(
              onPressed: _isRunning ? null : _loadToLastItem,
              icon: const Icon(Icons.download_for_offline_outlined),
              label: const Text('Load to last'),
            ),
            FilledButton.icon(
              onPressed: _isRunning ? _stopStress : _runRapidBounce,
              icon: Icon(
                _isRunning ? Icons.stop_rounded : Icons.swap_vert_rounded,
              ),
              label: Text(_isRunning ? 'Stop' : 'Rapid bounce'),
            ),
          ],
        ),
        _DynamicStatusBar(
          controller: _controller,
          status: _status,
          itemCount: _itemCount,
          batchSize: _batchSize,
          measureBatchSize: _measureBatchSize,
        ),
        Expanded(
          child: ColoredBox(
            color: Theme.of(context).colorScheme.surfaceContainerLowest,
            child: LazyWrap.dynamic(
              key: ValueKey('dynamic-$_configurationId'),
              itemCount: _itemCount,
              controller: _controller,
              batchSize: _batchSize,
              measureBatchSize: _measureBatchSize,
              cacheExtent: _cacheExtent,
              loadThreshold: _cacheExtent,
              fadeInItems: _fadeInItems,
              fadeInDuration: const Duration(milliseconds: 350),
              spacing: 8,
              runSpacing: 8,
              padding: const EdgeInsets.all(16),
              loadingBuilder: (context) => const Padding(
                padding: EdgeInsets.symmetric(vertical: 16),
                child: Center(
                  child: SizedBox.square(
                    dimension: 24,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  ),
                ),
              ),
              itemBuilder: (context, index) {
                if (index > _maxBuiltIndex) _maxBuiltIndex = index;
                return _DynamicTile(index: index);
              },
            ),
          ),
        ),
      ],
    );
  }
}

class _ControlSurface extends StatelessWidget {
  const _ControlSurface({required this.children});

  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return Material(
      color: Theme.of(context).colorScheme.surfaceContainer,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
        child: Wrap(
          spacing: 8,
          runSpacing: 8,
          crossAxisAlignment: WrapCrossAlignment.center,
          children: children,
        ),
      ),
    );
  }
}

class _CountSelector extends StatelessWidget {
  const _CountSelector({required this.value, required this.onChanged});

  static const values = [1000, 10000, 50000, 200000];

  final int value;
  final ValueChanged<int> onChanged;

  @override
  Widget build(BuildContext context) {
    return DropdownMenu<int>(
      key: const Key('item-count-selector'),
      initialSelection: value,
      width: 150,
      label: const Text('Items'),
      leadingIcon: const Icon(Icons.data_array_rounded),
      dropdownMenuEntries: values
          .map(
            (count) => DropdownMenuEntry<int>(
              value: count,
              label: _formatCount(count),
            ),
          )
          .toList(growable: false),
      onSelected: (next) {
        if (next != null && next != value) onChanged(next);
      },
    );
  }
}

class _AxisSelector extends StatelessWidget {
  const _AxisSelector({required this.value, required this.onChanged});

  final Axis value;
  final ValueChanged<Axis> onChanged;

  @override
  Widget build(BuildContext context) {
    return SegmentedButton<Axis>(
      segments: const [
        ButtonSegment(
          value: Axis.vertical,
          icon: Icon(Icons.swap_vert_rounded),
          label: Text('Vertical'),
        ),
        ButtonSegment(
          value: Axis.horizontal,
          icon: Icon(Icons.swap_horiz_rounded),
          label: Text('Horizontal'),
        ),
      ],
      selected: {value},
      onSelectionChanged: (selection) => onChanged(selection.first),
    );
  }
}

class _DynamicStatusBar extends StatelessWidget {
  const _DynamicStatusBar({
    required this.controller,
    required this.status,
    required this.itemCount,
    required this.batchSize,
    required this.measureBatchSize,
  });

  final ScrollController controller;
  final ValueNotifier<String> status;
  final int itemCount;
  final int batchSize;
  final int measureBatchSize;

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    return ColoredBox(
      color: colorScheme.surfaceContainerHigh,
      child: LayoutBuilder(
        builder: (context, constraints) {
          final statusText = ValueListenableBuilder<String>(
            valueListenable: status,
            builder: (context, message, _) => Text(
              message,
              maxLines: 1,
              overflow: TextOverflow.ellipsis,
              style: Theme.of(context).textTheme.bodySmall,
            ),
          );
          final metrics = AnimatedBuilder(
            animation: controller,
            builder: (context, _) {
              final pixels = controller.hasClients
                  ? controller.position.pixels
                  : 0.0;
              final maxExtent = controller.hasClients
                  ? controller.position.maxScrollExtent
                  : 0.0;
              return Text(
                '${_formatCount(itemCount)} • '
                'batch $batchSize/$measureBatchSize • '
                '${pixels.toStringAsFixed(0)} / '
                '${maxExtent.toStringAsFixed(0)} px',
                maxLines: 1,
                overflow: TextOverflow.ellipsis,
                style: Theme.of(context).textTheme.labelSmall,
              );
            },
          );

          return Padding(
            padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
            child: constraints.maxWidth < 600
                ? Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      statusText,
                      const SizedBox(height: 3),
                      SizedBox(width: double.infinity, child: metrics),
                    ],
                  )
                : Row(
                    children: [
                      Expanded(child: statusText),
                      const SizedBox(width: 12),
                      Flexible(child: metrics),
                    ],
                  ),
          );
        },
      ),
    );
  }
}

class _InfoChip extends StatelessWidget {
  const _InfoChip({required this.icon, required this.label});

  final IconData icon;
  final String label;

  @override
  Widget build(BuildContext context) {
    return Chip(
      avatar: Icon(icon, size: 18),
      label: Text(label),
    );
  }
}

class _FixedTile extends StatelessWidget {
  const _FixedTile({required this.index});

  final int index;

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    final color = Color.lerp(
      colorScheme.primaryContainer,
      colorScheme.tertiaryContainer,
      (index % 9) / 8,
    );
    return DecoratedBox(
      decoration: BoxDecoration(
        color: color,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Center(
        child: Text(
          '#$index',
          style: Theme.of(context).textTheme.titleSmall,
        ),
      ),
    );
  }
}

class _DynamicTile extends StatelessWidget {
  const _DynamicTile({required this.index});

  final int index;

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    final width = 84.0 + ((index % 7) * 16);
    final height = 42.0 + ((index % 4) * 10);
    final color = index.isEven
        ? colorScheme.secondaryContainer
        : colorScheme.tertiaryContainer;

    return Container(
      key: ValueKey('dynamic-item-$index'),
      width: width,
      height: height,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: color,
        borderRadius: BorderRadius.circular(14 + (index % 3) * 4),
        border: Border.all(color: colorScheme.outlineVariant),
      ),
      padding: const EdgeInsets.symmetric(horizontal: 10),
      child: Text(
        'Item $index',
        maxLines: 1,
        overflow: TextOverflow.ellipsis,
      ),
    );
  }
}

String _formatCount(int value) {
  if (value >= 1000) {
    final thousands = value / 1000;
    final digits = value % 1000 == 0 ? 0 : 1;
    return '${thousands.toStringAsFixed(digits)}K';
  }
  return '$value';
}
21
likes
150
points
226
downloads

Documentation

API reference

Publisher

verified publisherhensell.dev

Weekly Downloads

A Flutter widget that mimics Wrap layout behavior with lazy rendering and zero layout jumps.

Homepage

License

MIT (license)

Dependencies

flutter

More

Packages that depend on lazy_wrap