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

Declarative layout & presence animations — enter/exit + FLIP reflow for any layout. A Framer Motion for Flutter.

example/lib/main.dart

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

import 'stress_test_screen.dart';

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

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

  @override
  Widget build(BuildContext context) {
    // Deep-link a single tab via ?demo=filter|reorder|addremove (for docs
    // screenshots). Falls back to the tabbed demo.
    final demo = Uri.base.queryParameters['demo'];
    const order = ['filter', 'reorder', 'addremove', 'shared', 'stress'];
    final initial = order.indexOf(demo ?? '');

    return MaterialApp(
      title: 'flip_layout example',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.deepOrange, useMaterial3: true),
      home: DemoPage(initialTab: initial < 0 ? 0 : initial),
    );
  }
}

class DemoPage extends StatefulWidget {
  const DemoPage({super.key, this.initialTab = 0});

  final int initialTab;

  @override
  State<DemoPage> createState() => _DemoPageState();
}

class _DemoPageState extends State<DemoPage>
    with SingleTickerProviderStateMixin {
  late final TabController _tab;

  @override
  void initState() {
    super.initState();
    _tab = TabController(length: 5, vsync: this, initialIndex: widget.initialTab);
  }

  @override
  void dispose() {
    _tab.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('flip_layout'),
        bottom: TabBar(
          controller: _tab,
          isScrollable: true,
          tabs: const [
            Tab(icon: Icon(Icons.filter_alt), text: 'Filter'),
            Tab(icon: Icon(Icons.shuffle), text: 'Reorder'),
            Tab(icon: Icon(Icons.playlist_add), text: 'Add / remove'),
            Tab(icon: Icon(Icons.open_in_full), text: 'Shared'),
            Tab(icon: Icon(Icons.speed_outlined), text: 'Stress'),
          ],
        ),
      ),
      body: TabBarView(
        controller: _tab,
        children: const [
          _FilterDemo(),
          _ReorderDemo(),
          _AddRemoveDemo(),
          _SharedDemo(),
          StressTestScreen(),
        ],
      ),
    );
  }
}

// -- 1. Filter demo (the flagship: a Wrap that Material can't animate) --------

class _FilterDemo extends StatefulWidget {
  const _FilterDemo();

  @override
  State<_FilterDemo> createState() => _FilterDemoState();
}

class _FilterDemoState extends State<_FilterDemo> {
  static const _allTags = [
    'dart', 'flutter', 'riverpod', 'bloc', 'hooks', 'test',
    'canvas', 'gesture', 'router', 'query', 'floating', 'motion',
    'anim', 'physics', 'sliver', 'render', 'layout', 'overlay',
  ];
  String _filter = '';

  @override
  Widget build(BuildContext context) {
    final visible = _allTags.where((t) => t.contains(_filter)).toList();
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(12),
          child: TextField(
            autofocus: false,
            decoration: const InputDecoration(
              prefixIcon: Icon(Icons.search),
              hintText: 'Filter tags — try "an", "flu", "re"',
              border: OutlineInputBorder(),
            ),
            onChanged: (v) => setState(() => _filter = v),
          ),
        ),
        Expanded(
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(12),
            child: MotionGroup(
              duration: const Duration(milliseconds: 260),
              children: [
                for (final t in visible)
                  Chip(
                    key: ValueKey(t),
                    label: Text(t),
                    backgroundColor: Colors.deepOrange.shade100,
                  ),
              ],
              builder: (context, children) =>
                  Wrap(spacing: 8, runSpacing: 8, children: children),
            ),
          ),
        ),
      ],
    );
  }
}

// -- 2. Reorder demo (layout FLIP + staggered reveal) ------------------------

class _ReorderDemo extends StatefulWidget {
  const _ReorderDemo();

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

class _ReorderDemoState extends State<_ReorderDemo> {
  List<int> _items = List.generate(12, (i) => i + 1);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(12),
          child: Row(
            children: [
              FilledButton.icon(
                icon: const Icon(Icons.shuffle),
                label: const Text('Shuffle'),
                onPressed: () => setState(() => _items = [..._items]..shuffle()),
              ),
              const SizedBox(width: 8),
              FilledButton.tonal(
                onPressed: () =>
                    setState(() => _items = _items.reversed.toList()),
                child: const Text('Reverse'),
              ),
            ],
          ),
        ),
        Expanded(
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(12),
            child: MotionGroup(
              // Velocity-preserving physics: shuffle mid-slide and the bubbles
              // carry momentum into the new target instead of restarting.
              spring: const MotionSpring(stiffness: 220, damping: 16),
              stagger: const Duration(milliseconds: 25),
              children: [
                for (final i in _items)
                  _Bubble(key: ValueKey(i), label: '$i'),
              ],
              builder: (context, children) =>
                  Wrap(spacing: 10, runSpacing: 10, children: children),
            ),
          ),
        ),
      ],
    );
  }
}

class _Bubble extends StatelessWidget {
  const _Bubble({super.key, required this.label});
  final String label;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Container(
      width: 60,
      height: 60,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: scheme.primaryContainer,
        borderRadius: BorderRadius.circular(14),
      ),
      child: Text(label,
          style: TextStyle(
              fontSize: 18,
              fontWeight: FontWeight.bold,
              color: scheme.onPrimaryContainer)),
    );
  }
}

// -- 3. Add / remove demo (declarative enter/exit in a Column) ---------------

// -- 4. Shared element demo (magic move, no route change) --------------------

class _SharedDemo extends StatefulWidget {
  const _SharedDemo();

  @override
  State<_SharedDemo> createState() => _SharedDemoState();
}

