MCQ Streak — Animation Requirements
MCQ Streak Animations — A1..A4
Concise behavior reference for the four streak animations. Authoritative source for timing and triggers; product behaviour and triggers live in mcq_streak.
Shipped in commits 0820a36 (initial A1..A4 per spec v5.3) and 77e38e5 (A2 spring-in fix, spec v5.5).
A1 — Streak Activation (Indicator Bar)
Fires inside QuizIndicatorBar when the activating dot transitions to a star.
Position 5 — .newStreak
State machine: .none → .green → .yellow → .star.
| Phase | Duration | Behavior |
|---|---|---|
| Pre-A1 | 200ms easeInOut | Upstream blue → green on the just-answered dot. Already settled when A1 starts. |
| 1. Green hold | 100ms | Crossfade from staticDot to activatingDot (both green), then hold. |
| 2. Green → Yellow | 100ms | Opacity crossfade between circles. |
| 3a. Scale enter + spin | 500ms | Star inserts. scale 0 → 1.2 easeOut over 500ms, concurrent rotation 0° → 360° linear over 450ms. |
| 3b. Scale exit | 400ms | scale 1.2 → 1.0 easeInOut. |
Total A1 = 1100ms; perceived (incl. pre-A1) ≈ 1300ms.
Positions 6–10 — .continuingStreak
State machine collapses to .none → .star. Phases 3a + 3b only (no green hold, no yellow). Total ≈ 900ms.
Position 10 uses this chain too; lastActivationPosition is captured before the cycle reset so the star plays before the bar reverts to a fresh cycle.
Size settle (22.4 → 16pt)
When activatingMCQIndex reassigns from i to j, the prior activated star at i morphs 22.4pt → 16pt over 200ms easeInOut via matchedGeometryEffect between the activatingDot and staticDot star views.
Reduce Motion
Collapse to a single opacity transition .none → .star over 250ms easeInOut. No spin, no two-phase scale, no overshoot. Size settle still animates (size delta is acceptable).
Interruption
If activatingMCQIndex changes mid-A1, the chain Task cancels. The prior star hands off to the staticDot via matched geometry (size morphs, rotation snaps to 0).
A2 — Results Badge Spring-In
Fires on first onAppear of AnimatedGlobalStarsBadge on the Test Results screen when starsEarnedThisTest > 0.
| Token | Value |
|---|---|
| Entry | Instant. Badge is present at scale 0.6 at t=0 (committed pre-render via State(initialValue:)). |
| Spring | .spring(response: 0.45, dampingFraction: 0.55, blendDuration: 0) |
| Overshoot peak | ~200ms, scale ≈ 1.2 |
| Perceived settle | ~450ms |
| Suppressed when | starsEarnedThisTest == 0, Reduce Motion on, or kill/resume (results screen is not reconstructed on resume) |
A3 — Results Count-Up
Starts 900ms after A2 begins (450ms perceived settle + 450ms tail-clearing buffer for residual spring oscillation), only when starsEarnedThisTest > 0.
| Token | Value |
|---|---|
| Range | displayedCount: max(0, newTotal − starsEarnedThisTest) → newTotal |
| Step | +1 every 200ms |
| Step pulse | scale 1.0 → 1.07 → 1.0 via .spring(response: 0.18, dampingFraction: 0.62) |
| Digit roll | .contentTransition(.numericText(...)) on the count Text; each write wrapped in withAnimation(.easeInOut(duration: 0.15)) |
| Settle | scale 1.0 at newTotal |
| Suppressed when | starsEarnedThisTest == 0 or Reduce Motion on (count snaps to newTotal) |
| Interruption | onDisappear cancels the Task and snaps displayedCount = newTotal |
A4 — Celebration Particles
Confetti burst at streak positions 5, 7, 10. Rendered in a UIWindow at .alert + 1 (above all sheets), hit-test transparent.
| Intensity | Trigger | Base count (390×844) | Lifespan |
|---|---|---|---|
.standard | Position 5 | 80 | ≈ 2500ms |
.medium | Position 7 | 120 | ≈ 2700ms |
.max | Position 10 | 160 | ≈ 3000ms |
iPad scaling: actualCount = min(400, baseCount × screenW × screenH / (390 × 844)).
Physics (pure analytic, no per-frame mutation)
Per particle, seeded once at burst spawn:
- Origin: random
xalong bottom edge,y = screenHeight + 8. - Initial velocity: magnitude
screenHeight × (1.20..1.79)/s, direction−90° ± 18°. Upper bound 1.79 caps peak height at ≤ screen height. - Gravity:
screenHeight × 1.6/s². - Spin: random
−540..540°/s (constant; rotation = spin × t). - Spawn offset:
0..0.4sper particle — confetti trickles in rather than bursting in unison. - Lifespan fade: linear opacity 1 → 0 over the last 35% of lifespan.
State at time t:
position.x = p0.x + v0.dx * t
position.y = p0.y + v0.dy * t + 0.5 * g * t * t
rotation = spin * t
Rendered by TimelineView(.animation, paused: store.bursts.isEmpty) driving a Canvas. Burst pruning runs at 5Hz.
Window scene resolution (iPad multi-window safe)
WindowSceneAccessor (a UIViewRepresentable in TestView.background) reads view.window?.windowScene from didMoveToWindow and pushes it into viewModel.setHostScene(_:). ConfettiCelebrationPlayer.fire(at:intensity:in:) uses that scene; falls back to connectedScenes.first(where: { $0.activationState == .foregroundActive }) only on launch-edge race.
Concurrency cap
Max 3 non-fading bursts. A 4th spawn flags the oldest non-fading burst with fadeOutStart = now, fadeOutDuration = 200ms; a fadeMultiplier(t) multiplies into every particle’s natural opacity, then the burst is pruned.
Suppressed when
Reduce Motion on (no-op), or kill/resume (intensities not persisted — same lifecycle as celebrationMCQIds).
Trigger summary
| Animation | Source | Layer |
|---|---|---|
| A1 | StudyTestViewModel.lastActivationKind ← StreakTracker.lastActivationPosition (captured pre-reset) | QuizIndicatorBar |
| A2 | First onAppear of AnimatedGlobalStarsBadge when starsEarnedThisTest > 0 | Test Results |
| A3 | 900ms after A2 starts, gated on starsEarnedThisTest > 0 | Test Results |
| A4 | StreakTracker.celebrationIntensity(mcqId:) non-nil at submit | Off-app overlay window |