Skip to content

Launch animation: rocket-style play button#36

Open
Xitee1 wants to merge 17 commits into
mainfrom
feat/launch-animation
Open

Launch animation: rocket-style play button#36
Xitee1 wants to merge 17 commits into
mainfrom
feat/launch-animation

Conversation

@Xitee1
Copy link
Copy Markdown
Owner

@Xitee1 Xitee1 commented Apr 20, 2026

Summary

Tapping the play button now triggers a launch animation: the icon rotates toward the dial, crouches briefly in the button, shoots up leaving a glowing accent-colored trail, and impacts the dial with a white flash, accent halo, and three shockwave rings radiating from the progress ring outward. The stop icon only appears after the animation completes.

The animation is gated by a new user setting (default on, seeded from Android's Settings.Global.ANIMATOR_DURATION_SCALE == 0 at first install), and is also skipped at runtime when the system has animations disabled.

Based on the prototype designed in Claude Design — see docs/superpowers/specs/2026-04-20-launch-animation-design.md for the full spec and docs/superpowers/plans/2026-04-20-launch-animation.md for the implementation plan.

  • Data layer: launchAnimationEnabled: Boolean on UserSettings, persisted via DataStore with a one-time seed from system reduce-motion
  • UI: new LaunchAnimationController (Animatable-based state machine driving Crouch → Launch → Impact phases sequentially), LaunchOverlay composable renders the flying icon + trail in the root coordinate space, PlayButton and CircularDial gained optional params for controller-driven scale/impact state
  • Settings screen: new toggle under "Sterne" (DE + EN strings)

Test plan

  • Portrait, 15 min, tap Play — rocket flies toward dial center, impact effect hits the whole dial, then stop icon appears
  • Portrait, 45 min — same, knob position different but impact is still centered
  • Landscape — animation plays toward dial center in physical screen space
  • Tap during animation — timer stops immediately, animation visually completes, button ends on Play icon
  • Toggle off in Settings — no flight, just the existing Play → Stop crossfade
  • System reduce-motion on — no flight (runtime gate)
  • Fresh install with system reduce-motion on — toggle defaults to off
  • First-ever launch on Android 13+ — POST_NOTIFICATIONS dialog appears first; no animation on that tap (by design)
  • App background during animation — controller resets, button reflects true timer state on return
  • ./gradlew assembleDebug lint green

🤖 Generated with Claude Code

Xitee1 and others added 17 commits April 20, 2026 14:19
…n seed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Avoids per-frame floatArrayOf allocation inside drawImpactEffects during
the ~260ms impact phase. Negligible real-world impact but cleaner.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ving from crouchProgress

The launch animation has three distinct button-scale moments (1.0 at idle,
0.92 at crouch, 1.04 impact recoil). Deriving buttonScale from crouchProgress
can only express the first two; the impact recoil needs a separate driver.

Add an explicit buttonScale parameter so the LaunchAnimationController can
drive the full 820ms scale trajectory directly. crouchProgress now drives
only the icon rotation/scale during crouch.
…ct effect

Three linked design fixes after initial testing feedback:

1. Play icon lives only in the LaunchOverlay now — removed from PlayButton.
   Overlay renders it at button center during Idle/Crouch and animates
   position/rotation/scale during Launch. No more icon handoff between
   button and overlay, so no color jump or scale discontinuity at takeoff.
   A soft accent-colored glow fades in as the icon leaves the button,
   keeping it readable against the dark background.

2. Impact effect now hits the whole dial instead of the draggable knob.
   drawImpactEffects fills the dial's inner area with a bright center
   flash, radiates three shockwave rings from the center outward past
   the ring edge, and keeps the progress-arc brightness boost. The knob
   no longer gets its own aura — the knob is purely an interaction
   affordance, not an animation target.

3. Icon-scale compression during crouch is now driven by the controller's
   iconScale Animatable (0.9 at end of crouch), so the overlay-rendered
   icon visibly "crouches" just like the prototype. crouchProgress has
   been dropped — it was only wired to the button-internal icon which
   no longer exists.
Launch is now two-stage: a 200ms windup where the icon hovers out of the
button while scaling up (gathering energy), then a 340ms flight with
aggressive ease-out to the dial. Was previously a single 420ms ease-in-out
that felt too uniform.

Impact extended from 260ms to 600ms so the three shockwave ripples have
room to fully expand and fade (matching the prototype's 0/110/220ms
stagger). Added a white "bang" flash that fires instantly at impact for
a clearer moment of contact, with an accent-colored halo behind it as
the longer-lasting glow. Ripples now stay contained within the dial's
ring area (was overshooting past the edge), so the effect reads as a
contained shockwave on the dial face.

Icon fade-out window tightened from travel 0.85→1.0 to 0.95→1.0 so the
icon disappears crisply at the moment the impact begins, removing the
~60ms visual gap between takeoff-fade and impact.
Matches the v2 prototype after re-examining the reference. Three key
visual elements were missing or wrong:

1. **Rocket trail** — the biggest gap. Renders a glowing white-to-accent
   line from the button center to the current icon position during the
   launch phase. Alpha ramps up to peak around mid-flight, fades out at
   the end. Without it, the icon just slides; with it, the launch reads
   as an actual rocket with an exhaust tail.

2. **Shockwaves originate at the ring, not the dial center**. The
   prototype draws the three concentric waves starting at r=ringRadius
   and expanding outward to ringRadius+~30dp, stroke thinning from 8dp
   to 0.5dp. Visually this reads as the progress ring itself radiating
   energy, not a ripple propagating out from some interior point.

3. **Icon scale curve matches the prototype keyframes**: 0.9 (end of
   crouch) → 1.1 at 30% of launch → 0.9 at 80% → 0.5 at impact, in
   three chained animateTo calls. Previous two-stage windup was too
   exaggerated (1.18 → 0.25) and the extra 120ms didn't help readability.

Also reverted launch duration to 420ms (was 540) to match prototype
exactly, tightened icon fade-out from 0.95→1.0 back to 0.8→1.0, and
replaced the accent-halo dial fill with a more prominent white impact
flash (6dp → 80dp, alpha fading over ~380ms).
iconScale was already keyframed per the prototype, but iconTravel was a
single 420ms tween — smooth continuous acceleration with no dramatic
pacing. The prototype's rocketShoot CSS keyframe bakes in three segment
boundaries (30% / 80% of duration) and applies the easing per-segment,
which makes velocity drop to near zero at each boundary.

Splitting iconTravel into the same three segments produces that effect:
a short "hang" right after the icon leaves the button (~126ms mark), a
fast middle flight, and a soft approach to the dial. The pause-then-burst
rhythm is where the "power" in the launch actually comes from.
…launch

Two tweaks after another test pass:

1. **Travel/scale use a single keyframes spec with linear interpolation**
   between waypoints instead of three chained animateTo calls. Chained
   tweens each decelerate to zero at their end, producing hard stops at
   the 30% and 80% segment boundaries. Linear-per-segment keeps a
   constant velocity inside each segment and only shifts speed at the
   boundary — still dynamic (ratios 1:1.7:1.1 for travel) but without
   the "stopping" feel. The waypoints themselves are unchanged so the
   overall keyframe shape still matches the prototype.

2. **Stop icon no longer appears mid-launch.** The PlayButton's
   crossfade was keyed on `isRunning`, which becomes true as soon as the
   service starts — typically during the launch phase, 50-100ms after
   tap. That made it look like a *second* arrow popped out of the button
   behind the flying one. Now the TimerScreen derives a separate
   `playButtonShowsRunning = isRunning && phase == Idle` and passes it
   to the PlayButton; the visual state only flips to "running" after
   the controller has fully finished the animation and reset to Idle.
Linear-only per-segment was too smooth — the pause after the button
washed out. Ease-in-out per-segment was too hard — hit a wall at each
boundary. Sweet spot: a short explicit hold.

The launch timeline now has four beats:
  - 0-100ms: ease-in-out lift to 0.22 (decelerates into the hold)
  - 100-135ms: 35ms hold at 0.22 / scale 1.1 — the visible "hang" right
    after the icon leaves the button, where it reads as gathering energy
  - 135-336ms: linear cruise at higher constant velocity (big jump from
    zero gives the acceleration its "power" moment)
  - 336-420ms: linear approach to impact at the dial

Scale follows the same beats so the icon stays at peak size (1.1) during
the hold, then shrinks perspectively during the cruise. Total duration
unchanged at 420ms.
User preferred the pacing from e10f1da (three chained animateTo calls
with per-segment ease-in-out). The subsequent "smoother" and "explicit
hold" experiments both felt worse. Reverting just the travel/scale
specs; the stop-icon-after-animation fix from 0c0b687 stays.
Reference artifacts produced during the feature's brainstorming/planning
phase: the design decisions (scope, UX, edge cases) and the task
breakdown that guided implementation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant