card_deck_view 0.0.2 copy "card_deck_view: ^0.0.2" to clipboard
card_deck_view: ^0.0.2 copied to clipboard

A smooth, physics-driven card deck with continuous stack interpolation, controllers, and cross-platform gestures.

example/lib/main.dart

import 'dart:math' as math;

import 'package:card_deck_view/card_deck_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

import 'animal.dart';

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

class CardDeckExample extends StatelessWidget {
  const CardDeckExample({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'Card Deck View',
    debugShowCheckedModeBanner: false,
    theme: ThemeData(
      useMaterial3: true,
      scaffoldBackgroundColor: const Color(0xFFEEEEEE),
      colorScheme: const ColorScheme.light(
        primary: Color(0xFF333333),
        secondary: Color(0xFF777777),
        surface: Color(0xFFEEEEEE),
        onSurface: Color(0xFF333333),
      ),
      splashFactory: NoSplash.splashFactory,
      highlightColor: Colors.transparent,
      textButtonTheme: TextButtonThemeData(
        style: TextButton.styleFrom(
          foregroundColor: const Color(0xFF666666),
          textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w400),
        ),
      ),
      dialogTheme: DialogThemeData(
        backgroundColor: Colors.white,
        surfaceTintColor: Colors.transparent,
        elevation: 0,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
        titleTextStyle: const TextStyle(fontSize: 17, color: Color(0xFF333333)),
        contentTextStyle: const TextStyle(
          fontSize: 14,
          height: 1.6,
          color: Color(0xFF777777),
        ),
      ),
      popupMenuTheme: PopupMenuThemeData(
        color: Colors.white,
        surfaceTintColor: Colors.transparent,
        elevation: 2,
        shadowColor: const Color(0x18000000),
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
      ),
    ),
    home: const DeckGallery(),
  );
}

class DeckGallery extends StatefulWidget {
  const DeckGallery({super.key});
  @override
  State<DeckGallery> createState() => _DeckGalleryState();
}

class _DeckGalleryState extends State<DeckGallery> {
  final _controller = CardDeckController();
  final _deckKey = GlobalKey(debugLabel: 'Responsive animal deck');
  final _jump = TextEditingController();
  late List<Animal> _animals = List.generate(animalNames.length, Animal.new);
  int _page = 0;
  int _visible = 3;
  int _mountedCards = 0;
  bool _statsScheduled = false;
  bool _loop = true;
  CardDeckSwipeBehavior _swipeBehavior =
      CardDeckSwipeBehavior.leftNextRightPrevious;
  CardDeckTransition _transition = CardDeckTransition.cycleToBack;
  double _spacing = 12;
  double _rotation = 0.08;
  double _threshold = 0.28;
  double _damping = 30;
  String _prefetchSignature = '';

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

