main
md 787 lines 26.1 KB
Rendered Raw
1 ---
2 name: cue-animations
3 description: 'Flutter animation using the Cue package. Use for: adding animations, transitions, motion, enter/exit effects, hover effects, scroll animations, toggle animations, page transitions, draggable scrubbing, modal transitions, animated widgets, keyframe animations, spring physics, fade, slide, scale, rotate, blur, clip, decorate, parallax, stagger, delay, CueMotion presets. DO NOT USE FOR: non-animation UI work, state management, or layout without motion.'
4 ---
5
6 # Cue Animation Skill
7 <!-- skill-version: 3 -->
8
9 ## Core Mental Model
10
11 Cue separates animation into four concerns:
12
13 | Piece | Role |
14 |-------|------|
15 | `Cue` | **When** to animate (the trigger) |
16 | `Actor` | **What** is animated (wraps the child) |
17 | `Act` | **Which property** changes (fade, slide, scale, etc.) |
18 | `CueMotion` | **How** it moves (spring preset or timed curve) |
19
20 ## General Flutter Conventions (Always Apply)
21
22 - **Always create `Widget` classes, never helper functions that return widgets.** Use `StatelessWidget` or `StatefulWidget` for any reusable or extracted UI — not `Widget _buildFoo()` methods or top-level functions.
23 - **Use shorthand constructors everywhere**, not fully qualified class constructors:
24
25 ```dart
26 // Padding / EdgeInsets
27 .all(16) not EdgeInsets.all(16)
28 .only(top: 8) not EdgeInsets.only(top: 8)
29 .symmetric(h: 16) not EdgeInsets.symmetric(horizontal: 16)
30 .fromLTRB(...) not EdgeInsets.fromLTRB(...)
31
32 // BorderRadius
33 .circular(12) not BorderRadius.circular(12)
34 .vertical(top: .circular(16)) not BorderRadius.vertical(...)
35
36 // Alignment
37 .topLeft / .center / .bottomRight not Alignment.topLeft
38
39 // MainAxisAlignment / CrossAxisAlignment / MainAxisSize
40 .center / .spaceBetween / .min not MainAxisAlignment.center
41
42 // BoxFit / Clip / TextOverflow
43 .cover / .antiAlias / .fade not BoxFit.cover
44
45 // Colors
46 Colors.transparent / Colors.white (unchanged — no shorthand available)
47 ```
48
49 ## Step-by-Step Procedure
50
51 1. **Pick the trigger** — choose the right `Cue` factory for the use case.
52 2. **Set the motion** — always provide `motion:` on `Cue` (or on `Actor`/`Act` for overrides).
53 3. **Wrap children in `Actor`** — or use `Cue(acts: [...])` for a single child.
54 4. **Choose acts** — use shorthand factories (`.fadeIn()`, `.slideY()`, `.scale()`, etc.).
55 5. **Add stagger if needed** — use `delay:` on `Actor` to offset multiple children.
56
57 ## Trigger Reference
58
59 ```dart
60 // One-shot entrance when widget mounts
61 // Supports: repeat, reverseOnRepeat, repeatCount, onEnd
62 Cue.onMount(
63 motion: .smooth(),
64 repeat: false, // loop the animation
65 reverseOnRepeat: false, // ping-pong when looping
66 repeatCount: 3, // finite loop count (null = infinite)
67 onEnd: (forward) { ... }, // fires on completion in either direction
68 acts: [...],
69 child: child,
70 )
71
72 // Toggled by a boolean — forward on true, reverse on false
73 Cue.onToggle(toggled: isExpanded, motion: .smooth(), child: child)
74
75 // Restarts forward every time value changes
76 Cue.onChange(value: currentTab, motion: .smooth(), fromCurrentValue: true, child: child)
77
78 // Driven by pointer hover
79 Cue.onHover(motion: .interactive(), child: child)
80
81 // Driven by focus
82 Cue.onFocus(motion: .smooth(), child: child)
83
84 // Scrubbed continuously as the widget travels through the viewport (parallax-style).
85 // Progress = 0 when leading edge enters bottom of viewport, 1 when it reaches the top.
86 // Prefer non-overshoot motions (.smooth, .linear, .easeOut) — springs with bounce
87 // look jarring when scrubbed.
88 Cue.onScroll(child: child)
89
90 // Plays once when widget scrolls into view; stays complete. Does NOT reverse on scroll-out.
91 // Until revealed it scrubs like onScroll; once visible it plays forward with its motion.
92 // Use enabled: false to skip the animation and jump to completed state.
93 Cue.onScrollVisible(enabled: true, child: child)
94
95 // Scrubbed by external progress (e.g. drag)
96 Cue.onProgress(progress: dragNotifier, child: child)
97
98 // Index-based (page carousels, staggered lists)
99 // Works with CuePageController, CueTabController, CueIndexController
100 Cue.indexed(controller: cuePageController, index: index, child: child)
101
102 // Fully imperative
103 Cue(controller: myController, child: child)
104 ```
105
106 **`debugLabel`** — add a label to any `Cue` to identify it in the DevTools scrubber:
107 ```dart
108 Cue.onMount(motion: .smooth(), debugLabel: 'HeroCard', child: child)
109 ```
110
111 ## Actor and Acts
112
113 ```dart
114 // Multiple acts, same child — compose freely
115 Actor(
116 delay: 60.ms,
117 acts: [
118 .fadeIn(),
119 .slideY(from: 0.15),
120 .scale(from: 0.96),
121 ],
122 child: child,
123 )
124
125 // Extension shorthand for single child — skips explicit Actor
126 child.act([.fadeIn(), .slideUp()])
127
128 // Inline inside Cue (single animated child)
129 Cue.onMount(
130 motion: .smooth(),
131 acts: [.fadeIn(), .slideY(from: 0.2)],
132 child: child,
133 )
134 ```
135
136 **Act defaults — only set what differs from identity:**
137 Every act defaults both `from` and `to` to the resting/identity value for that property. You only provide the parameter that deviates. Common identity defaults:
138
139 | Act | `from` default | `to` default |
140 |-----|---------------|-------------|
141 | `scale` | `1.0` | `1.0` |
142 | `opacity` | `1.0` | `1.0` |
143 | `slideX/Y/Up/Down` | `0.0` | `0.0` |
144 | `blur` | `0.0` | `0.0` |
145 | `padding` | `EdgeInsets.zero` | `EdgeInsets.zero` |
146 | `rotate` | `0.0` | `0.0` |
147 | `translate` | `Offset.zero` | `Offset.zero` |
148
149 ```dart
150 .scale(from: 0.9) // → 1.0 (identity)
151 .scale(to: 1.05) // from 1.0 (identity) →
152 .slideY(from: 0.15) // → 0.0 (identity)
153 .blur(from: 8) // → 0.0 (identity, i.e. sharp)
154 .padding(to: .symmetric(vertical: 12)) // from zero →
155 ```
156
157 **Actor rules:**
158 - `Actor` without an ancestor `Cue` throws.
159 - Only one act per act key per `Actor` (no two slide variants in the same `Actor`).
160 - `motion` on an act > `Actor.motion` > `Cue` motion.
161 - `delay` is **additive**: `Actor.delay` + `Act.delay` stack — setting both does not override, it accumulates.
162
163 **One `Cue` per scene:** A single trigger should be covered by a single `Cue`. For a page entrance, use one `Cue.onMount` placed above all the `Actor`s — do **not** nest multiple `Cue.onMount` wrappers around individual children. Stagger via `Actor.delay`, not by multiplying triggers.
164
165 ```dart
166 // CORRECT — one Cue coordinates the whole entrance
167 Cue.onMount(
168 motion: .smooth(),
169 child: Column(
170 children: [
171 Actor(acts: [.fadeIn(), .slideY(from: 0.12)], child: title),
172 Actor(delay: 60.ms, acts: [.fadeIn(), .slideY(from: 0.12)], child: subtitle),
173 Actor(delay: 120.ms, acts: [.fadeIn(), .slideY(from: 0.12)], child: button),
174 ],
175 ),
176 )
177
178 // WRONG — three separate triggers for the same scene
179 Cue.onMount(acts: [.fadeIn()], child: title)
180 Cue.onMount(acts: [.fadeIn()], child: subtitle) // ✗
181 Cue.onMount(acts: [.fadeIn()], child: button) // ✗
182 ```
183
184 ## Motion Hierarchy
185
186 Motion cascades down and can be overridden at each level:
187
188 ```
189 Cue(motion: .smooth()) ← default for all Actors below
190 Actor(motion: .gentle()) ← default for all acts on this Actor
191 Act(motion: .snappy()) ← overrides for this specific act only
192 ```
193
194 - `Cue.motion` is the scene-wide default.
195 - `Actor.motion` overrides `Cue.motion` for all acts on that `Actor`.
196 - An act-level `motion` overrides everything for that single property.
197 - You only need to set `motion` where behavior should differ from the parent.
198 - **Setting `motion` implicitly sets `reverseMotion` to the same value.** To make reverse feel different, set `reverseMotion` explicitly. This applies at both `Cue` and `Actor` level.
199
200 ```dart
201 // reverse uses the same .smooth() motion — implicit
202 Cue.onToggle(toggled: isOpen, motion: .smooth(), child: child)
203
204 // reverse uses a different motion — explicit override
205 Cue.onToggle(toggled: isOpen, motion: .smooth(), reverseMotion: .snappy(), child: child)
206
207 // same rule on Actor
208 Actor(motion: .bouncy(), reverseMotion: .linear(160.ms), acts: [...], child: child)
209 ```
210
211 ```dart
212 Cue.onMount(
213 motion: .smooth(), // scene default
214 child: Column(children: [
215 Actor(
216 acts: [.fadeIn(), .slideY(from: 0.15)], // both use .smooth()
217 child: title,
218 ),
219 Actor(
220 motion: .gentle(), // overrides .smooth() for this Actor
221 acts: [
222 .scale(from: 0.95), // uses .gentle()
223 .fadeIn(motion: .snappy()), // overrides .gentle() for fadeIn only
224 ],
225 child: card,
226 ),
227 ]),
228 )
229 ```
230
231 ## Delay is Additive
232
233 `Actor.delay` and per-act `delay` stack — they do not override each other.
234
235 ```dart
236 Actor(
237 delay: 80.ms, // Actor-level delay
238 acts: [
239 .fadeIn(), // starts at 80 ms
240 .slideY(from: 0.15, delay: 40.ms), // starts at 80 + 40 = 120 ms
241 ],
242 child: child,
243 )
244 ```
245
246 Use `Actor.delay` for gross stagger between children, and act-level `delay` for fine-grained sequencing within a single `Actor`.
247
248 ## Motion Presets (CueMotion)
249
250 Prefer spring-based presets for natural interruption handling.
251
252 ```dart
253 .smooth() // fast, clean, no overshoot — best default
254 .snappy() // near-instant micro-interactions
255 .bouncy() // visible overshoot for emphasis
256 .gentle() // slower, softer
257 .wobbly() // exaggerated, playful
258 .interactive() // responsive drag/hover feel
259 .spatial() // layout movement
260 .effect() // decorative property changes
261
262 // Fixed-duration timing when physics isn't appropriate
263 .linear(250.ms)
264 .easeIn(300.ms)
265 .easeOut(300.ms)
266 .easeInOut(300.ms)
267 .curved(400.ms, curve: Curves.easeInOut)
268
269 // Special variants
270 CueMotion.none // zero-duration, resolves instantly — use when a CueMotion
271 // is required by the API but no animation is wanted
272 .threshold(200.ms, breakpoint: 0.5)
273 // stays at the start value, then snaps instantly to the end
274 // when progress crosses breakpoint (0→1 fraction).
275 // no interpolation — useful for visibility toggles that must
276 // align precisely with another ongoing animation.
277 ```
278
279 ## Act Families — Quick Reference
280
281 ### Transform
282 ```dart
283 .scale(from: 0.9) // uniform scale
284 .zoomIn() / .zoomOut()
285 .rotate(to: 180) // visual-only rotation in degrees (transform, no layout impact — prefer this)
286 .rotateLayout(to: 1, unit: .quarterTurns) // rotates AND recomputes layout — use only when layout must shift
287 .rotate3D(to: Rotation3D(x: 15), perspective: 0.001)
288 .flipX() / .flipY()
289 .translate(to: Offset(0, 24)) // pixel translation
290 .translateX(from: -20) / .translateY(from: 20)
291 .translateFromGlobal(offset: rect.topLeft) // translate from a global screen position
292 .translateFromGlobalRect(rect) // translate from the center of a global rect
293 .translateFromGlobalKey(key) // translate from a widget identified by GlobalKey
294 .slide(from: Offset(0, 2)) // fractional offset (relative to widget size)
295 .slideX() / .slideY() / .slideUp() / .slideDown()
296 .slideFromLeading() / .slideFromTrailing()
297 .stretch(to: Stretch(x: 1.2, y: 0.8)) // scale axes independently
298 .skew(to: Skew(x: 0.3)) // skew transformation
299 .transform(to: Matrix4.rotationZ(0.3)) // raw Matrix4 (from defaults to identity)
300 .parallax(slide: 0.3, axis: .horizontal) // scrub-linked parallax slide
301 ```
302
303 ### Visual
304 ```dart
305 .opacity(from: 0, to: 1)
306 .fadeIn() / .fadeOut()
307 .blur(from: 8) / .focus() / .unfocus()
308 .backdropBlur(to: 8)
309 .colorTint(from: Colors.white60, to: Colors.black)
310 ```
311
312 ### Layout & Clipping
313 ```dart
314 // NSize constructors used by sizedClip and sizedBox:
315 // NSize(w: 200, h: 100) — explicit width and height
316 // NSize.width(200) — fixed width, height follows child
317 // NSize.height(100) — fixed height, width follows child
318 // NSize.square(48) — equal width and height
319 // NSize.size(Size(...)) — from a Size object
320 // NSize.childSize — both axes follow child (no constraint)
321 // double.infinity → resolves to parent's max constraint
322
323 .sizedBox(width: .tween(40, double.infinity), height: .tween(40, 56))
324 .sizedClip(from: NSize.square(24), to: NSize.height(68))
325 .sizedClip(from: NSize.size(rect.size), to: NSize.width(240))
326 .fractionalSize(widthFactor: .tween(0.2, 1.0), heightFactor: .fixed(0.5))
327 .padding(to: .symmetric(vertical: 12))
328 .align(from: .bottomCenter, to: .bottomLeft)
329 .clipHeight(fromFactor: 0.3) / .clipWidth() / .clip() / .circularClip()
330 ```
331
332 ### Multi-Property Acts — AnimatableValue
333
334 Some acts animate multiple independent properties on the same widget. Each property accepts an `AnimatableValue`:
335 - `.tween(from, to)` — interpolates between two values
336 - `.fixed(value)` — constant, not animated (still required if the act needs to know the value)
337
338 Omitting a property entirely means that property is ignored/left at its natural value.
339
340 **`.sizedBox`** — animate width and/or height independently:
341 ```dart
342 .sizedBox(
343 width: .tween(80, double.infinity), // expands to fill
344 height: .fixed(40), // height stays constant
345 )
346 .sizedBox(width: .tween(100, 300)) // only width animates
347 ```
348
349 **`.fractionalSize`** — size as a fraction of the parent (0.0–1.0):
350 ```dart
351 .fractionalSize(
352 widthFactor: .tween(0.2, 1.0),
353 heightFactor: .fixed(0.5),
354 alignment: .fixed(.center),
355 )
356 ```
357
358 **`.decorate`** — animate box decoration properties individually.
359 Prefer `DecoratedBoxActor` for standalone use; use `.decorate` when composing with other acts inside an `Actor`:
360 ```dart
361 .decorate(
362 color: .tween(Colors.transparent, Colors.white),
363 borderRadius: .tween(.circular(0), .circular(16)),
364 border: .fixed(Border.all(color: Colors.blue)),
365 boxShadow: .tween(
366 [BoxShadow(blurRadius: 0)],
367 [BoxShadow(blurRadius: 8, offset: Offset(0, 4))],
368 ),
369 gradient: .tween(fromGradient, toGradient),
370 )
371 ```
372
373 **`CardAct` / `CardActor`** — animate card surface properties.
374 Prefer `CardActor` for standalone card animations; use `CardAct` inside `Actor(acts:[...])` when combining with other acts:
375 ```dart
376 // Preferred for card-only animations
377 CardActor(
378 elevation: .tween(1, 8),
379 color: .tween(Colors.white, Colors.blue.shade50),
380 borderRadius: .tween(.circular(8), .circular(24)),
381 shadowColor: .fixed(Colors.black),
382 margin: .tween(.zero, .all(8)),
383 child: child,
384 )
385
386 // Inside an Actor alongside other acts
387 Actor(
388 acts: [
389 CardAct(elevation: .fixed(2), borderRadius: .tween(.circular(8), .circular(24))),
390 .scale(from: 0.95),
391 ],
392 child: child,
393 )
394 ```
395
396 **`PositionAct` / `PositionedActor`** — animate stack positioning.
397 Prefer `PositionedActor` — it reads naturally inside a `Stack`:
398 ```dart
399 Stack(
400 children: [
401 PositionedActor(
402 from: .fill(), // fills the stack
403 to: .fromSTEB(20, 250, 20, 24), // shrinks to specific insets
404 child: myWidget,
405 ),
406 PositionedActor.keyframed(
407 frames: .fractional([...]),
408 relativeTo: stackSize,
409 child: myWidget,
410 ),
411 ],
412 )
413 ```
414
415 **`DecoratedBoxActor`** — standalone animated decoration, no `Actor`/`Cue` nesting needed:
416 ```dart
417 DecoratedBoxActor(
418 color: .tween(Colors.grey.shade100, Colors.blue.shade50),
419 borderRadius: .tween(.circular(8), .circular(32)),
420 shape: .circle,
421 child: SizedBox.square(dimension: 48),
422 )
423 ```
424
425 **`PaintActor`** — custom painting driven by animation progress:
426 ```dart
427 PaintActor(
428 painter: myCustomPainter, // receives CueAnimation<double> to drive paint
429 child: child,
430 )
431 ```
432
433 ### Specialized
434 ```dart
435 // Animate along a custom Path — optional autoRotate to face direction of travel
436 PathMotionAct(
437 path: myPath, // a Path() with a single continuous metric
438 autoRotate: true, // rotate widget to face path direction
439 alignment: .center, // pivot/anchor point
440 )
441 ```
442
443 ### Style & Decoration
444 ```dart
445 // textStyle — animates between two complete TextStyle values
446 .textStyle(
447 from: TextStyle(fontSize: 14, color: Colors.grey),
448 to: TextStyle(fontSize: 22, color: Colors.black, fontWeight: .bold),
449 )
450 // iconTheme — animates icon properties
451 .iconTheme(from: IconThemeData(size: 24), to: IconThemeData(size: 20))
452 ```
453
454 ### Positional (Stack)
455 See **Multi-Property Acts** section above for full `PositionedActor` coverage.
456
457 ```dart
458 PositionedActor(from: .fill(), to: .fromSTEB(20, 250, 20, 24), child: child)
459 PositionedActor.keyframed(frames: .fractional([...]), relativeTo: size, child: child)
460 ```
461
462 ### Custom Value
463 ```dart
464 // TweenActor — animate any value with a builder (does not need to be inside Actor)
465 TweenActor<double>(
466 from: 0,
467 to: 1,
468 motion: .smooth(),
469 builder: (context, animation) {
470 return FadeTransition(opacity: animation, child: child);
471 },
472 )
473 ```
474
475 ## Keyframes
476
477 ### Motion-based (spring between values)
478 ```dart
479 ScaleAct.keyframed(
480 frames: Keyframes([
481 .key(0.92),
482 .key(1.06, motion: .bouncy()),
483 .key(1.0),
484 ], motion: .smooth()),
485 )
486 ```
487
488 ### Fractional (time-based at specific progress points)
489 ```dart
490 TranslateAct.keyframed(
491 frames: .fractional([
492 .key(const Offset(0, 24), at: 0.0),
493 .key(const Offset(0, -8), at: 0.7),
494 .key(Offset.zero, at: 1.0),
495 ], duration: 450.ms),
496 )
497
498 // ScaleAct with fractional keyframes (used for custom switch squish)
499 ScaleAct.keyframed(
500 frames: .fractional([
501 .key(1.1, at: 0.4),
502 .key(1.1, at: 0.6),
503 .key(1.0, at: 1.0),
504 ]),
505 )
506 ```
507
508 ## Reverse Behavior
509
510 ```dart
511 // Mirror: same animation plays in reverse
512 .fadeIn(reverse: .mirror(delay: 40.ms))
513
514 // To: reverse targets a different value
515 .scale(to: 1.06, reverse: .to(0.98))
516
517 // None: reverse does nothing
518 .zoomIn(reverse: .none())
519
520 // Different motion for forward vs reverse on Cue/Actor
521 Cue.onToggle(
522 toggled: isOpen,
523 motion: .smooth(),
524 reverseMotion: .snappy(),
525 child: child,
526 )
527
528 Actor(
529 motion: .smooth(),
530 reverseMotion: .linear(160.ms),
531 delay: 80.ms,
532 reverseDelay: 20.ms,
533 acts: [...],
534 child: child,
535 )
536 ```
537
538 ## Modal Transitions
539
540 ### CueModalTransition
541
542 Morphs any trigger widget into a full modal. Captures the trigger's screen-space `Rect` and passes it to the builder so you can animate directly from the trigger's position and size.
543
544 ```dart
545 CueModalTransition(
546 motion: .smooth(),
547 reverseMotion: .snappy(), // defaults to motion if omitted
548 alignment: .bottomRight, // anchors modal's corner to trigger's same corner
549 // null = no positioning, builder must place itself
550 barrierColor: Colors.transparent, // use transparent for contextual menus
551 barrierDismissible: true,
552 hideTriggerOnTransition: true, // hides trigger while modal is open (seamless morph)
553 useRootNavigator: true, // false = scoped to a nested Navigator
554 backdrop: Actor( // optional: rendered behind modal, in front of barrier
555 acts: [.backdropBlur(to: 8)],
556 child: ColoredBox(color: Colors.transparent),
557 ),
558 triggerBuilder: (context, open) {
559 return FloatingActionButton(onPressed: open, child: Icon(Icons.add));
560 },
561 builder: (context, rect) {
562 // rect = trigger's bounding box in global screen coordinates
563 return Actor(
564 acts: [
565 .sizedClip(from: NSize.size(rect.size), to: NSize.width(300)), // expand from trigger size
566 .translateFromGlobalRect(rect), // move FROM trigger's position
567 ],
568 child: myModalContent,
569 );
570 },
571 )
572 ```
573
574 **`alignment`:** controls which corner/point of the modal is anchored to the same point on the trigger.
575 - `.bottomRight` — ideal for FABs and corner action buttons that expand in-place
576 - `.center` — works well for inline buttons that expand in place
577 - `null` — no automatic positioning; builder output goes into a full-screen `Stack` and must position itself
578
579 **`rect` in builder** — common act patterns using the trigger rect:
580 ```dart
581 .sizedClip(from: NSize.size(rect.size)) // expand from trigger's exact size
582 .translateFromGlobalRect(rect) // translate an element from trigger's center
583 .translateFromGlobal(offset: rect.topLeft) // translate from trigger's top-left corner
584 .iconTheme(from: IconThemeData(size: rect.height * 0.5), to: IconThemeData(size: 20))
585 ```
586
587 **Nesting** — bind different gestures to different modals from the same trigger:
588 ```dart
589 CueModalTransition(
590 motion: .smooth(),
591 triggerBuilder: (_, openTap) {
592 return CueModalTransition(
593 motion: .smooth(),
594 triggerBuilder: (context, openLongPress) {
595 return GestureDetector(
596 onTap: openTap,
597 onLongPress: openLongPress,
598 child: myButton,
599 );
600 },
601 builder: (context, rect) => LongPressContent(rect: rect),
602 );
603 },
604 builder: (context, rect) => TapContent(rect: rect),
605 )
606 ```
607
608 **Close the modal** from inside the builder with `Navigator.of(context).pop()`.
609
610 ### showCueDialog / CueDialogRoute
611
612 For standard dialogs driven by Cue. All `Actor`s inside automatically pick up the route controller via `CueScope` — no need to pass a controller explicitly.
613
614 ```dart
615 showCueDialog(
616 context: context,
617 motion: .smooth(),
618 reverseMotion: .snappy(),
619 barrierColor: Colors.black54,
620 builder: (context) => Actor(
621 acts: [.fadeIn(), .slideY(from: 0.12)],
622 child: AlertDialog(title: Text('Hello')),
623 ),
624 )
625
626 // Or push directly for full control:
627 Navigator.of(context).push(
628 CueDialogRoute(
629 motion: .smooth(),
630 reverseMotion: .snappy(),
631 pageBuilder: (context, _, _) => MyModalContent(),
632 ),
633 );
634 ```
635
636 ## CueDragScrubber
637
638 `CueDragScrubber` maps drag distance to animation progress. The controller is taken from the nearest `CueScope` ancestor automatically, or passed explicitly.
639
640 ```dart
641 CueDragScrubber(
642 distance: 250.0, // pixels that map to full 0→1 progress travel
643 axis: .vertical, // .vertical (default) or .horizontal
644 scrubDirection: .auto, // .forward, .reverse, or .auto (infers from controller state)
645 releaseMode: .fling, // what happens on finger lift:
646 // .fling — flings with velocity, falls back to .snap
647 // .snap — snaps forward if progress > 0.5, else reverses
648 // .none — stays wherever drag stopped
649 forceLinearScrubbing: true, // ignore motion curves while scrubbing (default: true)
650 onAnimationEnd: (forward) { ... }, // fires when animation completes/dismisses
651 child: myDraggableWidget,
652 )
653 ```
654
655 **`scrubDirection`:**
656 - `.forward` — dragging in the positive axis direction increases progress (0→1)
657 - `.reverse` — dragging in the positive axis direction decreases progress (1→0)
658 - `.auto` — infers from controller state: completed/forward → reverse scrub; dismissed/reverse → forward scrub
659
660 **Snap-to-final pattern** — combine with `Cue.onToggle(onEnd:)` to let Cue own the snapping and state:
661
662 ```dart
663 Cue.onToggle(
664 toggled: _isDraggedDown,
665 onEnd: (forward) => setState(() => _isDraggedDown = forward),
666 motion: .smooth(),
667 child: CueDragScrubber(
668 distance: 250.0,
669 releaseMode: .fling,
670 child: myCard,
671 ),
672 )
673 ```
674
675 **Explicit controller** — skip `CueScope` when the scrubber is far from the `Cue` in the tree:
676
677 ```dart
678 CueDragScrubber(
679 controller: _myController,
680 distance: 200.0,
681 child: handle,
682 )
683 ```
684
685 ## Page/Index Controllers
686
687 | Controller | Use case |
688 |------------|----------|
689 | `CuePageController` | `PageView` — drop-in replacement for `PageController` |
690 | `CueTabController` | `TabBar` / `TabBarView` — drop-in for `TabController` |
691 | `CueIndexController` | Custom index-driven sequences (carousels, steppers) |
692
693 ```dart
694 final controller = CuePageController(viewportFraction: 0.8);
695
696 PageView.builder(
697 controller: controller,
698 itemBuilder: (context, index) => Cue.indexed(
699 controller: controller,
700 index: index,
701 // During drag: scrub mode (progress mapped directly, motion shapes curve)
702 // After snap: play mode (motion + Actor delays produce staggered entrance)
703 child: Column(
704 children: [
705 Actor(acts: [.fadeIn(), .slideY(from: 0.12), .rotate(from: 4.5, reverse: .to(-4.5))], child: title),
706 Actor(delay: 60.ms, acts: [.scale(from: 0.96), .fadeIn()], child: card),
707 ],
708 ),
709 ),
710 )
711 ```
712
713 ## Imperative Controllers
714
715 Only reach for this when the trigger factories are insufficient.
716
717 ```dart
718 late final CueController _controller;
719
720 @override
721 void initState() {
722 super.initState();
723 _controller = CueController(vsync: this, motion: .smooth());
724 }
725
726 @override
727 void dispose() {
728 _controller.dispose(); // releases timeline; no need to release tracks manually
729 super.dispose();
730 }
731
732 @override
733 Widget build(BuildContext context) {
734 return Cue(
735 controller: _controller,
736 acts: [.fadeIn(), .slideY(from: 0.2)],
737 child: child,
738 );
739 }
740 ```
741
742 For typed imperative animations outside widgets:
743 ```dart
744 final opacity = controller.tweenTrack<double>(from: 0, to: 1);
745 final scale = controller.keyframedTrack<double>(
746 frames: Keyframes([.key(0.92), .key(1.06), .key(1.0)], motion: .smooth()),
747 );
748 // Release when no longer needed (if controller stays alive):
749 opacity.release();
750 ```
751
752 ## Debug Tools
753
754 Wrap in `kDebugMode` to get a scrubber overlay for completed animations:
755
756 ```dart
757 MaterialApp(
758 builder: (context, child) {
759 if (kDebugMode) return CueDebugTools(child: child!);
760 return child!;
761 },
762 )
763 ```
764
765 ## Style Rules (Always Follow)
766
767 - Use shorthand: `.smooth()` not `Spring.smooth()`, `.fadeIn()` not `Act.fadeIn()`
768 - Prefer `Cue(acts: [...])` over wrapping a single child in an explicit `Actor`
769 - Use `.act([...])` extension for single-widget inline cases
770 - One `Cue` per coordinated group — place it high enough to cover all related `Actor`s
771 - Compose multiple acts in one `Actor`; separate `Actor`s for different delay/motion groups
772 - Never `Actor` without a `Cue` ancestor
773
774 ## Common Patterns by Use Case
775
776 | Use Case | Pattern |
777 |----------|---------|
778 | Entrance animation | `Cue.onMount` + `.fadeIn()`, `.slideY()`, `.scale()` |
779 | Expand/collapse | `Cue.onToggle` + `.clipHeight()`, `.fadeIn()`, `.rotate()` |
780 | Tab/selection highlight | `Cue.onToggle` + `.sizedClip()`, `.colorTint()` |
781 | Hover feedback | `Cue.onHover(motion: .interactive())` + `.scale(to: 1.03)` |
782 | Context menu | `CueModalTransition` + `.sizedClip()`, `.translateFromGlobalRect()` |
783 | Page carousel | `Cue.indexed` + `CuePageController` + `.parallax()`, `.rotate()` |
784 | Drag-to-dismiss | `CueDragScrubber` + `Cue.onToggle(onEnd:...)` |
785 | Scroll reveal | `Cue.onScrollVisible` + `.fadeIn()`, `.slideY()` |
786 | Loading → done morph | `Cue.onChange` + `.sizedClip()`, `.fadeIn()`, `.zoomIn()` |
787 | Custom value animation | `TweenActor<T>(from:, to:, builder:)` |