selection_marquee 0.2.0 copy "selection_marquee: ^0.2.0" to clipboard
selection_marquee: ^0.2.0 copied to clipboard

A highly customizable Flutter package for drag-to-select functionality with auto-scrolling and desktop-style features.

example/lib/main.dart

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:selection_marquee/selection_marquee.dart';

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

class _AppScrollBehavior extends MaterialScrollBehavior {
  @override
  Set<PointerDeviceKind> get dragDevices => {
        PointerDeviceKind.touch,
        PointerDeviceKind.mouse,
        PointerDeviceKind.trackpad,
      };
}

class ExampleApp extends StatelessWidget {
  const ExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Selection Marquee Example',
      scrollBehavior: _AppScrollBehavior(),
      theme: ThemeData(useMaterial3: true),
      home: const ExampleHome(),
    );
  }
}

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

  @override
  State<ExampleHome> createState() => _ExampleHomeState();
}

class _ExampleHomeState extends State<ExampleHome> {
  final SelectionController _controller = SelectionController();
  final GlobalKey _marqueeKey = GlobalKey();
  final ScrollController _scrollController = ScrollController();

  // layout
  bool _isGrid = true;

  // runtime tuning values for auto-scroll
  double _edgeZoneFraction = 0.12;
  double _autoScrollSpeed = 600.0;
  double _minAutoScrollFactor = 0.25;
  bool _edgeAutoScrollEnabled = true;
  bool _shortcutsEnabled = true;
  DragScrollBehavior _dragScrollBehavior = DragScrollBehavior.auto;
  AutoScrollMode _autoScrollMode = AutoScrollMode.jump;
  Curve _autoScrollCurve = Curves.linear;

  // selection decoration options
  SelectionBorderStyle _selectionBorderStyle =
      SelectionBorderStyle.marchingAnts;
  double _selectionBorderWidth = 1.0;
  double _selectionDashLength = 8.0;
  double _selectionGapLength = 4.0;
  int _selectionMarchMs = 800;
  double _selectionBorderRadius = 4.0;

  final Map<String, Curve> _curveOptions = {
    'linear': Curves.linear,
    'easeIn': Curves.easeIn,
    'easeOut': Curves.easeOut,
    'easeInOut': Curves.easeInOut,
    'fastOutSlowIn': Curves.fastOutSlowIn,
  };

  // sidebar + indicator
  bool _sidebarOpen = false;
  double _currentVelocity = 0.0; // px/s, positive = down

  final List<String> items = List.generate(50, (i) => 'Item ${i + 1}');

  @override
  void initState() {
    super.initState();
    _controller.allItemsGetter = () => items;
  }