  void _mounted(int delta) {
    _mountedCards += delta;
    if (_statsScheduled) {
      return;
    }
    _statsScheduled = true;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _statsScheduled = false;
      if (mounted) {
        setState(() {});
      }
    });
  }

  void _prefetch(int index) {
    final signature = '$index/$_visible/${_animals.length}/$_loop';
    if (_prefetchSignature == signature) {
      return;
    }
    _prefetchSignature = signature;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted || signature != _prefetchSignature) {
        return;
      }
      for (
        var depth = 0;
        depth < math.min(_visible + 1, _animals.length);
        depth++
      ) {
        final raw = index + depth;
        if (!_loop && raw >= _animals.length) {
          break;
        }
        precacheImage(
          AssetImage(_animals[raw % _animals.length].asset),
          context,
          onError: (_, _) {},
        );
      }
    });
  }

  void _select(int page) {
    if (page == _page) {
      return;
    }
    _controller.reset();
    setState(() {
      _page = page;
      _animals = List.generate(
        page == 2 ? 1000 : animalNames.length,
        Animal.new,
      );
      _prefetchSignature = '';
    });
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(
      title: const Text(
        'card deck / examples',
        style: TextStyle(
          fontSize: 13,
          letterSpacing: 0.6,
          color: Color(0xFF777777),
        ),
      ),
      titleSpacing: 24,
      centerTitle: false,
      backgroundColor: Theme.of(context).scaffoldBackgroundColor,
      scrolledUnderElevation: 0,
    ),
    body: SafeArea(
      top: false,
      child: SizedBox.expand(
        child: LayoutBuilder(
          builder: (context, constraints) {
            final wide = constraints.maxWidth >= 900;
            if (_page == 1 && wide) {
              return Row(
                children: [
                  Expanded(child: _stage()),
                  SizedBox(
                    width: 300,
                    child: SingleChildScrollView(
                      padding: const EdgeInsets.fromLTRB(16, 24, 24, 24),
                      child: _settingsPanel(),
                    ),
                  ),
                ],
              );
            }
            if (_page == 1) {
              return SingleChildScrollView(
                child: Column(
                  children: [
                    SizedBox(
                      height: math.max(430, constraints.maxHeight - 72),
                      child: _stage(),
                    ),
                    Padding(
                      padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
                      child: ExpansionTile(
                        title: const Text('Deck settings'),
                        tilePadding: EdgeInsets.zero,
                        children: [_settings()],
                      ),
                    ),
                  ],
                ),
              );
            }
            return _stage();
          },
        ),
      ),
    ),
    bottomNavigationBar: SafeArea(
      top: false,
      child: SizedBox(
        height: 64,
        child: Row(
          children: [
            for (final (index, label) in [
              'Animal cards',
              'Playground',
              '1,000 cards',
            ].indexed)
              Expanded(
                child: Semantics(
                  selected: _page == index,
                  child: TextButton(
                    onPressed: () => _select(index),
                    child: Column(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Text(
                          label,
                          style: TextStyle(
                            fontSize: 12,
                            color: _page == index
                                ? const Color(0xFF333333)
                                : const Color(0xFF999999),
                          ),
                        ),
                        const SizedBox(height: 7),
                        Container(
                          height: 2,
                          width: 12,
                          color: _page == index
                              ? const Color(0xFF555555)
                              : Colors.transparent,
                        ),
                      ],
                    ),
                  ),
                ),
              ),
          ],
        ),
      ),
    ),
  );

  Widget _settingsPanel() => Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      const Text(
        'Deck settings',
        style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
      ),
      const SizedBox(height: 24),
      _settings(),
    ],
  );

  Widget _stage() => LayoutBuilder(
    builder: (context, constraints) {
      final width = math.min(360.0, math.max(100.0, constraints.maxWidth - 72));
      final stackSpace = math.max(32.0, _spacing * (_visible - 1) + 16);
      final height = math.min(
        width * 1.32,
        math.max(180.0, constraints.maxHeight - 170 - stackSpace),
      );
      _prefetch(_controller.currentIndex);
      return SingleChildScrollView(
        child: ConstrainedBox(
          constraints: BoxConstraints(
            minWidth: constraints.maxWidth,
            minHeight: constraints.maxHeight,
          ),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              const SizedBox(height: 16),
              SizedBox(
                width: width,
                height: height,
                child: CardDeckView<Animal>(
                  key: _deckKey,
                  items: _animals,
                  controller: _controller,
                  visibleCount: _visible,
                  loop: _loop,
                  swipeBehavior: _swipeBehavior,
                  transition: _transition,
                  itemKeyBuilder: (animal) => animal.index,
                  style: CardDeckStyle(
                    borderRadius: 32,
                    rotationStep: -0.018,
                    shadows: const [
                      BoxShadow(
                        color: Color(0x16000000),
                        blurRadius: 26,
                        offset: Offset(0, 9),
                      ),
                    ],
                    spacing: _spacing,
                    dragRotation: _rotation,
                    maxRotation: _rotation,
                  ),
                  physics: CardDeckPhysics(
                    swipeThreshold: _threshold,
                    spring: SpringDescription(
                      mass: 1,
                      stiffness: 300,
                      damping: _damping,
                    ),
                  ),
                  onChanged: (index) {
                    if (mounted) {
                      setState(() {});
                    }
                  },
                  emptyBuilder: (_) => Container(
                    decoration: BoxDecoration(
                      color: const Color(0xFFE6E9E2),
                      borderRadius: BorderRadius.circular(24),
                    ),
                    child: Center(
                      child: Column(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          const Text('No more cards'),
                          TextButton(
                            onPressed: _controller.reset,
                            child: const Text('Start again'),
                          ),
                        ],
                      ),
                    ),
                  ),
                  itemBuilder: (_, animal, _) => AnimalCard(
                    animal: animal,
                    onReset: _controller.reset,
                    onMount: () => _mounted(1),
                    onUnmount: () => _mounted(-1),
                  ),
                ),
              ),
              SizedBox(height: stackSpace),
              ListenableBuilder(
                listenable: _controller,
                builder: (context, _) => Text(
                  '${math.min(_controller.currentIndex + 1, _animals.length)} / ${_animals.length}',
                  style: const TextStyle(
                    fontSize: 13,
                    color: Color(0xFF6E756C),
                  ),
                ),
              ),
              const SizedBox(height: 10),
              Wrap(
                alignment: WrapAlignment.center,
                spacing: 8,
                children: [
                  IconButton(
                    tooltip: 'Previous card',
                    onPressed: _controller.previous,
                    icon: const Icon(CupertinoIcons.arrow_left, size: 18),
                  ),
                  TextButton.icon(
                    onPressed: _controller.next,
                    icon: const Icon(CupertinoIcons.arrow_right, size: 18),
                    label: const Text('Next'),
                  ),
                  PopupMenuButton<String>(
                    tooltip: 'More actions',
                    icon: const Icon(CupertinoIcons.ellipsis, size: 20),
                    onSelected: (action) {
                      switch (action) {
                        case 'reset':
                          _controller.reset();
                        case 'jump':
                          _showJump();
                        case 'credits':
                          _credits();
                      }
                    },
                    itemBuilder: (_) => const [
                      PopupMenuItem(value: 'reset', child: Text('Reset deck')),
                      PopupMenuItem(value: 'jump', child: Text('Jump to card')),
                      PopupMenuItem(
                        value: 'credits',
                        child: Text('About the artwork'),
                      ),
                    ],
                  ),
                ],
              ),
              if (_page == 2)
                Padding(
                  padding: const EdgeInsets.only(top: 8),
                  child: Text(
                    'Mounted: $_mountedCards / ${_visible + 1} maximum',
                    style: const TextStyle(fontSize: 12),
                  ),
                ),
            ],
          ),
        ),
      );
    },
  );

  Widget _settings() => Column(
    children: [
      DropdownButtonFormField<CardDeckTransition>(
        initialValue: _transition,
        isExpanded: true,
        decoration: const InputDecoration(labelText: 'Transition'),
        items: const [
          DropdownMenuItem(
            value: CardDeckTransition.slideOut,
            child: Text('Slide out'),
          ),
          DropdownMenuItem(
            value: CardDeckTransition.cycleToBack,
            child: Text('Cycle to back'),
          ),
        ],
        onChanged: (value) {
          if (value != null) setState(() => _transition = value);
        },
      ),
      const SizedBox(height: 12),
      DropdownButtonFormField<CardDeckSwipeBehavior>(
        initialValue: _swipeBehavior,
        isExpanded: true,
        decoration: const InputDecoration(labelText: 'Swipe behavior'),
        items: const [
          DropdownMenuItem(
            value: CardDeckSwipeBehavior.advanceBothDirections,
            child: Text('Both advance'),
          ),
          DropdownMenuItem(
            value: CardDeckSwipeBehavior.leftNextRightPrevious,
            child: Text('Left next / right previous'),
          ),
        ],
        onChanged: (value) {
          if (value != null) setState(() => _swipeBehavior = value);
        },
      ),
      const SizedBox(height: 20),
      _slider(
        'Visible cards',
        _visible.toDouble(),
        1,
        8,
        (v) => _visible = v.round(),
        divisions: 7,
      ),
      _slider('Stack spacing', _spacing, 0, 20, (v) => _spacing = v),
      _slider('Drag rotation', _rotation, 0, 0.15, (v) => _rotation = v),
      _slider('Swipe threshold', _threshold, 0.1, 0.6, (v) => _threshold = v),
      _slider('Spring damping', _damping, 12, 45, (v) => _damping = v),
      const SizedBox(height: 12),
      Row(
        children: [
          const Expanded(
            child: Text(
              'Infinite loop',
              style: TextStyle(fontSize: 13, color: Color(0xFF666666)),
            ),
          ),
          CupertinoSwitch(
            activeTrackColor: const Color(0xFF555555),
            value: _loop,
            onChanged: (v) => setState(() => _loop = v),
          ),
        ],
      ),
    ],
  );

  Widget _slider(
    String label,
    double value,
    double min,
    double max,
    ValueChanged<double> update, {
    int? divisions,
  }) => Column(
    children: [
      Row(
        children: [
          Expanded(
            child: Text(
              label,
              style: const TextStyle(fontSize: 13, color: Color(0xFF666666)),
            ),
          ),
          Text(value.toStringAsFixed(max < 1 ? 2 : 0)),
        ],
      ),
      SizedBox(
        width: double.infinity,
        child: CupertinoSlider(
          activeColor: const Color(0xFF666666),
          value: value,
          min: min,
          max: max,
          divisions: divisions,
          onChanged: (v) => setState(() => update(v)),
        ),
      ),
    ],
  );

  Future<void> _showJump() async {
    _jump.text = '${math.min(_controller.currentIndex + 1, _animals.length)}';
    final index = await showDialog<int>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Jump to card'),
        content: TextField(
          controller: _jump,
          autofocus: true,
          keyboardType: TextInputType.number,
          decoration: InputDecoration(labelText: 'Card 1–${_animals.length}'),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Cancel'),
          ),
          TextButton(
            onPressed: () {
              final number = int.tryParse(_jump.text);
              if (number != null && number >= 1 && number <= _animals.length) {
                Navigator.pop(context, number - 1);
              }
            },
            child: const Text('Go'),
          ),
        ],
      ),
    );
    if (mounted && index != null) {
      _controller.moveTo(index);
    }
  }

  void _credits() {
    showDialog<void>(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('About the artwork'),
        content: SelectableText(
          'Twelve animal illustrations from the supplied reference sheet, prepared as transparent local assets.\n\nCard content is built with a custom AnimalCard widget.\n\nHandwritten font: Caveat (SIL Open Font License).\nThe illustrations and font load locally; no connection is needed.',
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }
}
1
likes
160
points
80
downloads

Documentation

API reference

Publisher

verified publisherleewei0923.com

Weekly Downloads

A smooth, physics-driven card deck with continuous stack interpolation, controllers, and cross-platform gestures.

Repository (GitHub)
View/report issues

Topics

#animation #cards #swipe

License

MIT (license)

Dependencies

flutter

More

Packages that depend on card_deck_view