feat(mobile): dismiss the splash with a pixel ripple built on react-native-ease - #1714
Conversation
janicduplessis
left a comment
There was a problem hiding this comment.
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-lineoptionsforpair,rename,mac/[id]/workspaceand the AndroidheaderStyleobject, so their expansion here is manual. Revert those to keep the diff to re-indentation only.splash-overlay.tsx:159: thepixelGriddoc 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 msonDisplayfallback and theplayedflag are kept. - Error-boundary retry: an error before
playedremounts fresh. An error after it remounts withreplayed: the app renders at scale 1 with no overlay and no animation.RootErrorBoundarystill 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 usefillMode = backwardson iOS (EaseView.mm:350-352) andstartDelayon Android.CUT_MS = 1avoids Core Animation's 0.25 s default. - react-native-ease API usage: the per-category
transitionmap (default+opacity), animatingborderRadius(iOScornerRadius, Android outline clip),rotatein degrees, andtype: 'none'. On Android,useHardwareLayeronly switches on while the view's own animation runs, so it costs nothing in the ripple path. iOS animatestransform.scale.*andtransform.rotationas separate component keypaths, so the cells still rotate while scaling to 0. - Permanent wrapper:
EaseViewsubclassesReactViewGroup/RCTViewComponentView, sopointerEventsandtestIDwork as usual. After the settle its transform is identity. Form sheets and modals are presented natively outside this view, andMenuDrawer/gesture handling sit inside it unchanged.invalidateLayerre-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-domchurn is exactly whatpnpm install --lockfile-onlyproduces from main plus thispackage.json. - Checks: lint, typecheck, format:check and test (182 passed) pass in
apps/mobile. The branch merges cleanly into currentorigin/main.
janicduplessis
left a comment
There was a problem hiding this comment.
Re-review of 8da4949: clear. I found no remaining actionable findings.
- 1. Scope: #1701 now records the maintainer's decision to use
react-native-easeand 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 onSplashScreen.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'suseReducedMotion: () => truemock 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.tsxdiff is now identical to oxfmt's output for main plus the<SplashOverlay>wrap, and thepixelGriddoc comment is removed.
In apps/mobile, lint, typecheck, format:check and the tests pass (182/182).
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-ease0.8.0, a Fabric native component. Adding it changes the runtime fingerprint (iOS6f8711a5…->237669dc…, Androidb26cd7c7…->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
SplashOverlaynow wraps the app so it can scale it. Nothing animates from JS: once the native splash is hidden, one render flips everyEaseViewto its end state, and each view carries its own duration and delay, so Core Animation and Android'sObjectAnimatorrun the whole timeline:cubic-bezier(.7,0,.3,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 usesuseHardwareLayer: 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-androidunconditionally, which breaks on AGP 9 unlessandroid.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:ios-light.mp4
ios-dark.mp4
ios-reduce.mp4
android-light.mp4
android-dark.mp4
android-reduce.mp4
Reduce Motion was set with
defaults write com.apple.Accessibility ReduceMotionEnabledon iOS andsettings put global transition_animation_scale 0on Android.Frame rate (Android emulator):
dumpsys gfxinfo framestatsover 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