card_deck_view 0.0.2
card_deck_view: ^0.0.2 copied to clipboard
A smooth, physics-driven card deck with continuous stack interpolation, controllers, and cross-platform gestures.
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'),
),
],
),
);
}
}