class _SharedDemoState extends State<_SharedDemo> {
  int? _selected;

  static const _colors = [
    Color(0xFFE53935), Color(0xFFFB8C00), Color(0xFFFDD835),
    Color(0xFF43A047), Color(0xFF1E88E5), Color(0xFF8E24AA),
    Color(0xFF00ACC1), Color(0xFFF4511E), Color(0xFF6D4C41),
  ];

  Widget _tile(int i, {double radius = 16, bool detail = false}) => Container(
        decoration: BoxDecoration(
          color: _colors[i % _colors.length],
          borderRadius: BorderRadius.circular(radius),
        ),
        alignment: Alignment.center,
        child: FittedBox(
          child: Padding(
            padding: const EdgeInsets.all(10),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text('${i + 1}',
                    style: const TextStyle(
                        color: Colors.white, fontWeight: FontWeight.bold)),
                // Extra copy that only the detail shows — the cross-fade
                // dissolves the plain tile into this richer card in flight.
                if (detail)
                  const Padding(
                    padding: EdgeInsets.only(top: 6),
                    child: Text('Detail',
                        style: TextStyle(color: Colors.white70)),
                  ),
              ],
            ),
          ),
        ),
      );

  @override
  Widget build(BuildContext context) {
    // Keep the grid mounted and LAYER the detail on top (don't replace it), so
    // the grid's scroll position and state are preserved — and the shared
    // element flies back to its tile automatically on close.
    return MotionSharedScope(
      duration: const Duration(milliseconds: 420),
      curve: Curves.easeInOutCubic,
      // Dissolve the plain grid tile into the richer detail card during flight
      // (and back) instead of carrying only the destination child.
      crossFade: true,
      child: Stack(
        children: [
          _grid(),
          if (_selected != null) _detail(_selected!),
        ],
      ),
    );
  }

  Widget _grid() {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('Tap a tile — it flies to the detail view (same page, no route).',
              style: Theme.of(context).textTheme.bodyMedium),
          const SizedBox(height: 12),
          Expanded(
            child: GridView.count(
              crossAxisCount: 3,
              mainAxisSpacing: 12,
              crossAxisSpacing: 12,
              children: [
                for (var i = 0; i < 18; i++) // enough to scroll
                  GestureDetector(
                    onTap: () => setState(() => _selected = i),
                    child: MotionSharedId(id: i, child: _tile(i)),
                  ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  Widget _detail(int i) {
    return GestureDetector(
      onTap: () => setState(() => _selected = null),
      child: Stack(
        children: [
          const Positioned.fill(child: ColoredBox(color: Color(0xCC000000))),
          Center(
            child: SizedBox(
              width: 240,
              height: 240,
              child: MotionSharedId(id: i, child: _tile(i, radius: 28, detail: true)),
            ),
          ),
          const Positioned(
            bottom: 24,
            left: 0,
            right: 0,
            child: Text('Tap anywhere to go back',
                textAlign: TextAlign.center,
                style: TextStyle(color: Colors.white70)),
          ),
        ],
      ),
    );
  }
}

class _AddRemoveDemo extends StatefulWidget {
  const _AddRemoveDemo();

  @override
  State<_AddRemoveDemo> createState() => _AddRemoveDemoState();
}

class _AddRemoveDemoState extends State<_AddRemoveDemo> {
  final List<int> _items = [1, 2, 3];
  int _next = 4;
  String _status = 'Add, remove, or clear — watch the lifecycle callbacks.';

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(12),
          child: Row(
            children: [
              FilledButton.icon(
                icon: const Icon(Icons.add),
                label: const Text('Add item'),
                onPressed: () => setState(() => _items.insert(0, _next++)),
              ),
              const SizedBox(width: 8),
              FilledButton.tonalIcon(
                icon: const Icon(Icons.clear_all),
                label: const Text('Clear'),
                // Removing them all at once shows the staggered exit.
                onPressed: _items.isEmpty
                    ? null
                    : () => setState(_items.clear),
              ),
            ],
          ),
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12),
          child: Align(
            alignment: Alignment.centerLeft,
            child: Text(_status,
                style: Theme.of(context).textTheme.bodySmall),
          ),
        ),
        const SizedBox(height: 8),
        Expanded(
          child: SingleChildScrollView(
            padding: const EdgeInsets.symmetric(horizontal: 12),
            child: MotionGroup(
              duration: const Duration(milliseconds: 280),
              // Stagger the exit so a "Clear" cascades instead of vanishing.
              exitStagger: const Duration(milliseconds: 90),
              onEnter: (key) => setState(
                  () => _status = 'Entered ${(key as ValueKey).value}'),
              onExitComplete: (key) => setState(
                  () => _status = 'Removed ${(key as ValueKey).value}'),
              children: [
                for (final i in _items)
                  Card(
                    key: ValueKey(i),
                    margin: const EdgeInsets.only(bottom: 8),
                    child: ListTile(
                      leading: CircleAvatar(child: Text('$i')),
                      title: Text('Item $i'),
                      subtitle: const Text('Delete me — I fade out, the rest slide up.'),
                      trailing: IconButton(
                        icon: const Icon(Icons.delete_outline),
                        onPressed: () => setState(() => _items.remove(i)),
                      ),
                    ),
                  ),
              ],
              builder: (context, children) => Column(children: children),
            ),
          ),
        ),
      ],
    );
  }
}
3
likes
160
points
236
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Declarative layout & presence animations — enter/exit + FLIP reflow for any layout. A Framer Motion for Flutter.

Repository (GitHub)
View/report issues

Topics

#ui #animation #layout #transitions

License

MIT (license)

Dependencies

flutter

More

Packages that depend on flip_layout