  @override
  void dispose() {
    _controller.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  Widget _buildCard(String id) {
    return SelectableItem(
      id: id,
      controller: _controller,
      marqueeKey: _marqueeKey,
      borderRadius: BorderRadius.circular(12),
      onLongPress: () => _controller.toggle(id),
      onContextMenu: (position) {
        showMenu(
          context: context,
          position: RelativeRect.fromLTRB(
            position.dx,
            position.dy,
            position.dx,
            position.dy,
          ),
          items: [
            PopupMenuItem(
              value: 'info',
              child: Text('Selected: ${_controller.selectedIds.length} items'),
            ),
            const PopupMenuItem(value: 'clear', child: Text('Clear Selection')),
          ],
        ).then((value) {
          if (value == 'clear') {
            _controller.clear();
          }
        });
      },
      selectedBuilder: (context, child, selected) {
        return AnimatedContainer(
          duration: const Duration(milliseconds: 180),
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(12),
            boxShadow: selected
                ? [
                    BoxShadow(
                      color: Theme.of(
                        context,
                      ).colorScheme.primary.withValues(alpha: 0.25),
                      blurRadius: 8,
                    ),
                  ]
                : null,
          ),
          child: child,
        );
      },
      selectionDecoration: BoxDecoration(
        color: Colors.transparent,
        border: Border.all(color: Colors.blueAccent, width: 2),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Card(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Center(child: Text(id)),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Selection Marquee Example'),
        actions: [
          IconButton(
            icon: Icon(_sidebarOpen ? Icons.chevron_right : Icons.chevron_left),
            tooltip: 'Toggle Sidebar',
            onPressed: () => setState(() => _sidebarOpen = !_sidebarOpen),
          ),
          IconButton(
            icon: const Icon(Icons.select_all),
            tooltip: 'Select All',
            onPressed: () => _controller.selectAll(candidates: items),
          ),
          IconButton(
            icon: const Icon(Icons.clear),
            tooltip: 'Clear',
            onPressed: () => _controller.clear(),
          ),
          IconButton(
            icon: Icon(_isGrid ? Icons.list : Icons.grid_view),
            onPressed: () => setState(() => _isGrid = !_isGrid),
          ),
        ],
      ),
      body: Row(
        children: [
          // main content
          Expanded(
            child: Listener(
              onPointerMove: (event) {
                // compute a local estimated velocity for the sidebar display only
                final rb = _marqueeKey.currentContext?.findRenderObject()
                    as RenderBox?;
                if (rb == null) return;
                final local = rb.globalToLocal(event.position);
                final s = rb.size;
                final zone = s.height * _edgeZoneFraction;
                double velocity = 0.0;
                if (local.dy <= zone) {
                  final proximity = (zone - local.dy) / zone;
                  final factor = (_minAutoScrollFactor +
                          (1 - _minAutoScrollFactor) * proximity)
                      .clamp(0.0, 1.0);
                  velocity = -_autoScrollSpeed * factor; // negative => up
                } else if (local.dy >= s.height - zone) {
                  final proximity = (local.dy - (s.height - zone)) / zone;
                  final factor = (_minAutoScrollFactor +
                          (1 - _minAutoScrollFactor) * proximity)
                      .clamp(0.0, 1.0);
                  velocity = _autoScrollSpeed * factor; // positive => down
                }
                setState(() => _currentVelocity = velocity);
              },
              onPointerUp: (_) => setState(() => _currentVelocity = 0.0),
              child: SelectionMarquee(
                controller: _controller,
                marqueeKey: _marqueeKey,
                scrollController: _scrollController,
                enableShortcuts: _shortcutsEnabled,
                dragScrollBehavior: _dragScrollBehavior,
                config: SelectionConfig(
                  edgeAutoScroll: _edgeAutoScrollEnabled,
                  autoScrollSpeed: _autoScrollSpeed,
                  edgeZoneFraction: _edgeZoneFraction,
                  minAutoScrollFactor: _minAutoScrollFactor,
                  autoScrollMode: _autoScrollMode,
                  selectionDecoration: SelectionDecoration(
                    borderStyle: _selectionBorderStyle,
                    borderWidth: _selectionBorderWidth,
                    borderRadius: _selectionBorderRadius,
                    dashLength: _selectionDashLength,
                    gapLength: _selectionGapLength,
                    marchingSpeed: Duration(milliseconds: _selectionMarchMs),
                  ),
                  autoScrollCurve: _autoScrollCurve,
                ),
                child: Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: CustomScrollView(
                    controller: _scrollController,
                    slivers: [
                      SliverToBoxAdapter(
                        child: Padding(
                          padding: const EdgeInsets.symmetric(vertical: 8.0),
                          child: Text(
                            'Tip: drag with mouse or long-press and drag on touch devices. Tap items to toggle selection.',
                            style: Theme.of(context).textTheme.bodyMedium,
                          ),
                        ),
                      ),
                      SliverToBoxAdapter(
                        child: Card(
                          margin: const EdgeInsets.symmetric(
                            horizontal: 8,
                            vertical: 6,
                          ),
                          child: Padding(
                            padding: const EdgeInsets.all(12.0),
                            child: Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                Row(
                                  children: [
                                    const Text('Edge Auto-Scroll'),
                                    const Spacer(),
                                    Switch(
                                      value: _edgeAutoScrollEnabled,
                                      onChanged: (v) => setState(
                                        () => _edgeAutoScrollEnabled = v,
                                      ),
                                    ),
                                  ],
                                ),
                                Row(
                                  children: [
                                    const Text(
                                        'Keyboard Shortcuts (Ctrl+A, Esc)'),
                                    const Spacer(),
                                    Switch(
                                      value: _shortcutsEnabled,
                                      onChanged: (v) => setState(
                                        () => _shortcutsEnabled = v,
                                      ),
                                    ),
                                  ],
                                ),
                                const SizedBox(height: 8),
                                const Text('Drag Scroll Behavior'),
                                Row(
                                  children: [
                                    Expanded(
                                      child: DropdownButton<DragScrollBehavior>(
                                        value: _dragScrollBehavior,
                                        items: DragScrollBehavior.values
                                            .map(
                                              (v) => DropdownMenuItem(
                                                value: v,
                                                child: Text(v.name),
                                              ),
                                            )
                                            .toList(),
                                        onChanged: (v) => setState(
                                          () => _dragScrollBehavior =
                                              v ?? DragScrollBehavior.auto,
                                        ),
                                      ),
                                    ),
                                  ],
                                ),
                                const SizedBox(height: 8),
                                Text(
                                  'Auto-scroll mode: ${_autoScrollMode.name}',
                                ),
                                Row(
                                  children: [
                                    Expanded(
                                      child: DropdownButton<AutoScrollMode>(
                                        value: _autoScrollMode,
                                        items: AutoScrollMode.values
                                            .map(
                                              (m) => DropdownMenuItem(
                                                value: m,
                                                child: Text(m.name),
                                              ),
                                            )
                                            .toList(),
                                        onChanged: (v) => setState(
                                          () => _autoScrollMode =
                                              v ?? AutoScrollMode.jump,
                                        ),
                                      ),
                                    ),
                                  ],
                                ),
                                const SizedBox(height: 8),
                                Text(
                                  'Edge zone fraction: ${_edgeZoneFraction.toStringAsFixed(2)}',
                                ),
                                Slider(
                                  min: 0.05,
                                  max: 0.5,
                                  divisions: 45,
                                  value: _edgeZoneFraction,
                                  onChanged: (v) =>
                                      setState(() => _edgeZoneFraction = v),
                                ),
                                Text(
                                  'Max auto-scroll speed: ${_autoScrollSpeed.toStringAsFixed(0)} px/s',
                                ),
                                Slider(
                                  min: 50,
                                  max: 2000,
                                  divisions: 39,
                                  value: _autoScrollSpeed,
                                  onChanged: (v) =>
                                      setState(() => _autoScrollSpeed = v),
                                ),
                                Text(
                                  'Min auto-scroll factor: ${_minAutoScrollFactor.toStringAsFixed(2)}',
                                ),
                                Slider(
                                  min: 0.0,
                                  max: 1.0,
                                  divisions: 100,
                                  value: _minAutoScrollFactor,
                                  onChanged: (v) =>
                                      setState(() => _minAutoScrollFactor = v),
                                ),
                                const SizedBox(height: 8),
                                const Text('Auto-scroll curve:'),
                                Row(
                                  children: [
                                    Expanded(
                                      child: DropdownButton<String>(
                                        value: _curveOptions.keys.firstWhere(
                                          (k) =>
                                              _curveOptions[k] ==
                                              _autoScrollCurve,
                                          orElse: () => 'linear',
                                        ),
                                        items: _curveOptions.keys
                                            .map(
                                              (k) => DropdownMenuItem(
                                                value: k,
                                                child: Text(k),
                                              ),
                                            )
                                            .toList(),
                                        onChanged: (v) => setState(() {
                                          if (v != null) {
                                            _autoScrollCurve =
                                                _curveOptions[v]!;
                                          }
                                        }),
                                      ),
                                    ),
                                  ],
                                ),
                                const SizedBox(height: 8),
                                const Text('Selection border style:'),
                                Row(
                                  children: [
                                    Expanded(
                                      child:
                                          DropdownButton<SelectionBorderStyle>(
                                        value: _selectionBorderStyle,
                                        items: SelectionBorderStyle.values
                                            .map(
                                              (s) => DropdownMenuItem(
                                                value: s,
                                                child: Text(s.name),
                                              ),
                                            )
                                            .toList(),
                                        onChanged: (v) => setState(
                                          () => _selectionBorderStyle =
                                              v ?? SelectionBorderStyle.solid,
                                        ),
                                      ),
                                    ),
                                  ],
                                ),
                                Text(
                                  'Border radius: ${_selectionBorderRadius.toStringAsFixed(1)}',
                                ),
                                Slider(
                                  min: 0.0,
                                  max: 24.0,
                                  divisions: 24,
                                  value: _selectionBorderRadius,
                                  onChanged: (v) => setState(
                                    () => _selectionBorderRadius = v,
                                  ),
                                ),
                                Text(
                                  'Border width: ${_selectionBorderWidth.toStringAsFixed(1)}',
                                ),
                                Slider(
                                  min: 0.5,
                                  max: 6.0,
                                  divisions: 11,
                                  value: _selectionBorderWidth,
                                  onChanged: (v) =>
                                      setState(() => _selectionBorderWidth = v),
                                ),
                                Text(
                                  'Dash length: ${_selectionDashLength.toStringAsFixed(1)}',
                                ),
                                Slider(
                                  min: 2.0,
                                  max: 32.0,
                                  divisions: 15,
                                  value: _selectionDashLength,
                                  onChanged: (v) =>
                                      setState(() => _selectionDashLength = v),
                                ),
                                Text(
                                  'Gap length: ${_selectionGapLength.toStringAsFixed(1)}',
                                ),
                                Slider(
                                  min: 0.0,
                                  max: 24.0,
                                  divisions: 12,
                                  value: _selectionGapLength,
                                  onChanged: (v) =>
                                      setState(() => _selectionGapLength = v),
                                ),
                                Text('Marching speed: $_selectionMarchMs ms'),
                                Slider(
                                  min: 100,
                                  max: 2000,
                                  divisions: 19,
                                  value: _selectionMarchMs.toDouble(),
                                  onChanged: (v) => setState(
                                    () => _selectionMarchMs = v.toInt(),
                                  ),
                                ),
                              ],
                            ),
                          ),
                        ),
                      ),
                      _isGrid
                          ? SliverPadding(
                              padding: const EdgeInsets.all(8.0),
                              sliver: SliverGrid.count(
                                crossAxisCount: 4,
                                mainAxisSpacing: 8,
                                crossAxisSpacing: 8,
                                children:
                                    items.map((e) => _buildCard(e)).toList(),
                              ),
                            )
                          : SliverList(
                              delegate: SliverChildBuilderDelegate(
                                (context, index) => Padding(
                                  padding: const EdgeInsets.symmetric(
                                    vertical: 6.0,
                                  ),
                                  child: _buildCard(items[index]),
                                ),
                                childCount: items.length,
                              ),
                            ),
                                                                  const SliverToBoxAdapter(child: SizedBox(height: 80)),
                                                                ],
                                                              ),
                                                            ),
                                                          ),
                                                        ),
                                                      ),
                                            
                                                      // sidebar
                                                      AnimatedContainer(
                                                        duration: const Duration(milliseconds: 200),
                                                        width: _sidebarOpen ? 240 : 0,
                                                        child: _sidebarOpen
                                                            ? Container(
                                                                color: Theme.of(
                                                                  context,
                                                                ).colorScheme.surfaceContainerHighest,
                                                                child: Padding(
                                                                  padding: const EdgeInsets.all(12.0),
                                                                  child: Column(
                                                                    crossAxisAlignment: CrossAxisAlignment.start,
                                                                    children: [
                                                                      Row(
                                                                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                                                                        children: [
                                                                          const Text('Auto-scroll'),
                                                                          IconButton(
                                                                            icon: const Icon(Icons.close),
                                                                            onPressed: () =>
                                                                                setState(() => _sidebarOpen = false),
                                                                          ),
                                                                        ],
                                                                      ),
                                                                      const SizedBox(height: 8),
                                                                      Text(
                                                                        'Velocity: ${_currentVelocity.toStringAsFixed(1)} px/s',
                                                                      ),
                                                                      const SizedBox(height: 8),
                                                                      LinearProgressIndicator(
                                                                        value:
                                                                            (_currentVelocity.abs() /
                                                                                    (_autoScrollSpeed == 0
                                                                                        ? 1
                                                                                        : _autoScrollSpeed))
                                                                                .clamp(0.0, 1.0),
                                                                      ),
                                                                      const SizedBox(height: 12),
                                                                      Text(
                                                                        'Direction: ${_currentVelocity < 0
                                                                            ? 'Up'
                                                                            : _currentVelocity > 0
                                                                            ? 'Down'
                                                                            : 'None'}',
                                                                      ),
                                                                      const SizedBox(height: 12),
                                                                      Text(
                                                                        'Edge zone: ${_edgeZoneFraction.toStringAsFixed(2)}',
                                                                      ),
                                                                      Text(
                                                                        'Max speed: ${_autoScrollSpeed.toStringAsFixed(0)}',
                                                                      ),
                                                                      Text(
                                                                        'Min factor: ${_minAutoScrollFactor.toStringAsFixed(2)}',
                                                                      ),
                                                                    ],
                                                                  ),
                                                                ),
                                                              )
                                                            : null,
                                                      ),        ],
      ),
      bottomNavigationBar: BottomAppBar(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
          child: Row(
            children: [
              ValueListenableBuilder<Set<String>>(
                valueListenable: _controller.selectedListenable,
                builder: (context, set, _) => Text('Selected: ${set.length}'),
              ),
              const Spacer(),
              IconButton(
                tooltip: 'Reveal first selected',
                icon: const Icon(Icons.visibility),
                onPressed: () {
                  final first = _controller.selectedIds.isNotEmpty
                      ? _controller.selectedIds.first
                      : null;
                  if (first != null) {
                    _controller.ensureItemVisible(
                      first,
                      scrollController: _scrollController,
                    );
                  }
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}
0
likes
160
points
318
downloads

Publisher

unverified uploader

Weekly Downloads

A highly customizable Flutter package for drag-to-select functionality with auto-scrolling and desktop-style features.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on selection_marquee