card_fan_spread 1.0.0
card_fan_spread: ^1.0.0 copied to clipboard
A Flutter card component that reproduces magician deck spreads: table spread, fan, reverse fan, the cardistry S fan and the two-hand fan, with an optional double-tier offset (giant fan) and a movable [...]
đ Card Fan Spread #
A Flutter card component that reproduces how a magician spreads a deck:
table spread (dealer style), fan, S fan (a cardistry fan whose
packet bends into an S) and two-hand fan (one hand holds each half, each
fanning outwards) â four layouts whose left/right direction is a single
direction (rightToLeft turns the fan into the reverse fan), all with a
per-card cascading animation. The fan modes also support a tunable
double-tier offset â turn it on and you get cardistry's giant fan,
far bigger than an ordinary round fan â plus a movable pivot: the fan's
centre of rotation no longer has to sit at the middle of the card edge, so the
same deck can fan off-axis or hang from a point below the cards.
Language: English | 䏿
![]() |
![]() |
![]() |
![]() |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
⨠Features #
- Four spreading styles à two directions â
FanSpreadMode:tableSpread(dealer style),fan,sFanandtwoHandFan, switchable at any time; the left/right direction is a singledirection(SpreadDirection.leftToRight/rightToLeft) shared by all four. - Two-hand fan â one hand holds each half and each half fans outwards about
its own hand, staying a separate stack even when collapsed; the two fans
cross at the top and the bottom. Same parameters as the fan modes (
fanAngleis the sweep of each side, capped atkMaxTwoHandFanAngle= 220°), and past 90° each fan stays centred on its own side and splits the space evenly. No double-tier offset. - S fan â cardistry's S Fan: instead of pinning every card to one pivot,
the pinches march along an S-shaped spine and the cards radiate
perpendicular to it, spreading the packet into an upright S-shaped band. Its
bend is tuned with
fanAngle(0°â540°: it can coil past a full turn), and dragging the top card opens / closes it at twice the sensitivity. - Double-tier offset (giant fan) â one extra parameter (
fanTierRatio) on the fan modes pushes every odd-indexed card outwards, turning the fan into an inner / outer double-tier spread: cardistry's giant fan (Giant Fan / Giant Pressure Fan), two Faro-interleaved packets fanned out. (The S fan and the two-hand fan ignore it.) - Movable pivot â
fanPivotX/fanPivotLiftmove the fan's centre of rotation: slide it along the card edge for an off-axis fan, or sink it below the edge so the cards hang from the pivot. 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 back.
- Adjustable geometry â fan sweep
fanAngle(0°â360° for the fan modes, 0°â540° for the S fan, 0°â220° per side for the two-hand fan), double-tier offsetfanTierRatio, pivotfanPivotX/fanPivotLift, plus table spread densitytableStepRatioand arctableArcLift. - Images are supplied by you â one card back (optional) + optional 52 faces
(reused cyclically), switchable at any time via
showFace. With no image (or while it is still decoding / failed to decode) every card is drawn as a blank white card instead of a black block. - Smooth transitions everywhere â cross-fade when switching back/face, and shadows/borders that fade in and out with the actual overlap instead of popping in and out.
- Sprite pre-compositing â card image + rounded corners + shadow blur + border are rendered once off-screen; 52 cards stay smooth at 60fps.
- Double-tap to open / close â double-tapping the canvas toggles the spread
(
doubleTapToToggle, on by default); no button wiring needed. - Drag to control â in the table spread, drag the top card and the deck
follows your finger (
tableDragToSpread, on by default), and the drag direction picks the spread direction (onDirectionChanged); in the fan modes, drag the top card to change the angle (onFanAngleChanged).
đ Getting started #
Live Demo â try it out online
Images: one card back (cardBack, optional). Optionally pass 52 card faces
(cardFaces; fewer images are reused cyclically) to enable face/back switching.
Without a back image â or before it finishes decoding (missing asset, still
loading) â every card is drawn as a blank white card.
đ 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, // fan sweep in degrees (0 = fully collapsed; up to 360,
// or 540 for the S fan, whose bend coils past a full turn)
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 below the bottom edge / card height (negative = inside the card)
duration: const Duration(milliseconds: 1800),
stagger: 0.55, // how much each card trails the previous one
);
controller.play(); // spread (replays if already fully open)
controller.close(); // collapse
controller.toggle(); // open / close
Passing no controller is fine too â the widget then drives its own animation
(and autoPlay decides 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 current state with controller.progress (0 = collapsed, 1 = fully
spread), controller.isAnimating and controller.isOpened.
The same controller can be bound to multiple CardFanSpread widgets at once
(e.g. a thumbnail preview + the main view): commands apply to all of them,
while the getters read the most recently bound one.
đ Double-tapping the canvas #
By default a double-tap on the canvas toggles play / collapse: it collapses when
the deck is fully spread, otherwise it spreads (and turns a collapse back into a
spread). With a controller bound, the double-tap drives every widget bound
to that controller. Pass doubleTapToToggle: false to switch the built-in
behaviour off â onDoubleTap still fires, so you can hook your own logic.
CardFanSpread(
controller: controller,
doubleTapToToggle: true, // this is the default
onDoubleTap: () => debugPrint('double tapped'),
);
đī¸ Dragging the top card (table spread) #
In the table spread you can drag the top card and the deck follows your finger
(tableDragToSpread, on by default): the finger movement is projected onto the
"collapsed position â final position" axis, and that fraction of the whole travel
is how far the top card has gone â which is then mapped back onto the overall
progress (stagger and easing included). Letting go leaves the deck exactly
where it is â it does not finish the animation on its own. Pass
tableDragToSpread: false to switch it off.
// From code: jump the progress to a value and stop any running animation.
controller.seekTo(0.5); // half spread, held there
đ§ A collapsed drag picks the direction #
Table spread â while it is collapsed, the drag direction decides which way it fans out, and the top card follows your finger: drag right â left-to-right (the top card becomes the rightmost one and the ribbon opens to the right), drag left â right-to-left. The deck keeps opening as your finger keeps moving that way.
Fan â the same rule whenever the fan looks collapsed: drag the top card
clockwise â left-to-right, anticlockwise â right-to-left, and the same
gesture then keeps opening the fan on the other side. "Looks collapsed" covers
both fanAngle = 0 and a deck closed with the controller (progress 0, while
fanAngle may still read 220°). Dragging an open fan closed and pushing on in
the same direction flips it too, so it is never stuck at 0°; merely closing it
to 0° and stopping there does not flip.
The widget suggests the direction â you feed it back:
CardFanSpread(
direction: _direction, // your state
onDirectionChanged: (d) => setState(() => _direction = d),
);
Wire the callback and your state wins; without it the widget flips its own effective direction, otherwise a collapsed fan dragged the other way would be pinned at 0° and do nothing at all. The table spread only decides while collapsed â an already-spread deck just opens / closes under your finger. The fan decides at the collapsed angle, or when you push past it, and dragging back and forth flips it every time the top card crosses the collapsed position (the accumulated rotation resets on each flip, so a still finger never flips repeatedly). The S fan and the two-hand fan opt out: the S fan's top card sits at the end of the packet and the two-hand fan already has one fan per hand, so "the other side" has no clear meaning.
An open fan drags the top card to change fanAngle instead (see
onFanAngleChanged).
đŧī¸ Card images #
Only cardBack is optional (though you will normally pass one). To show faces,
pass cardFaces (52 images, or any number â they are reused cyclically) and
flip showFace:
CardFanSpread(
cardBack: const AssetImage('assets/cards/card_back.png'),
cardFaces: faceAssets.map(AssetImage.new).toList(),
showFace: true,
);
It also works with no image at all: when cardBack is omitted (or the image is
still decoding / failed to decode) every card is drawn as a blank white card
(kCardBlankColor) instead of a black block â a missing asset gives you a white
deck, not a black one.
Switching showFace cross-fades back â face over 280ms, so the whole picture
never jumps in brightness.
đ Layout #
The component needs bounded constraints â wrap it in SizedBox /
Expanded / AspectRatio, or place it in an ordinary box layout. When used
directly inside a parent that offers unbounded width (e.g. a Row), it falls
back to a built-in 400Ã600 canvas instead of crashing.
đī¸ Parameters #
| Parameter | Description | Applies to |
|---|---|---|
fanAngle |
Fan sweep (0 = fully collapsed: every card's target angle is 0, so the spread ends up as one stacked deck). The cap depends on the mode: 0â360° for the fan modes (kMaxFanAngle â beyond a full turn the fan merely wraps back onto itself), 0â540° for the S fan (kMaxSFanAngle). For the S fan it is the bend of the packet: the spine's tangent turns out by half of it and comes back, and the cards (perpendicular to the spine) swing by the same amount â that bend can coil past a full turn (360° is the canonical S, ~450° is the fullest, and 540° is the limit before the two bows close into an "8"). For the two-hand fan it is the sweep of each side, capped at kMaxTwoHandFanAngle (220°); at 0° neither fan is open, but each hand still holds its own half, so the spread ends up as two stacks rather than one, and from 90° up each fan is centred on its own side (dividing the space evenly above and below) |
fan / S fan / two-hand fan |
fanTierRatio |
Double-tier offset: how far the outer tier is pushed along the card's long axis / card height (0â0.65, 0 = single-tier fan) | fan (ignored by the S fan / two-hand fan) |
fanPivotX |
Pivot (the fan's centre of rotation) along the card's width: 0 = left edge, 0.5 = middle of the edge (default), 1 = right edge. Every card rotates about the same relative point, so sliding it to one side gives an off-axis fan | fan / two-hand fan (ignored by the S fan) |
fanPivotLift |
Pivot depth relative to the bottom edge / card height: 0 = exactly on the edge; positive sinks it below the edge (the cards hang from the pivot); negative lifts it into the card (default â0.06, where a thumb pinches the fan). Range â0.5â1.0 | fan / two-hand fan (ignored by the S fan) |
direction |
Left/right direction, shared by all four modes: table spread â stacking order (with leftToRight the right half is on top); fan â which end the fan grows from (rightToLeft is the reverse fan); S fan â the mirrored S; two-hand fan â which hand moves first and which half ends up on top (leftToRight = left hand first, right half on top) |
all |
tableStepRatio |
Offset between neighbours / card width (smaller = deeper overlap) | table spread |
tableArcLift |
How much the ends lift relative to the middle / card height. Positive lifts the ends, negative dips them | table spread |
stagger |
Per-card delay factor, 0 = all cards move together | all |
shadowColor |
Drop shadow between cards, ~14% black by default. Pass Colors.transparent to disable |
all |
spriteMemoryBudget |
Memory budget (bytes) for the pre-composited sprites, kSpriteMemoryBudget = 64 MB by default. When the estimate exceeds 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 |
all |
curve |
Overall easing curve | all |
autoPlay |
Whether the spread plays automatically on mount | all |
doubleTapToToggle |
Whether double-tapping the canvas toggles play / collapse (default true) | all |
tableDragToSpread |
Whether dragging the top card opens / closes the spread under your finger in the table spread (default true) | table spread |
onDirectionChanged |
Suggests a direction when a collapsed spread is dragged the other way; feed it back into direction. Table spread: drag right â leftToRight, drag left â rightToLeft. Fan: dragging the collapsed top card clockwise â leftToRight, anticlockwise â rightToLeft (the top card follows your finger). One flip per drag; a fan that is already open only changes its angle |
table spread / fan |
onFanAngleChanged |
Called when the top card is dragged to change the fan angle (clamped to kMinFanAngle âĻ the current mode's cap: 540° for the S fan, 220° for the two-hand fan, 360° otherwise); feed the value back into fanAngle. If the deck is not spread yet, dragging also plays the spread animation |
fan / two-hand fan / S fan (the S fan's top card sits at the end of the packet, so its sensitivity is double: it turns 1° per 4° of fan angle) |
Shadows are deliberately light: with 52 heavily overlapping cards the shadows would otherwise stack up into a dark frame, so the separation is carried mostly by the card borders.
⥠Performance #
52 cards at 60fps is easy to drop frames on, so rendering is optimized on four levels:
- Pre-composited sprites: card image + rounded corners + shadow blur + border
are rendered once off-screen into a single bitmap; each frame then only does one
drawImageRectper card. Because all cards share the same source images, 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), so the widget tree is never rebuilt;Paintobjects are reused instead of allocating dozens per frame. - Coincident cards are blitted once: while the deck is still stacked (and for the cards the stagger has not released yet) every card shares the same transform and the same layer, so the duplicates are skipped â a collapsed 52-card fan goes from 52 blits per frame to 2. (Skipped during the back/face cross-fade, where the two layers are stacked semi-transparently.)
- Anti-aliased blitting: every
drawImageRectusesFilterQuality.medium(mipmap + bilinear) â both for the sprites and for the per-frame fallback path that downscales the source image before sprites are ready. Rotated cards (fan modes) and fractional-size scaling therefore keep smooth edges instead of stair-stepping.
When the available size changes (window resize, card-count slider), sprites are re-composited at the new size; until they are ready the per-frame path is used, so there is no flicker or blank frame.
That re-compositing is rung-based rather than per-pixel: dragging the fan angle changes the card size continuously (306px â 614px over a full 0â360° sweep), and re-compositing every 4px would mean ~50 full rebuilds of the sprite set per drag â thousands of off-screen renders with 52 distinct faces. Instead the size snaps up the next rung of a geometric ladder when it grows (so cards are always over-sampled, never blurry) and only drops a rung once the cards have shrunk by more than 1/1.35: a full drag now re-composites ~3â5 times instead of ~50. Sources that are no longer used (faces turned off, another card back, fewer cards) are evicted from the cache instead of staying resident, and the budget above keeps the peak in check.
đ API reference #
đ§Š CardFanSpread #
| Property | Type | Default | Description |
|---|---|---|---|
cardBack |
ImageProvider? |
null |
Card back image. When omitted (or while it is still decoding) every card is drawn as a blank white card (kCardBlankColor). |
cardFaces |
List<ImageProvider>? |
null |
Card face images (typically 52, reused cyclically). When empty or showFace is false, all cards show the back. |
mode |
FanSpreadMode |
FanSpreadMode.fan |
Spreading style (four layouts). |
direction |
SpreadDirection |
SpreadDirection.leftToRight |
Left/right direction shared by all four modes: table spread â stacking order; fan â which end it grows from; S fan â mirrored S; two-hand fan â which hand moves first and which half ends up on top. |
controller |
CardFanSpreadController? |
null |
Optional external controller. Without one, the widget drives its own animation. |
showFace |
bool |
false |
true shows card faces, false shows the back. |
cardCount |
int |
52 |
Number of cards taking part in the spread (âĨ 2). |
fanAngle |
double |
220 |
Fan sweep in degrees (0 = fully collapsed). Capped per mode: 0â[kMaxFanAngle] = 360 for the fan modes, 0â[kMaxSFanAngle] = 540 for the S fan, whose bend coils past a full turn. |
fanTierRatio |
double |
0 |
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 fan). Fan only; ignored by the S fan. |
fanPivotX |
double |
0.5 |
Pivot (the fan's centre of rotation) along the card's width, 0â1 (0 = left edge, 0.5 = middle of the edge, 1 = right edge). Every card rotates about the same relative point, so sliding the pivot to one side gives an off-axis fan. Fan only; ignored by the S fan. |
fanPivotLift |
double |
-0.06 |
Pivot depth relative to the bottom edge / card height ([kMinFanPivotLift]â[kMaxFanPivotLift] = â0.5â1.0: 0 = on the edge, positive sinks below it, negative lifts into the card; the default â0.06 is where a thumb pinches the fan). Fan only; ignored by the S fan. |
tableStepRatio |
double |
0.09 |
Neighbour offset / card width (smaller = deeper overlap). Table spread only. |
tableArcLift |
double |
-0.6 |
How much the ends lift relative to the middle / card height. Positive lifts the ends, negative dips them. Table spread only. |
shadowColor |
Color |
Color(0x24000000) |
Drop shadow between cards (~14% black). Pass Colors.transparent to disable. |
spriteMemoryBudget |
int |
[kSpriteMemoryBudget] = 64 MB |
Memory budget for the pre-composited sprites. When the estimate exceeds it, the compositing ratio is scaled down (sharpness for memory) so the app is not killed under memory pressure. Raise it when many distinct faces are shown, lower it on memory-tight targets such as the web. |
duration |
Duration |
Duration(milliseconds: 1800) |
Duration of a full spread. |
curve |
Curve |
Curves.easeOutCubic |
Overall easing curve. |
stagger |
double |
0.55 |
Per-card delay factor (0â2). 0 = all cards move together. |
autoPlay |
bool |
true |
Whether the spread plays automatically on mount. |
doubleTapToToggle |
bool |
true |
Whether double-tapping the canvas toggles play / collapse (collapses when fully open, otherwise spreads). Pass false to turn the built-in toggle off. |
tableDragToSpread |
bool |
true |
Table spread only: dragging the top card opens / closes the spread under your finger (it stays where you let go instead of finishing the animation). |
onDirectionChanged |
ValueChanged<SpreadDirection>? |
null |
Suggests a direction when a collapsed spread is dragged the other way (feed it back into direction). Table spread: drag right â leftToRight, drag left â rightToLeft. Fan: dragging the collapsed top card clockwise â leftToRight, anticlockwise â rightToLeft. One flip per drag; the S fan / two-hand fan never infer it. |
onDoubleTap |
VoidCallback? |
null |
Called on double-tap (after the play / collapse toggle). Fires regardless of doubleTapToToggle. |
onFanAngleChanged |
ValueChanged<double>? |
null |
Called when the top card is dragged to adjust the fan angle (clamped to kMinFanAngle âĻ the current mode's cap: 540° for the S fan, 220° for the two-hand fan, 360° otherwise; controlled component: feed the value back into fanAngle). If the deck is not fully spread yet, the drag also plays the spread animation (and turns a collapse back into a spread). All four fan modes (the S fan's top card sits at the far end of the packet, so its sensitivity is double: 1° of card rotation is 4° of fan angle); the touch must land on the top card. |
đŽ 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. |
progress |
double |
Current spread progress, 0 = collapsed, 1 = fully spread (most recently bound widget). |
isAnimating |
bool |
Whether an animation is currently playing. |
isOpened |
bool |
Whether the deck is fully spread. |
đ Enums #
| Value | Enum | Description |
|---|---|---|
tableSpread |
FanSpreadMode |
Dealer-style spread into an arc. |
fan |
FanSpreadMode |
Fan: every card is pinned to one pivot and spreads by rotation alone (direction picks which end it grows from). |
sFan |
FanSpreadMode |
S fan: the pinches march along an S-shaped spine and the packet bends into a reversed curve. |
twoHandFan |
FanSpreadMode |
Two-hand fan: one hand holds each half â the left half fans out to the left, the right half to the 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 horizontally mirrored S. Two-hand fan: right hand first, left half on top. |
âšī¸ Additional information #
- Repository: github.com/runoob-coder/card-fan-spread-widget
- Issue tracker: github.com/runoob-coder/card-fan-spread-widget/issues
- Example app: See the
example/directory for a full interactive demo that lets you tweak every property in real time: mode switching, play / replay / collapse, face and back switching, plus live sliders for fan angle, giant tier offset, pivot position, pivot 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.
đ Support #
If card_fan_spread helps you build better UIs, please consider supporting it.
It only takes a few seconds and helps other Flutter developers discover the library.
- â Star on GitHub
âī¸ Buy Me a Coffee #
| Buy Me a Coffee | Donate with PayPal |
|---|---|
![]() |
![]() |














