layout_motion 0.5.0 copy "layout_motion: ^0.5.0" to clipboard
layout_motion: ^0.5.0 copied to clipboard

Automatic FLIP layout animations for Flutter. Wrap any Column, Row, Wrap, or Stack to animate add, remove, and reorder with zero configuration.

example/lib/main.dart

import 'dart:math';
import 'package:flutter/material.dart';
import 'package:layout_motion/layout_motion.dart';

void main() => runApp(const LayoutMotionExampleApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'layout_motion Demo',
      theme: ThemeData(colorSchemeSeed: Colors.deepPurple, useMaterial3: true),
      home: const DemoSelector(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('layout_motion Demos')),
      body: ListView(
        children: [
          ListTile(
            title: const Text('Basic List (Add / Remove)'),
            subtitle: const Text('Column with animated insert and delete'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const BasicListDemo()),
            ),
          ),
          ListTile(
            title: const Text('Reorder'),
            subtitle: const Text('Shuffle items and watch them animate'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const ReorderDemo()),
            ),
          ),
          ListTile(
            title: const Text('Wrap Reflow'),
            subtitle: const Text('Responsive Wrap with animated reflow'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const WrapReflowDemo()),
            ),
          ),
          ListTile(
            title: const Text('Row Layout'),
            subtitle: const Text('Horizontal row with animated add/remove'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const RowDemo()),
            ),
          ),
          ListTile(
            title: const Text('Stack Layout'),
            subtitle: const Text('Positioned boxes with animated shuffling'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const StackDemo()),
            ),
          ),
          const Divider(),
          ListTile(
            title: const Text('Stagger + Spring'),
            subtitle: const Text('Cascading spring-based animations'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const StaggerSpringDemo()),
            ),
          ),
          ListTile(
            title: const Text('Transition Composition'),
            subtitle: const Text('Combine fade + slide + scale with operator+'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const CompositionDemo()),
            ),
          ),
          ListTile(
            title: const Text('Lifecycle Callbacks'),
            subtitle: const Text('React to animation start/complete events'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const CallbacksDemo()),
            ),
          ),
          ListTile(
            title: const Text('Advanced Options'),
            subtitle: const Text('Toggle enabled, clip, threshold, timing'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const AdvancedOptionsDemo()),
            ),
          ),
          const Divider(),
          ListTile(
            title: const Text('Drag-to-Reorder'),
            subtitle: const Text('Long-press and drag to reorder with FLIP'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const DragReorderDemo()),
            ),
          ),
          ListTile(
            title: const Text('Pop Exit Mode'),
            subtitle: const Text('Exiting children leave layout flow instantly'),
            onTap: () => Navigator.push(
              context,
              MaterialPageRoute(builder: (_) => const PopExitDemo()),
            ),
          ),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 1: Basic List — Add/Remove items from a Column
// ---------------------------------------------------------------------------
class BasicListDemo extends StatefulWidget {
  const BasicListDemo({super.key});

  @override
  State<BasicListDemo> createState() => _BasicListDemoState();
}

class _BasicListDemoState extends State<BasicListDemo> {
  final _items = <int>[1, 2, 3];
  int _nextId = 4;

  void _addItem() {
    setState(() {
      _items.add(_nextId++);
    });
  }

  void _removeItem(int id) {
    setState(() {
      _items.remove(id);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Basic List')),
      floatingActionButton: FloatingActionButton(
        onPressed: _addItem,
        child: const Icon(Icons.add),
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: MotionLayout(
          duration: const Duration(milliseconds: 400),
          curve: Curves.easeOutCubic,
          enterTransition: const SlideIn(offset: Offset(0, 0.15)),
          exitTransition: const FadeOut(),
          child: Column(
            children: [
              for (final id in _items)
                Card(
                  key: ValueKey(id),
                  child: ListTile(
                    title: Text('Item $id'),
                    trailing: IconButton(
                      icon: const Icon(Icons.close),
                      onPressed: () => _removeItem(id),
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 2: Reorder — Shuffle items, watch them animate to new positions
// ---------------------------------------------------------------------------
class ReorderDemo extends StatefulWidget {
  const ReorderDemo({super.key});

  @override
  State<ReorderDemo> createState() => _ReorderDemoState();
}

class _ReorderDemoState extends State<ReorderDemo> {
  var _items = List.generate(8, (i) => i + 1);

  void _shuffle() {
    setState(() {
      _items = List.of(_items)..shuffle(Random());
    });
  }

  void _sort() {
    setState(() {
      _items.sort();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Reorder'),
        actions: [
          IconButton(
            icon: const Icon(Icons.sort),
            tooltip: 'Sort',
            onPressed: _sort,
          ),
          IconButton(
            icon: const Icon(Icons.shuffle),
            tooltip: 'Shuffle',
            onPressed: _shuffle,
          ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: MotionLayout(
          duration: const Duration(milliseconds: 500),
          curve: Curves.easeOutCubic,
          child: Column(
            children: [
              for (final id in _items)
                Card(
                  key: ValueKey(id),
                  color: Colors.primaries[id % Colors.primaries.length]
                      .withValues(alpha: 0.3),
                  child: ListTile(
                    leading: CircleAvatar(child: Text('$id')),
                    title: Text('Item $id'),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 3: Wrap Reflow — Responsive Wrap that animates when items reflow
// ---------------------------------------------------------------------------
class WrapReflowDemo extends StatefulWidget {
  const WrapReflowDemo({super.key});

  @override
  State<WrapReflowDemo> createState() => _WrapReflowDemoState();
}

class _WrapReflowDemoState extends State<WrapReflowDemo> {
  var _tags = <String>[
    'Flutter',
    'Dart',
    'Animation',
    'FLIP',
    'Layout',
    'Motion',
    'Widget',
    'Package',
  ];
  int _nextId = 0;

  void _addTag() {
    setState(() {
      _tags.add('Tag ${_nextId++}');
    });
  }

  void _removeTag(String tag) {
    setState(() {
      _tags.remove(tag);
    });
  }

  void _shuffle() {
    setState(() {
      _tags = List.of(_tags)..shuffle(Random());
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Wrap Reflow'),
        actions: [
          IconButton(
            icon: const Icon(Icons.shuffle),
            tooltip: 'Shuffle',
            onPressed: _shuffle,
          ),
          IconButton(
            icon: const Icon(Icons.add),
            tooltip: 'Add Tag',
            onPressed: _addTag,
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: MotionLayout(
          duration: const Duration(milliseconds: 400),
          curve: Curves.easeOutCubic,
          enterTransition: const ScaleIn(scale: 0.5),
          exitTransition: const ScaleOut(scale: 0.5),
          child: Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              for (final tag in _tags)
                Chip(
                  key: ValueKey(tag),
                  label: Text(tag),
                  onDeleted: () => _removeTag(tag),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 4: Row Layout — Horizontal row with add/remove/shuffle
// ---------------------------------------------------------------------------
class RowDemo extends StatefulWidget {
  const RowDemo({super.key});

  @override
  State<RowDemo> createState() => _RowDemoState();
}

class _RowDemoState extends State<RowDemo> {
  var _items = <int>[1, 2, 3, 4, 5];
  int _nextId = 6;

  void _addItem() {
    setState(() {
      _items.add(_nextId++);
    });
  }

  void _removeItem(int id) {
    setState(() {
      _items.remove(id);
    });
  }

  void _shuffle() {
    setState(() {
      _items = List.of(_items)..shuffle(Random());
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Row Layout'),
        actions: [
          IconButton(
            icon: const Icon(Icons.shuffle),
            tooltip: 'Shuffle',
            onPressed: _shuffle,
          ),
          IconButton(
            icon: const Icon(Icons.add),
            tooltip: 'Add',
            onPressed: _addItem,
          ),
        ],
      ),
      body: SingleChildScrollView(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.all(16),
        child: MotionLayout(
          duration: const Duration(milliseconds: 400),
          curve: Curves.easeOutCubic,
          enterTransition: const SlideIn(offset: Offset(0.15, 0)),
          exitTransition: const ScaleOut(),
          child: Row(
            children: [
              for (final id in _items)
                Padding(
                  key: ValueKey(id),
                  padding: const EdgeInsets.symmetric(horizontal: 4),
                  child: ActionChip(
                    avatar: CircleAvatar(
                      radius: 12,
                      child: Text('$id', style: const TextStyle(fontSize: 11)),
                    ),
                    label: Text('Item $id'),
                    onPressed: () => _removeItem(id),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 5: Stack Layout — Positioned boxes that animate within a Stack
// ---------------------------------------------------------------------------
class StackDemo extends StatefulWidget {
  const StackDemo({super.key});

  @override
  State<StackDemo> createState() => _StackDemoState();
}

class _StackDemoState extends State<StackDemo> {
  static const _colors = [
    Colors.red,
    Colors.blue,
    Colors.green,
    Colors.orange,
    Colors.purple,
    Colors.teal,
    Colors.pink,
    Colors.indigo,
  ];

  static const _boxSize = 80.0;
  static const _stackWidth = 320.0;
  static const _stackHeight = 400.0;

  final _rng = Random();
  int _nextId = 5;

  /// Each box stores its id, position, and color index.
  var _boxes = <_StackBox>[
    const _StackBox(id: 1, left: 20, top: 20, colorIndex: 0),
    const _StackBox(id: 2, left: 160, top: 40, colorIndex: 1),
    const _StackBox(id: 3, left: 60, top: 180, colorIndex: 2),
    const _StackBox(id: 4, left: 200, top: 260, colorIndex: 3),
  ];

  /// Randomise positions for every box within the stack bounds.
  void _shufflePositions() {
    setState(() {
      _boxes = [
        for (final box in _boxes)
          box.copyWith(
            left: _rng.nextDouble() * (_stackWidth - _boxSize),
            top: _rng.nextDouble() * (_stackHeight - _boxSize),
          ),
      ];
    });
  }

  void _addBox() {
    final id = _nextId++;
    setState(() {
      _boxes.add(
        _StackBox(
          id: id,
          left: _rng.nextDouble() * (_stackWidth - _boxSize),
          top: _rng.nextDouble() * (_stackHeight - _boxSize),
          colorIndex: id % _colors.length,
        ),
      );
    });
  }

  void _removeBox(int id) {
    setState(() {
      _boxes = _boxes.where((b) => b.id != id).toList();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Stack Layout'),
        actions: [
          IconButton(
            icon: const Icon(Icons.shuffle),
            tooltip: 'Shuffle Positions',
            onPressed: _shufflePositions,
          ),
          IconButton(
            icon: const Icon(Icons.remove),
            tooltip: 'Remove Last',
            onPressed: _boxes.isNotEmpty
                ? () => _removeBox(_boxes.last.id)
                : null,
          ),
          IconButton(
            icon: const Icon(Icons.add),
            tooltip: 'Add Box',
            onPressed: _addBox,
          ),
        ],
      ),
      body: Center(
        child: MotionLayout(
          duration: const Duration(milliseconds: 500),
          curve: Curves.easeOutCubic,
          enterTransition: const ScaleIn(scale: 0.3),
          exitTransition: const ScaleOut(scale: 0.3),
          child: Stack(
            children: [
              // Invisible sizing widget to keep the Stack at a fixed size.
              const SizedBox(
                key: ValueKey('__stack_sizer__'),
                width: _stackWidth,
                height: _stackHeight,
              ),
              for (final box in _boxes)
                Positioned(
                  key: ValueKey(box.id),
                  left: box.left,
                  top: box.top,
                  width: _boxSize,
                  height: _boxSize,
                  child: GestureDetector(
                    onTap: () => _removeBox(box.id),
                    child: DecoratedBox(
                      decoration: BoxDecoration(
                        color: _colors[box.colorIndex].withValues(alpha: 0.85),
                        borderRadius: BorderRadius.circular(12),
                        boxShadow: const [
                          BoxShadow(
                            color: Colors.black26,
                            blurRadius: 6,
                            offset: Offset(2, 2),
                          ),
                        ],
                      ),
                      child: Center(
                        child: Text(
                          '${box.id}',
                          style: const TextStyle(
                            color: Colors.white,
                            fontSize: 20,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

/// Simple data class for a box in the [StackDemo].
class _StackBox {
  const _StackBox({
    required this.id,
    required this.left,
    required this.top,
    required this.colorIndex,
  });

  final int id;
  final double left;
  final double top;
  final int colorIndex;

  _StackBox copyWith({double? left, double? top}) {
    return _StackBox(
      id: id,
      left: left ?? this.left,
      top: top ?? this.top,
      colorIndex: colorIndex,
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 6: Stagger + Spring — Cascading spring-based animations
// ---------------------------------------------------------------------------
class StaggerSpringDemo extends StatefulWidget {
  const StaggerSpringDemo({super.key});

  @override
  State<StaggerSpringDemo> createState() => _StaggerSpringDemoState();
}

class _StaggerSpringDemoState extends State<StaggerSpringDemo> {
  var _items = List.generate(6, (i) => i + 1);
  int _nextId = 7;
  MotionSpring _spring = MotionSpring.bouncy;
  StaggerFrom _staggerFrom = StaggerFrom.first;
  int _staggerMs = 50;

  void _addItem() {
    setState(() {
      _items.add(_nextId++);
    });
  }

  void _removeItem(int id) {
    setState(() {
      _items.remove(id);
    });
  }

  void _shuffle() {
    setState(() {
      _items = List.of(_items)..shuffle(Random());
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Stagger + Spring'),
        actions: [
          IconButton(
            icon: const Icon(Icons.shuffle),
            tooltip: 'Shuffle',
            onPressed: _shuffle,
          ),
          IconButton(
            icon: const Icon(Icons.remove),
            tooltip: 'Remove Last',
            onPressed: _items.isNotEmpty
                ? () => _removeItem(_items.last)
                : null,
          ),
          IconButton(
            icon: const Icon(Icons.add),
            tooltip: 'Add',
            onPressed: _addItem,
          ),
        ],
      ),
      body: Column(
        children: [
          Card(
            margin: const EdgeInsets.all(12),
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: Column(
                children: [
                  // Spring preset selector.
                  ListTile(
                    title: const Text('Spring Preset'),
                    trailing: SegmentedButton<String>(
                      segments: const [
                        ButtonSegment(value: 'gentle', label: Text('Gentle')),
                        ButtonSegment(value: 'smooth', label: Text('Smooth')),
                        ButtonSegment(value: 'bouncy', label: Text('Bouncy')),
                        ButtonSegment(value: 'stiff', label: Text('Stiff')),
                      ],
                      selected: {
                        _spring == MotionSpring.gentle
                            ? 'gentle'
                            : _spring == MotionSpring.smooth
                                ? 'smooth'
                                : _spring == MotionSpring.bouncy
                                    ? 'bouncy'
                                    : 'stiff',
                      },
                      onSelectionChanged: (v) {
                        setState(() {
                          _spring = switch (v.first) {
                            'gentle' => MotionSpring.gentle,
                            'smooth' => MotionSpring.smooth,
                            'bouncy' => MotionSpring.bouncy,
                            _ => MotionSpring.stiff,
                          };
                        });
                      },
                    ),
                  ),
                  const Divider(height: 1),
                  // Stagger direction selector.
                  ListTile(
                    title: const Text('Stagger From'),
                    trailing: SegmentedButton<StaggerFrom>(
                      segments: const [
                        ButtonSegment(
                          value: StaggerFrom.first,
                          label: Text('First'),
                        ),
                        ButtonSegment(
                          value: StaggerFrom.last,
                          label: Text('Last'),
                        ),
                        ButtonSegment(
                          value: StaggerFrom.center,
                          label: Text('Center'),
                        ),
                      ],
                      selected: {_staggerFrom},
                      onSelectionChanged: (v) {
                        setState(() => _staggerFrom = v.first);
                      },
                    ),
                  ),
                  const Divider(height: 1),
                  // Stagger delay slider.
                  ListTile(
                    title: Text('Stagger Delay: ${_staggerMs}ms'),
                    subtitle: Slider(
                      min: 0,
                      max: 200,
                      divisions: 20,
                      value: _staggerMs.toDouble(),
                      label: '${_staggerMs}ms',
                      onChanged: (v) =>
                          setState(() => _staggerMs = v.round()),
                    ),
                  ),
                ],
              ),
            ),
          ),
          Expanded(
            child: SingleChildScrollView(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: MotionLayout(
                duration: const Duration(milliseconds: 600),
                spring: _spring,
                staggerDuration: Duration(milliseconds: _staggerMs),
                staggerFrom: _staggerFrom,
                enterTransition: const FadeSlideIn(),
                exitTransition: const FadeOut(),
                child: Column(
                  children: [
                    for (final id in _items)
                      Card(
                        key: ValueKey(id),
                        color: Colors.primaries[id % Colors.primaries.length]
                            .withValues(alpha: 0.25),
                        child: ListTile(
                          leading: CircleAvatar(child: Text('$id')),
                          title: Text('Item $id'),
                          trailing: IconButton(
                            icon: const Icon(Icons.close),
                            onPressed: () => _removeItem(id),
                          ),
                        ),
                      ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 7: Transition Composition — Combine transitions with operator+
// ---------------------------------------------------------------------------
class CompositionDemo extends StatefulWidget {
  const CompositionDemo({super.key});

  @override
  State<CompositionDemo> createState() => _CompositionDemoState();
}

class _CompositionDemoState extends State<CompositionDemo> {
  final _items = <int>[1, 2, 3, 4, 5];
  int _nextId = 6;
  String _enterPreset = 'fadeSlide';
  String _exitPreset = 'fadeScale';

  MotionTransition get _enterTransition => switch (_enterPreset) {
        'fade' => const FadeIn(),
        'slide' => const SlideIn(),
        'scale' => const ScaleIn(),
        'fadeSlide' => const FadeSlideIn(),
        'fadeScale' => const FadeScaleIn(),
        'size' => const SizeIn(),
        'fadeSlideScale' =>
          const FadeIn() + const SlideIn() + const ScaleIn(scale: 0.9),
        _ => const FadeIn(),
      };

  MotionTransition get _exitTransition => switch (_exitPreset) {
        'fade' => const FadeOut(),
        'slide' => const SlideOut(),
        'scale' => const ScaleOut(),
        'fadeSlide' => const FadeSlideOut(),
        'fadeScale' => const FadeScaleOut(),
        'size' => const SizeOut(),
        'fadeSlideScale' =>
          const FadeOut() + const SlideOut() + const ScaleOut(scale: 0.9),
        _ => const FadeOut(),
      };

  void _addItem() {
    setState(() => _items.add(_nextId++));
  }

  void _removeItem(int id) {
    setState(() => _items.remove(id));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Transition Composition'),
        actions: [
          IconButton(
            icon: const Icon(Icons.remove),
            tooltip: 'Remove Last',
            onPressed: _items.isNotEmpty
                ? () => _removeItem(_items.last)
                : null,
          ),
          IconButton(
            icon: const Icon(Icons.add),
            tooltip: 'Add',
            onPressed: _addItem,
          ),
        ],
      ),
      body: Column(
        children: [
          Card(
            margin: const EdgeInsets.all(12),
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: Column(
                children: [
                  ListTile(
                    title: const Text('Enter Transition'),
                    trailing: DropdownButton<String>(
                      value: _enterPreset,
                      onChanged: (v) => setState(() => _enterPreset = v!),
                      items: const [
                        DropdownMenuItem(value: 'fade', child: Text('Fade')),
                        DropdownMenuItem(value: 'slide', child: Text('Slide')),
                        DropdownMenuItem(value: 'scale', child: Text('Scale')),
                        DropdownMenuItem(
                          value: 'fadeSlide',
                          child: Text('Fade + Slide'),
                        ),
                        DropdownMenuItem(
                          value: 'fadeScale',
                          child: Text('Fade + Scale'),
                        ),
                        DropdownMenuItem(value: 'size', child: Text('Size')),
                        DropdownMenuItem(
                          value: 'fadeSlideScale',
                          child: Text('Fade + Slide + Scale'),
                        ),
                      ],
                    ),
                  ),
                  ListTile(
                    title: const Text('Exit Transition'),
                    trailing: DropdownButton<String>(
                      value: _exitPreset,
                      onChanged: (v) => setState(() => _exitPreset = v!),
                      items: const [
                        DropdownMenuItem(value: 'fade', child: Text('Fade')),
                        DropdownMenuItem(value: 'slide', child: Text('Slide')),
                        DropdownMenuItem(value: 'scale', child: Text('Scale')),
                        DropdownMenuItem(
                          value: 'fadeSlide',
                          child: Text('Fade + Slide'),
                        ),
                        DropdownMenuItem(
                          value: 'fadeScale',
                          child: Text('Fade + Scale'),
                        ),
                        DropdownMenuItem(value: 'size', child: Text('Size')),
                        DropdownMenuItem(
                          value: 'fadeSlideScale',
                          child: Text('Fade + Slide + Scale'),
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
          ),
          Expanded(
            child: SingleChildScrollView(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: MotionLayout(
                duration: const Duration(milliseconds: 500),
                curve: Curves.easeOutCubic,
                enterTransition: _enterTransition,
                exitTransition: _exitTransition,
                child: Column(
                  children: [
                    for (final id in _items)
                      Card(
                        key: ValueKey(id),
                        child: ListTile(
                          leading: CircleAvatar(child: Text('$id')),
                          title: Text('Item $id'),
                          trailing: IconButton(
                            icon: const Icon(Icons.close),
                            onPressed: () => _removeItem(id),
                          ),
                        ),
                      ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 8: Lifecycle Callbacks — React to animation events
// ---------------------------------------------------------------------------
class CallbacksDemo extends StatefulWidget {
  const CallbacksDemo({super.key});

  @override
  State<CallbacksDemo> createState() => _CallbacksDemoState();
}

class _CallbacksDemoState extends State<CallbacksDemo> {
  final _items = <int>[1, 2, 3];
  int _nextId = 4;
  bool _isAnimating = false;
  final _log = <String>[];

  void _addItem() {
    setState(() => _items.add(_nextId++));
  }

  void _removeItem(int id) {
    setState(() => _items.remove(id));
  }

  void _addLog(String msg) {
    setState(() {
      _log.insert(0, msg);
      if (_log.length > 20) _log.removeLast();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Row(
          children: [
            const Text('Callbacks'),
            if (_isAnimating) ...[
              const SizedBox(width: 12),
              const SizedBox(
                width: 16,
                height: 16,
                child: CircularProgressIndicator(strokeWidth: 2),
              ),
            ],
          ],
        ),
        actions: [
          IconButton(
            icon: const Icon(Icons.delete_sweep),
            tooltip: 'Clear Log',
            onPressed: () => setState(() => _log.clear()),
          ),
          IconButton(
            icon: const Icon(Icons.remove),
            tooltip: 'Remove Last',
            onPressed: _items.isNotEmpty
                ? () => _removeItem(_items.last)
                : null,
          ),
          IconButton(
            icon: const Icon(Icons.add),
            tooltip: 'Add',
            onPressed: _addItem,
          ),
        ],
      ),
      body: Column(
        children: [
          Expanded(
            flex: 2,
            child: SingleChildScrollView(
              padding: const EdgeInsets.all(16),
              child: MotionLayout(
                duration: const Duration(milliseconds: 400),
                staggerDuration: const Duration(milliseconds: 40),
                enterTransition: const FadeSlideIn(),
                exitTransition: const FadeOut(),
                onAnimationStart: () {
                  _addLog('onAnimationStart');
                  setState(() => _isAnimating = true);
                },
                onAnimationComplete: () {
                  _addLog('onAnimationComplete');
                  setState(() => _isAnimating = false);
                },
                onChildEnter: (key) => _addLog('onChildEnter: $key'),
                onChildExit: (key) => _addLog('onChildExit: $key'),
                child: Column(
                  children: [
                    for (final id in _items)
                      Card(
                        key: ValueKey(id),
                        child: ListTile(
                          title: Text('Item $id'),
                          trailing: IconButton(
                            icon: const Icon(Icons.close),
                            onPressed: () => _removeItem(id),
                          ),
                        ),
                      ),
                  ],
                ),
              ),
            ),
          ),
          const Divider(height: 1),
          Expanded(
            flex: 1,
            child: Container(
              color: Colors.grey.shade100,
              padding: const EdgeInsets.all(8),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    'Event Log',
                    style: Theme.of(context).textTheme.labelLarge,
                  ),
                  const SizedBox(height: 4),
                  Expanded(
                    child: ListView.builder(
                      itemCount: _log.length,
                      itemBuilder: (context, index) {
                        return Text(
                          _log[index],
                          style: const TextStyle(
                            fontFamily: 'monospace',
                            fontSize: 12,
                          ),
                        );
                      },
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 9: Advanced Options — Explore MotionLayout parameters interactively
// ---------------------------------------------------------------------------
class AdvancedOptionsDemo extends StatefulWidget {
  const AdvancedOptionsDemo({super.key});

  @override
  State<AdvancedOptionsDemo> createState() => _AdvancedOptionsDemoState();
}

class _AdvancedOptionsDemoState extends State<AdvancedOptionsDemo> {
  var _items = <int>[1, 2, 3, 4, 5];
  int _nextId = 6;

  // --- Configurable parameters ---
  bool _enabled = true;
  bool _clipHardEdge = true;
  double _moveThreshold = 0.5;
  int _transitionMs = 400;

  // Move duration is fixed to make the contrast visible.
  static const _moveDuration = Duration(milliseconds: 600);

  void _addItem() {
    setState(() {
      _items.add(_nextId++);
    });
  }

  void _removeItem(int id) {
    setState(() {
      _items.remove(id);
    });
  }

  void _shuffle() {
    setState(() {
      _items = List.of(_items)..shuffle(Random());
    });
  }

  @override
  Widget build(BuildContext context) {
    final transitionDuration = Duration(milliseconds: _transitionMs);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Advanced Options'),
        actions: [
          IconButton(
            icon: const Icon(Icons.shuffle),
            tooltip: 'Shuffle',
            onPressed: _shuffle,
          ),
          IconButton(
            icon: const Icon(Icons.remove),
            tooltip: 'Remove Last',
            onPressed: _items.isNotEmpty
                ? () => _removeItem(_items.last)
                : null,
          ),
          IconButton(
            icon: const Icon(Icons.add),
            tooltip: 'Add',
            onPressed: _addItem,
          ),
        ],
      ),
      body: Column(
        children: [
          // ---------------------------------------------------------------
          // Controls panel
          // ---------------------------------------------------------------
          Card(
            margin: const EdgeInsets.all(12),
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: Column(
                children: [
                  // --- enabled toggle ---
                  SwitchListTile(
                    title: const Text('enabled'),
                    subtitle: Text(
                      _enabled
                          ? 'Animations are active'
                          : 'Animations disabled (instant layout)',
                    ),
                    value: _enabled,
                    onChanged: (v) => setState(() => _enabled = v),
                  ),
                  const Divider(height: 1),

                  // --- clipBehavior toggle ---
                  SwitchListTile(
                    title: const Text('clipBehavior'),
                    subtitle: Text(
                      _clipHardEdge
                          ? 'Clip.hardEdge (clips overflow)'
                          : 'Clip.none (overflow visible)',
                    ),
                    value: _clipHardEdge,
                    onChanged: (v) => setState(() => _clipHardEdge = v),
                  ),
                  const Divider(height: 1),

                  // --- moveThreshold slider ---
                  ListTile(
                    title: Text(
                      'moveThreshold: ${_moveThreshold.toStringAsFixed(1)} px',
                    ),
                    subtitle: Slider(
                      min: 0.5,
                      max: 5.0,
                      divisions: 9,
                      value: _moveThreshold,
                      label: _moveThreshold.toStringAsFixed(1),
                      onChanged: (v) => setState(() => _moveThreshold = v),
                    ),
                  ),
                  const Divider(height: 1),

                  // --- transitionDuration slider ---
                  ListTile(
                    title: Text(
                      'transitionDuration: ${_transitionMs}ms  '
                      '(move: ${_moveDuration.inMilliseconds}ms)',
                    ),
                    subtitle: Slider(
                      min: 100,
                      max: 1500,
                      divisions: 14,
                      value: _transitionMs.toDouble(),
                      label: '${_transitionMs}ms',
                      onChanged: (v) =>
                          setState(() => _transitionMs = v.round()),
                    ),
                  ),
                ],
              ),
            ),
          ),

          // ---------------------------------------------------------------
          // Animated list area
          // ---------------------------------------------------------------
          Expanded(
            child: SingleChildScrollView(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: MotionLayout(
                duration: _moveDuration,
                curve: Curves.easeOutCubic,
                enabled: _enabled,
                clipBehavior: _clipHardEdge ? Clip.hardEdge : Clip.none,
                moveThreshold: _moveThreshold,
                transitionDuration: transitionDuration,
                enterTransition: const SlideIn(offset: Offset(0, 0.15)),
                exitTransition: const FadeOut(),
                child: Column(
                  children: [
                    for (final id in _items)
                      Card(
                        key: ValueKey(id),
                        color: Colors.primaries[id % Colors.primaries.length]
                            .withValues(alpha: 0.25),
                        child: ListTile(
                          leading: CircleAvatar(child: Text('$id')),
                          title: Text('Item $id'),
                          trailing: IconButton(
                            icon: const Icon(Icons.close),
                            onPressed: () => _removeItem(id),
                          ),
                        ),
                      ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 10: Drag-to-Reorder — Long-press and drag to reorder items with FLIP
// ---------------------------------------------------------------------------
class DragReorderDemo extends StatefulWidget {
  const DragReorderDemo({super.key});

  @override
  State<DragReorderDemo> createState() => _DragReorderDemoState();
}

class _DragReorderDemoState extends State<DragReorderDemo> {
  var _items = List.generate(6, (i) => i + 1);
  int _nextId = 7;

  void _addItem() {
    setState(() {
      _items.add(_nextId++);
    });
  }

  void _removeItem(int id) {
    setState(() {
      _items.remove(id);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Drag-to-Reorder'),
        actions: [
          IconButton(
            icon: const Icon(Icons.remove),
            tooltip: 'Remove Last',
            onPressed: _items.isNotEmpty
                ? () => _removeItem(_items.last)
                : null,
          ),
          IconButton(
            icon: const Icon(Icons.add),
            tooltip: 'Add',
            onPressed: _addItem,
          ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: MotionLayout(
          duration: const Duration(milliseconds: 400),
          curve: Curves.easeOutCubic,
          enterTransition: const FadeSlideIn(),
          exitTransition: const FadeOut(),
          onReorder: (oldIndex, newIndex) {
            setState(() {
              final item = _items.removeAt(oldIndex);
              _items.insert(newIndex, item);
            });
          },
          dragDecorator: (child) {
            return Material(
              elevation: 8,
              borderRadius: BorderRadius.circular(12),
              child: child,
            );
          },
          child: Column(
            children: [
              for (final id in _items)
                Card(
                  key: ValueKey(id),
                  color: Colors.primaries[id % Colors.primaries.length]
                      .withValues(alpha: 0.25),
                  child: ListTile(
                    leading: CircleAvatar(child: Text('$id')),
                    title: Text('Item $id'),
                    subtitle: const Text('Long-press to drag'),
                    trailing: IconButton(
                      icon: const Icon(Icons.close),
                      onPressed: () => _removeItem(id),
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// Demo 11: Pop Exit Mode — Exiting children leave layout flow instantly
// ---------------------------------------------------------------------------
class PopExitDemo extends StatefulWidget {
  const PopExitDemo({super.key});

  @override
  State<PopExitDemo> createState() => _PopExitDemoState();
}

class _PopExitDemoState extends State<PopExitDemo> {
  final _items = <int>[1, 2, 3, 4, 5];
  int _nextId = 6;
  ExitLayoutBehavior _behavior = ExitLayoutBehavior.pop;

  void _addItem() {
    setState(() {
      _items.add(_nextId++);
    });
  }

  void _removeItem(int id) {
    setState(() {
      _items.remove(id);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Pop Exit Mode'),
        actions: [
          IconButton(
            icon: const Icon(Icons.remove),
            tooltip: 'Remove Last',
            onPressed: _items.isNotEmpty
                ? () => _removeItem(_items.last)
                : null,
          ),
          IconButton(
            icon: const Icon(Icons.add),
            tooltip: 'Add',
            onPressed: _addItem,
          ),
        ],
      ),
      body: Column(
        children: [
          Card(
            margin: const EdgeInsets.all(12),
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: ListTile(
                title: const Text('Exit Layout Behavior'),
                trailing: SegmentedButton<ExitLayoutBehavior>(
                  segments: const [
                    ButtonSegment(
                      value: ExitLayoutBehavior.maintain,
                      label: Text('Maintain'),
                    ),
                    ButtonSegment(
                      value: ExitLayoutBehavior.pop,
                      label: Text('Pop'),
                    ),
                  ],
                  selected: {_behavior},
                  onSelectionChanged: (v) {
                    setState(() => _behavior = v.first);
                  },
                ),
              ),
            ),
          ),
          Expanded(
            child: SingleChildScrollView(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: MotionLayout(
                duration: const Duration(milliseconds: 500),
                curve: Curves.easeOutCubic,
                enterTransition: const FadeSlideIn(),
                exitTransition: const FadeOut(),
                exitLayoutBehavior: _behavior,
                child: Column(
                  children: [
                    for (final id in _items)
                      Card(
                        key: ValueKey(id),
                        color: Colors.primaries[id % Colors.primaries.length]
                            .withValues(alpha: 0.25),
                        child: ListTile(
                          leading: CircleAvatar(child: Text('$id')),
                          title: Text('Item $id'),
                          trailing: IconButton(
                            icon: const Icon(Icons.close),
                            onPressed: () => _removeItem(id),
                          ),
                        ),
                      ),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
2
likes
160
points
315
downloads

Publisher

unverified uploader

Weekly Downloads

Automatic FLIP layout animations for Flutter. Wrap any Column, Row, Wrap, or Stack to animate add, remove, and reorder with zero configuration.

Repository (GitHub)
View/report issues

Topics

#animation #layout #widget #flip #transition

Documentation

API reference

Funding

Consider supporting this project:

github.com

License

MIT (license)

Dependencies

flutter

More

Packages that depend on layout_motion