Skip to content

feat(mobile): dismiss the splash with a pixel ripple built on react-native-ease - #1714

Merged
janicduplessis merged 2 commits into
mainfrom
feat/1701-splash-pixel-ripple
Sep 28, 2026
Merged

janicduplessis merged 2 commits into
mainfrom
feat/1701-splash-pixel-ripple

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Description

The splash dismiss from #1699 (squeeze, burst, ring) is replaced with the concept the maintainer picked, "Pixel ripple": the icon's purple square floods the screen, the "stim" letters pop and shrink, and the purple breaks into square pixels that spin away outward from the logo while the app settles from 0.98 to 1.

Native change, no OTA. The animation is built on react-native-ease 0.8.0, a Fabric native component. Adding it changes the runtime fingerprint (iOS 6f8711a5… -> 237669dc…, Android b26cd7c7… -> a6eca5c3…), so this ships in a new TestFlight / Play build, not as an EAS Update to build 14. The maintainer chose ease for this and accepted the store build (#1701 comment).

Solution

SplashOverlay now wraps the app so it can scale it. Nothing animates from JS: once the native splash is hidden, one render flips every EaseView to its end state, and each view carries its own duration and delay, so Core Animation and Android's ObjectAnimator run the whole timeline:

ms
0–350 purple square (starts 1 pt inside the icon's square, so the icon hides it on the first frame) scales to cover the screen, radius 21 -> 2, cubic-bezier(.7,0,.3,1)
330 / 350 backdrop and icon cut, then the flood cuts, revealing a full-screen grid of 10-column purple cells underneath
350–570 letters (icon.png clipped to a 72 pt circle) pop to 1.2, then shrink to 0
450–910 each cell shrinks to 0 with a 45° rotate over 160 ms, delayed by its distance from the logo (100 + up to 300 ms)
400–1000 the app scales 0.98 -> 1

About 1.0 s in total; the overlay unmounts at 960 ms. About 240–260 cells on a phone, each animating only transform.

The handoff, the 300 ms fallback, once per process, and the boot test's unmount assertion are unchanged from #1699. In dark mode the flood stays brand purple.

Reduce Motion (Reanimated's synchronous useReducedMotion()) skips mounting the pixel grid and crossfades the overlay over 250 ms. The overlay uses useHardwareLayer: without it, Android applies the fading opacity to each child separately and the purple grid showed through the fading backdrop. On Android, "Remove animations" also zeroes the animator scale, and the platform animators then jump straight to the end, so the splash cuts rather than fades. That follows the OS setting.

Dependency risk: ease 0.8.0 applies kotlin-android unconditionally, which breaks on AGP 9 unless android.builtInKotlin=false. The Expo 58 template sets it, so the build works here. The fix (appandflow/react-native-ease#56) is tagged v0.8.1, but that release never reached npm. No open ease issue mentions RN 0.88 or Expo 58.

Test plan

Release builds (stim ios --configuration Release, stim android --variant release), recorded from a cold start:

Light Dark Reduce Motion
iOS 27 sim (iPhone 18 Pro)
ios-light.mp4
ios-dark.mp4
ios-reduce.mp4
Android emulator (1080x2400)
android-light.mp4
android-dark.mp4
android-reduce.mp4

Reduce Motion was set with defaults write com.apple.Accessibility ReduceMotionEnabled on iOS and settings put global transition_animation_scale 0 on Android.

Frame rate (Android emulator): dumpsys gfxinfo framestats over three cold starts shows a frame on every 16.7 ms vsync through the whole animation. The one exception was a single missed vsync at the handoff in two of the three runs. The host's load average was 400–560, and the emulator is not a mid-range phone.

Frame-by-frame checks of the recordings: the flood covers the screen before the backdrop cuts (no white flash), the handoff frame matches the native splash, and the Android crossfade no longer shows the grid.

Fixes #1701

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh review of #1714 (fixes #1701). I found no correctness bug that blocks the animation itself. Findings are ranked below; 1 needs a decision before merge, the rest are optional or nits.

Findings

1. The native dependency contradicts the issue, and nothing records who approved it (decide before merge).
The fix idea in #1701 says to use Reanimated, "add no native dependency so the fingerprint is unchanged and it ships OTA". This PR adds react-native-ease (apps/mobile/package.json:41), which changes the runtime fingerprint and moves the release to a new TestFlight / Play build. The description is upfront about the consequence, but it doesn't say who decided it. It also carries a build risk: ease 0.8.0 applies kotlin-android unconditionally and only builds because the generated android/gradle.properties has android.builtInKotlin=false. I confirmed the flag is there. I also confirmed that npm's latest version is 0.8.0 and 0.8.1 isn't published. Fix: have the maintainer confirm the dependency and the store-build release path, then update #1701's fix idea to match. If OTA delivery matters more, port the same timeline to Reanimated.

2. Reduce Motion detection is async, which delays the splash hide behind a promise (medium; simplification).
splash-overlay.tsx:53-61, 69, 79: SplashScreen.hide() now also waits for AccessibilityInfo.isReduceMotionEnabled() to resolve, so the 300 ms fallback no longer guarantees the hide. The PR says the handoff is "unchanged from #1699", but it is now gated on this promise. It also adds a three-state reduceMotion (undefined/true/false) and an extra effect. The app already uses Reanimated's synchronous useReducedMotion() (stim-jar.tsx:11, device-zoom.ts:100), and it was the source here before this PR. Fix: const reduceMotion = useReducedMotion(); from react-native-reanimated, then drop the effect, the reduceMotion === undefined guard and that term in started. With the value known on the first render, the Reduce Motion path can also skip mounting the ~240 grid cells, the flood and the letters. Those never animate there and sit under an opaque backdrop. The boot test mocks useReducedMotion: () => true, so after this change it would exercise the crossfade path. It still asserts the overlay unmounts.

3. The unmount relies on a fixed 50 ms slack (low; optional).
splash-overlay.tsx:38-39, 72-75: the timer starts in the effect, while the ~250 native prop updates reach the UI thread later on a busy cold start. On a 402 pt wide phone, the last on-screen cells finish at about 871 ms and the unmount comes at 960 ms, leaving roughly 90 ms of real margin. If the UI thread is later than that, the remaining purple cells cut off instead of shrinking. The settle EaseView finishes at 1000 ms, after every cell (910 ms). Fix, if you want robustness over a constant: in the ripple path, unmount from its onTransitionEnd, and keep the timer only as a fallback.

4. What shows behind the 0.98 app is the native root background (low; cosmetic).
splash-overlay.tsx:86-96: from ~450 ms to 1000 ms, a 4-9 pt border of the native root view shows around the scaled app. On iOS that is systemBackground (pure black in dark mode, against the app's #15121D). On Android it is the DayNight window background. The effect is subtle, but it is visible in dark mode. Fix, if it shows in the recordings: give the wrapper's parent the current theme background.

5. Nits (style and comment policy).

  • apps/mobile/src/app/_layout.tsx:99-130: I ran oxfmt on main's file with only the <SplashOverlay> wrap added. It keeps the one-line options for pair, rename, mac/[id]/workspace and the Android headerStyle object, so their expansion here is manual. Revert those to keep the diff to re-indentation only.
  • splash-overlay.tsx:159: the pixelGrid doc comment restates the code. Delete it per the comment policy. The constant comments at 11-23, 27 and 38 name external constraints (the asset's geometry, Android's non-anti-aliased clip, Core Animation's 0-duration default, the Fabric mount timing) and can stay.

Checked and clean

  • Handoff and first frame: the flood (94 pt, r21) is fully covered by the icon's 96 pt/r22 square. The letters layer redraws the same image in the same position. SplashScreen.hide() runs in the same effect as the flip render. The 300 ms onDisplay fallback and the played flag are kept.
  • Error-boundary retry: an error before played remounts fresh. An error after it remounts with replayed: the app renders at scale 1 with no overlay and no animation. RootErrorBoundary still hides the splash.
  • Cut order: backdrop and icon cut at 330 ms, the flood at 350 ms. At t=330 the cubic-bezier(.7,0,.3,1) curve is at about 99.7%, a side of about 1030 pt against an 874 pt screen, so no white flash. Delayed animations use fillMode = backwards on iOS (EaseView.mm:350-352) and startDelay on Android. CUT_MS = 1 avoids Core Animation's 0.25 s default.
  • react-native-ease API usage: the per-category transition map (default + opacity), animating borderRadius (iOS cornerRadius, Android outline clip), rotate in degrees, and type: 'none'. On Android, useHardwareLayer only switches on while the view's own animation runs, so it costs nothing in the ripple path. iOS animates transform.scale.* and transform.rotation as separate component keypaths, so the cells still rotate while scaling to 0.
  • Permanent wrapper: EaseView subclasses ReactViewGroup / RCTViewComponentView, so pointerEvents and testID work as usual. After the settle its transform is identity. Form sheets and modals are presented natively outside this view, and MenuDrawer/gesture handling sit inside it unchanged. invalidateLayer re-applies the target transform (identity), so a trait-collection change later does no harm.
  • Dark mode: the flood and cells stay brand purple, and the backdrop uses the system appearance, matching the native splash config.
  • PR description: the numbers match the constants: 910 ms ripple end, 960 ms unmount, the 400-1000 ms settle, and 240 cells on a 402 pt phone / 260 on a 411 pt one.
  • Lockfile: the Sentry and @types/react-dom churn is exactly what pnpm install --lockfile-only produces from main plus this package.json.
  • Checks: lint, typecheck, format:check and test (182 passed) pass in apps/mobile. The branch merges cleanly into current origin/main.

@janicduplessis janicduplessis left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 8da4949: clear. I found no remaining actionable findings.

  • 1. Scope: #1701 now records the maintainer's decision to use react-native-ease and ship a store build, and the PR body links it.
  • 2. Reduce Motion: useReducedMotion() from Reanimated is synchronous. The promise, the three-state value and the extra gate on SplashScreen.hide() are gone, so the 300 ms fallback is unconditional again. Under Reduce Motion the grid is not mounted, and the crossfade covers only the backdrop, icon and letters. The boot test's useReducedMotion: () => true mock now exercises that crossfade path, as it did on main, and still asserts that the overlay unmounts.
  • 3. Unmount slack: I accept leaving it. The margin is about 90 ms, and the fallback path would add a second route to the unmount.
  • 4. Settle border: I accept leaving it. The measured strip is 1-2 px, because the edge cells uncover last, when the app is already at about 0.995 scale.
  • 5. Nits: the _layout.tsx diff is now identical to oxfmt's output for main plus the <SplashOverlay> wrap, and the pixelGrid doc comment is removed.

In apps/mobile, lint, typecheck, format:check and the tests pass (182/182).

@janicduplessis
janicduplessis marked this pull request as ready for review September 28, 2026 03:46
@janicduplessis
janicduplessis merged commit 957ce42 into main Sep 28, 2026
9 checks passed
@janicduplessis
janicduplessis deleted the feat/1701-splash-pixel-ripple branch September 28, 2026 03:46
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.

Phone app: replace the splash dismiss with the pixel ripple concept

1 participant