Owner Nikunj

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.

PhaseDurationBehavior
Pre-A1200ms easeInOutUpstream blue → green on the just-answered dot. Already settled when A1 starts.
1. Green hold100msCrossfade from staticDot to activatingDot (both green), then hold.
2. Green → Yellow100msOpacity crossfade between circles.
3a. Scale enter + spin500msStar inserts. scale 0 → 1.2 easeOut over 500ms, concurrent rotation 0° → 360° linear over 450ms.
3b. Scale exit400msscale 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.

TokenValue
EntryInstant. 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 whenstarsEarnedThisTest == 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.

TokenValue
RangedisplayedCount: max(0, newTotal − starsEarnedThisTest) → newTotal
Step+1 every 200ms
Step pulsescale 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))
Settlescale 1.0 at newTotal
Suppressed whenstarsEarnedThisTest == 0 or Reduce Motion on (count snaps to newTotal)
InterruptiononDisappear 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.

IntensityTriggerBase count (390×844)Lifespan
.standardPosition 580≈ 2500ms
.mediumPosition 7120≈ 2700ms
.maxPosition 10160≈ 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 x along 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.4s per 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

AnimationSourceLayer
A1StudyTestViewModel.lastActivationKindStreakTracker.lastActivationPosition (captured pre-reset)QuizIndicatorBar
A2First onAppear of AnimatedGlobalStarsBadge when starsEarnedThisTest > 0Test Results
A3900ms after A2 starts, gated on starsEarnedThisTest > 0Test Results
A4StreakTracker.celebrationIntensity(mcqId:) non-nil at submitOff-app overlay window