đ Card Fan Spread
A Flutter card component that reproduces how a magician spreads a deck: table
spread (dealer style), fan, S fan (cardistry: the packet bends into an
S) and two-hand fan (one hand holds each half, each fanning outwards) â all
with a per-card cascading animation. One direction flips all four
(rightToLeft gives the reverse fan); the fan modes also take a double-tier
offset for cardistry's giant fan, and a movable pivot, so the same deck
can fan off-axis or hang from a point below the cards.
Language: English | 䏿
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
⨠Features
- Four styles à two directions â
FanSpreadMode.tableSpread/fan/sFan/twoHandFan, sharingSpreadDirection.leftToRight/rightToLeft. - Two-hand fan â each hand holds its own half and fans outwards about its own
grip (it stays two stacks when collapsed); the two fans cross at the top and the
bottom.
fanAngleis the sweep of each side, capped atkMaxTwoHandFanAngle= 220°, and past 90° each fan is centred on its own side. No double-tier offset. - S fan â the pinches march along an S-shaped spine and the cards radiate
perpendicular to it;
fanAngle(0°â540°) is the bend, so it can coil past a full turn. - Giant fan â
fanTierRatiopushes every odd-indexed card outwards into an inner / outer double-tier spread. Fan modes only. - Movable pivot â
fanPivotX/fanPivotLiftmove the centre of rotation along (or below) the card edge, for off-axis or hanging fans. Every card still rotates about the same relative point. Fan / two-hand fan; the S fan ignores them. - Cascading animation â cards leave the stack one after another, and the whole sequence can be played in reverse to collapse the deck.
- Gestures â double-tap toggles play / collapse; in the table spread, drag the
top card and the deck follows your finger (the drag direction also picks the
spread direction); in the fan modes, drag the top card to change
fanAngle. - Images are yours â one card back (optional) plus optional 52 faces (reused
cyclically), switchable via
showFace; with no image â or before it decodes â every card is a blank white card, never a black block. - Smooth at 60fps â image + rounded corners + shadow + border are pre-composited once into a sprite, so each frame blits 52 cards and nothing else; back â face cross-fades and shadows fade with the real overlap instead of popping.
đ Getting started
Live Demo â try it out online
Wrap it in bounded constraints, hand it a card back, and you have a spread:
CardFanSpread(
mode: FanSpreadMode.fan,
cardBack: const AssetImage('assets/cards/card_back.png'),
);
đ Usage
đ Basic
final controller = CardFanSpreadController();
CardFanSpread(
mode: FanSpreadMode.fan, // or tableSpread / sFan / twoHandFan
direction: SpreadDirection.leftToRight, // rightToLeft = reverse fan / mirrored S / right hand first
controller: controller,
cardBack: const AssetImage('assets/cards/card_back.png'),
cardFaces: faceAssets.map(AssetImage.new).toList(), // 52 cards
showFace: true, // true = faces, false = back
fanAngle: 220, // sweep in degrees (0 = collapsed; cap 360 / 540 / 220 by mode)
fanTierRatio: 0.22, // double-tier offset (giant fan): 0 = single tier
fanPivotX: 0.5, // pivot along the card edge: 0 = left edge, 1 = right edge
fanPivotLift: -0.06, // pivot depth / card height (negative = inside the card)
duration: const Duration(milliseconds: 1800),
stagger: 0.55, // how much each card trails the previous one
);
Omit controller and the widget drives its own animation (with autoPlay
deciding whether it spreads on mount).
đŽ Controlling the animation
CardFanSpreadController drives the open/close animation:
controller.play(); // spread (replays if already fully open)
controller.replay(); // always spread from the collapsed state
controller.close(); // collapse
controller.toggle(); // open / close
controller.reset(); // jump back to collapsed instantly
controller.seekTo(0.5); // jump the progress to a value and stop the animation
Read the state with controller.progress (0 = collapsed, 1 = fully spread),
controller.isAnimating and controller.isOpened. One controller can be bound to
several CardFanSpread widgets at once (e.g. a thumbnail + the main view):
commands reach all of them, the getters read the most recently bound one.
đ Gestures
- Double-tap the canvas to toggle play / collapse (
doubleTapToToggle, on by default). With a controller bound it drives every widget bound to that controller; switch it off andonDoubleTapstill fires. - Table spread drag â drag the top card and the deck follows your finger
(
tableDragToSpread): the finger travel is projected onto the "collapsed â final" axis and mapped back onto the overall progress. Letting go leaves the deck where it is â it does not finish the animation on its own. - A collapsed drag picks the direction â drag right (table spread) or
clockwise (fan) â
leftToRight, the other way ârightToLeft; a fan pushed past the collapsed angle flips as well, so it is never stuck at 0°. The widget suggests the direction throughonDirectionChanged; feed it back intodirectionand your state wins. The S fan and the two-hand fan opt out. - Fan angle drag â an open fan's top card drags the angle instead
(
onFanAngleChanged); feed the value back intofanAngle.
đŧī¸ Card images
Only cardBack is optional (though you will normally pass one). To show faces,
pass cardFaces (52 images, or any number â reused cyclically) and set
showFace: true; switching it cross-fades back â face over 280ms, so the picture
never jumps in brightness. With no image at all â cardBack omitted, still
decoding, or failed to decode â every card is drawn as a blank white card
(kCardBlankColor): a missing asset gives you a white deck, not a black one.
đ Layout
The component needs bounded constraints â wrap it in SizedBox /
Expanded / AspectRatio, or place it in an ordinary box layout. Inside a parent
that offers unbounded width (e.g. a Row) it falls back to a built-in 400Ã600
canvas instead of crashing.
đ API reference
đ§Š CardFanSpread
| Property | Type | Default | Modes | Description |
|---|---|---|---|---|
cardBack |
ImageProvider? |
null |
all | Card back. Omitted (or still decoding) â every card is drawn as a blank white card (kCardBlankColor). |
cardFaces |
List<ImageProvider>? |
null |
all | Face images (typically 52, reused cyclically). Empty or showFace: false â all cards show the back. |
mode |
FanSpreadMode |
FanSpreadMode.fan |
â | Spreading style (four layouts). |
direction |
SpreadDirection |
SpreadDirection.leftToRight |
all | Table spread â stacking order (right half on top); fan â which end it grows from (rightToLeft = reverse fan); S fan â the mirrored S; two-hand fan â which hand moves first and which half ends up on top. |
controller |
CardFanSpreadController? |
null |
all | External controller. Without one, the widget drives its own animation. |
showFace |
bool |
false |
all | true shows card faces, false shows the back. |
cardCount |
int |
52 |
all | Cards taking part in the spread (âĨ 2). |
fanAngle |
double |
220 |
fan / S fan / two-hand | Sweep in degrees; 0 = fully collapsed (every card's target angle is 0, so it ends up as one stacked deck). Cap: kMaxFanAngle 360 (fan â beyond a full turn it just wraps onto itself), kMaxSFanAngle 540 (S fan: the bend of the spine, which can coil past a full turn), kMaxTwoHandFanAngle 220 per side (two-hand: at 0° the result is two stacks, one per hand). |
fanTierRatio |
double |
0 |
fan | Double-tier offset (giant fan): how far the outer tier is pushed along the card's long axis / card height, 0âkMaxFanTierRatio (0.65); 0 = single tier. |
fanPivotX |
double |
0.5 |
fan / two-hand | Pivot (centre of rotation) along the card width, 0â1 (0 = left edge, 0.5 = middle, 1 = right edge); sliding it to one side gives an off-axis fan. |
fanPivotLift |
double |
-0.06 |
fan / two-hand | Pivot depth relative to the bottom edge / card height, kMinFanPivotLiftâkMaxFanPivotLift (â0.5â1.0): 0 = on the edge, positive sinks below it (cards hang from the pivot), negative lifts it into the card (the default is where a thumb pinches the fan). |
tableStepRatio |
double |
0.09 |
table | Offset between neighbours / card width (smaller = deeper overlap). |
tableArcLift |
double |
-0.6 |
table | How much the ends lift relative to the middle / card height. Positive lifts the ends, negative dips them. |
shadowColor |
Color |
Color(0x24000000) |
all | Drop shadow between cards (~14% black). Colors.transparent disables it. |
spriteMemoryBudget |
int |
kSpriteMemoryBudget = 64 MB |
all | Memory budget for the pre-composited sprites; past it the compositing ratio is scaled down (sharpness for memory). Raise it for many distinct faces, lower it on memory-tight targets such as the web. |
duration |
Duration |
Duration(milliseconds: 1800) |
all | Duration of a full spread. |
curve |
Curve |
Curves.easeOutCubic |
all | Overall easing curve. |
stagger |
double |
0.55 |
all | Per-card delay factor (0â2). 0 = all cards move together. |
autoPlay |
bool |
true |
all | Whether the spread plays automatically on mount. |
doubleTapToToggle |
bool |
true |
all | Whether double-tapping the canvas toggles play / collapse. |
tableDragToSpread |
bool |
true |
table | Whether dragging the top card opens / closes the spread under your finger. |
onDirectionChanged |
ValueChanged<SpreadDirection>? |
null |
table / fan | Suggests a direction when a collapsed spread is dragged the other way (feed it back into direction): right / clockwise â leftToRight, the other way â rightToLeft. One flip per crossing of the collapsed position; the S fan / two-hand fan never infer it. |
onDoubleTap |
VoidCallback? |
null |
all | Called on double-tap, whether or not doubleTapToToggle is on. |
onFanAngleChanged |
ValueChanged<double>? |
null |
fan / two-hand / S | Called when the top card is dragged to change the angle (clamped to kMinFanAngle âĻ the mode's cap); feed it back into fanAngle. If the deck is not spread yet, the drag plays the spread too. The S fan's top card sits at the end of the packet, so it is twice as sensitive (1° of card = 4° of angle). The touch must land on the top card. |
Shadows are deliberately light: with 52 heavily overlapping cards they would stack up into a dark frame, so the separation is carried mostly by the card borders.
đŽ CardFanSpreadController
| Member | Type | Description |
|---|---|---|
play() |
method | Spread; replays from the start if already fully open. |
replay() |
method | Always spread from the collapsed state. |
close() |
method | Collapse (reverse the animation to 0). |
toggle() |
method | Open if collapsed, collapse if open. |
reset() |
method | Jump back to the collapsed state instantly. |
seekTo(value) |
method | Jump the progress to a value and stop the animation. |
progress |
double |
Current progress, 0 = collapsed, 1 = fully spread (most recently bound widget). |
isAnimating |
bool |
Whether an animation is playing. |
isOpened |
bool |
Whether the deck is fully spread. |
đ Enums
| Value | Enum | Description |
|---|---|---|
tableSpread |
FanSpreadMode |
Dealer-style spread into an arc. |
fan |
FanSpreadMode |
Every card is pinned to one pivot and spreads by rotation alone. |
sFan |
FanSpreadMode |
The pinches march along an S-shaped spine and the packet bends into a reversed curve. |
twoHandFan |
FanSpreadMode |
One hand holds each half â the left half fans left, the right half fans right, each about its own hand. |
leftToRight |
SpreadDirection |
Table spread: right half on top. Fan: opens from the left. S fan: the default S. Two-hand fan: left hand first, right half on top. |
rightToLeft |
SpreadDirection |
Table spread: left half on top. Fan: opens from the right (the reverse fan). S fan: the mirrored S. Two-hand fan: right hand first, left half on top. |
⥠Performance
52 cards at 60fps is easy to drop frames on, so rendering is optimized:
- Pre-composited sprites: card image + rounded corners + shadow blur + border
are rendered once off-screen into a single bitmap, so each frame does one
drawImageRectper card. All cards share the same sources, so the cache holds one entry per distinct source image, not per card. - Geometry computed at build time: card size, bounding-box fit and every
card's target position / angle are resolved once in
src/card_layout.dart; the paint phase only applies transforms and blits. - No rebuilds while animating: progress is read live from the
Animation<double>driving the painter (super.repaint: animation), andPaintobjects are reused instead of being allocated per frame. - Coincident cards are blitted once: while the deck is stacked (and for the cards the stagger has not released yet) the duplicates share one transform and are skipped â a collapsed 52-card fan drops from 52 blits per frame to 2. Not skipped during the back/face cross-fade, where the two layers blend.
- Anti-aliased blitting: every
drawImageRectusesFilterQuality.medium(mipmap + bilinear), so rotated and fractionally scaled cards keep smooth edges instead of stair-stepping.
When the available size changes (window resize, card-count slider) the sprites are
re-composited at the new size, on a geometric ladder of size rungs rather than per
pixel: a full fan-angle drag re-composites ~3â5 times instead of ~50, and cards
are always over-sampled, never blurry. Sources that are no longer used are evicted,
and spriteMemoryBudget keeps the peak in check. Until a re-composite is ready the
per-frame path is used, so there is no flicker or blank frame.
âšī¸ Additional information
- Repository: github.com/runoob-coder/card-fan-spread-widget
- Issue tracker: github.com/runoob-coder/card-fan-spread-widget/issues
- Example app: the
example/directory is a full interactive demo that lets you tweak every property in real time â mode, play / replay / collapse, face and back, plus sliders for fan angle, tier offset, pivot position and depth, overlap density, arc lift, card count, duration and stagger. - Contributions: Pull requests and issues are welcome!
- Image credits: the card back images used in the
example/demo come from bicyclecards.com. "Bicycle" and the Bicycle card back designs are trademarks of The United States Playing Card Company (USPCC) and are used here for demonstration purposes only. The package itself ships no assets â card images are always supplied by the caller.
đ License
This package is dual-licensed:
- MIT License (see
LICENSE) â covers all uses except magic-related commercial activities. - Commercial License for magic-related activities (see
LICENSE-COMMERCIAL) â any commercial activity involving magic, conjuring, illusion, cardistry or other magic-related commercial endeavors (including but not limited to the sale, promotion, or monetization of magic performances, magic products, or magic services) requires prior written permission from the copyright holder.
For everything outside the magic commercial scope, the MIT License applies.
đ Support
If card_fan_spread helps you build better UIs, please consider supporting it.
- â Star on GitHub
âī¸ Buy Me a Coffee
| Buy Me a Coffee | Donate with PayPal |
|---|---|
![]() |
![]() |
Libraries
- card_fan_spread
- A Flutter widget that reproduces the card spreads magicians and cardists use: the table spread, the fan, the reverse fan, the cardistry S fan and the two-hand fan.













