diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b803a418e..a553b14c5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -38,6 +38,7 @@ "react-dom": "19.3.0", "react-native": "0.88.0-rc.1", "react-native-drawer-layout": "^4.2.10", + "react-native-ease": "0.8.0", "react-native-gesture-handler": "~3.3.0", "react-native-mmkv": "4.3.2", "react-native-nitro-modules": "0.37.1", diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 2888b3292..20d3bad4d 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -74,61 +74,62 @@ function RootLayoutContent() { return ( - - - - - - - - - - - + + + + + + + - - - - - - - - - - - - - - + > + + + + + + + + + + + + + + + + + + ); } diff --git a/apps/mobile/src/components/splash-overlay.tsx b/apps/mobile/src/components/splash-overlay.tsx index 782f8a790..aa9d7ddc3 100644 --- a/apps/mobile/src/components/splash-overlay.tsx +++ b/apps/mobile/src/components/splash-overlay.tsx @@ -1,17 +1,9 @@ import { Image } from 'expo-image'; import * as SplashScreen from 'expo-splash-screen'; -import { useEffect, useState } from 'react'; -import { Appearance, StyleSheet } from 'react-native'; -import Animated, { - Easing, - ReduceMotion, - useAnimatedStyle, - useReducedMotion, - useSharedValue, - withDelay, - withSequence, - withTiming, -} from 'react-native-reanimated'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { Appearance, StyleSheet, useWindowDimensions, View } from 'react-native'; +import { EaseView } from 'react-native-ease'; +import { useReducedMotion } from 'react-native-reanimated'; import { colors } from '@/design/tokens'; @@ -19,28 +11,50 @@ const ICON = require('@/assets/images/icon.png'); /** `imageWidth` of the `expo-splash-screen` config in app.config.ts. */ const ICON_SIZE = 120; +/** + * icon.png draws a 96 pt purple square with a 22 pt radius. The flood starts 1 pt inside it so the icon hides it + * completely on the first frame. + */ +const FLOOD_SIZE = 94; +const FLOOD_RADIUS = 21; +/** + * icon.png's white letter circle is 66 pt wide. Android clips without anti-aliasing, so the clip keeps a purple margin + * and its edge falls on purple. + */ +const LETTERS_CLIP = 72; +const COLUMNS = 10; -const SQUEEZE_MS = 140; -const BURST_MS = 520; +const FLOOD_MS = 350; +/** Core Animation replaces a 0 duration with its 0.25 s default. */ +const CUT_MS = 1; +const RIPPLE_DELAY_MS = 100; +const RIPPLE_SPREAD_MS = 300; +const PIXEL_MS = 160; +const RIPPLE_END_MS = FLOOD_MS + RIPPLE_DELAY_MS + RIPPLE_SPREAD_MS + PIXEL_MS; +const POP_MS = 90; +const SHRINK_MS = 130; +const SETTLE_DELAY_MS = FLOOD_MS + 50; +const SETTLE_MS = 600; const CROSSFADE_MS = 250; +/** The native animations start once this render reaches the UI thread, after the unmount timer starts. */ +const UNMOUNT_SLACK_MS = 50; const DISPLAY_TIMEOUT_MS = 300; let played = false; /** - * Draws the native splash in JS and hides the native one once this frame is on screen, then plays the dismiss once - * per app process: the icon squeezes, then bursts toward the viewer while a ring pulses out and the background clears. - * Reduce Motion gets a crossfade. + * Wraps the app, draws the native splash over it, and hides the native splash once this frame is on screen. Then it + * plays the dismiss once per app process: the icon's purple square floods the screen, the letters pop and shrink, and + * the purple breaks into pixels that vanish outward from the logo while the app settles into place. Reduce Motion gets + * a crossfade. */ -export function SplashOverlay() { +export function SplashOverlay({ children }: { children: ReactNode }) { const [visible, setVisible] = useState(!played); const [scheme] = useState<'light' | 'dark'>(() => (Appearance.getColorScheme() === 'dark' ? 'dark' : 'light')); const reduceMotion = useReducedMotion(); const [displayed, setDisplayed] = useState(false); - const backdrop = useSharedValue(1); - const iconScale = useSharedValue(1); - const iconOpacity = useSharedValue(1); - const ring = useSharedValue(0); + const [replayed] = useState(played); + const { width, height } = useWindowDimensions(); useEffect(() => { if (!visible) return; @@ -50,58 +64,129 @@ export function SplashOverlay() { } played = true; SplashScreen.hide(); - if (reduceMotion) { - const crossfade = { duration: CROSSFADE_MS, reduceMotion: ReduceMotion.Never }; - backdrop.set(withTiming(0, crossfade)); - iconOpacity.set(withTiming(0, crossfade)); - } else { - iconScale.set( - withSequence( - withTiming(0.9, { duration: SQUEEZE_MS, easing: Easing.out(Easing.quad) }), - withTiming(4, { duration: BURST_MS, easing: Easing.in(Easing.cubic) }), - ), - ); - iconOpacity.set(withDelay(SQUEEZE_MS + BURST_MS * 0.3, withTiming(0, { duration: BURST_MS * 0.7 }))); - ring.set(withDelay(SQUEEZE_MS, withTiming(1, { duration: BURST_MS, easing: Easing.out(Easing.cubic) }))); - backdrop.set(withDelay(SQUEEZE_MS, withTiming(0, { duration: BURST_MS, easing: Easing.inOut(Easing.quad) }))); - } - const timeout = setTimeout(() => setVisible(false), reduceMotion ? CROSSFADE_MS : SQUEEZE_MS + BURST_MS); + const timeout = setTimeout( + () => setVisible(false), + (reduceMotion ? CROSSFADE_MS : RIPPLE_END_MS) + UNMOUNT_SLACK_MS, + ); return () => clearTimeout(timeout); - }, [visible, displayed, reduceMotion, backdrop, iconScale, iconOpacity, ring]); + }, [visible, displayed, reduceMotion]); - const backdropStyle = useAnimatedStyle(() => ({ opacity: backdrop.get() })); - const iconStyle = useAnimatedStyle(() => ({ - opacity: iconOpacity.get(), - transform: [{ scale: iconScale.get() }, { rotate: `${(iconScale.get() - 1) * -4}deg` }], - })); - const ringStyle = useAnimatedStyle(() => ({ - opacity: ring.get() === 0 ? 0 : 0.5 * (1 - ring.get()), - transform: [{ scale: 0.6 + ring.get() * 6 }], - })); + const started = replayed || displayed; + const grid = useMemo(() => pixelGrid(width, height), [width, height]); + const ripple = started && !reduceMotion; + const floodScale = (Math.max(width, height) + 4 * grid.size) / FLOOD_SIZE; - if (!visible) return null; return ( - - - - - - setDisplayed(true)} /> - - - + <> + + {children} + + {visible && ( + + {!reduceMotion && ( + + {grid.cells.map((cell) => ( + + ))} + + )} + + + + setDisplayed(true)} /> + + + + + + + + )} + ); } +function pixelGrid(width: number, height: number) { + const size = width / COLUMNS; + const rows = (Math.ceil(height / 2 / size) + 1) * 2; + const gridHeight = rows * size; + const maxDistance = Math.hypot(width / 2, gridHeight / 2); + const cells = []; + for (let row = 0; row < rows; row++) { + for (let column = 0; column < COLUMNS; column++) { + const left = column * size; + const top = row * size; + const distance = Math.hypot(left + size / 2 - width / 2, top + size / 2 - gridHeight / 2); + const delay = Math.round(FLOOD_MS + RIPPLE_DELAY_MS + (distance / maxDistance) * RIPPLE_SPREAD_MS); + cells.push({ key: `${row}:${column}`, left, top, delay }); + } + } + return { size, width, height: gridHeight, cells }; +} + const styles = StyleSheet.create({ + app: { flex: 1 }, center: { alignItems: 'center', justifyContent: 'center' }, + grid: { position: 'absolute' }, icon: { width: ICON_SIZE, height: ICON_SIZE }, - ring: { - position: 'absolute', - width: ICON_SIZE * 0.8, - height: ICON_SIZE * 0.8, - borderRadius: ICON_SIZE * 0.4, - borderWidth: 4, - borderColor: colors.light.brand, + flood: { position: 'absolute', width: FLOOD_SIZE, height: FLOOD_SIZE, backgroundColor: colors.light.brand }, + pixel: { position: 'absolute', backgroundColor: colors.light.brand }, + letters: { position: 'absolute', width: LETTERS_CLIP, height: LETTERS_CLIP }, + lettersClip: { width: LETTERS_CLIP, height: LETTERS_CLIP, borderRadius: LETTERS_CLIP / 2, overflow: 'hidden' }, + lettersIcon: { + width: ICON_SIZE, + height: ICON_SIZE, + marginLeft: (LETTERS_CLIP - ICON_SIZE) / 2, + marginTop: (LETTERS_CLIP - ICON_SIZE) / 2, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 383f4e375..a95cff044 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,7 +147,7 @@ importers: version: 58.0.7(@babel/core@7.29.7(supports-color@8.1.1))(expo@58.0.0-preview.7)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) '@sentry/react-native': specifier: 8.28.0 - version: 8.28.0(672b379b6e7e68e5b9eeed6245d458db) + version: 8.28.0(9457479bd38008a58fa8d0b0ce8065f8) expo: specifier: ~58.0.0-preview.7 version: 58.0.0-preview.7(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@58.0.1)(@expo/metro-runtime@58.0.7)(expo-router@58.0.8)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3) @@ -208,6 +208,9 @@ importers: react-native-drawer-layout: specifier: ^4.2.10 version: 4.2.10(b2eb7b3ef08c15606ffa7158704edab4) + react-native-ease: + specifier: 0.8.0 + version: 0.8.0(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) react-native-gesture-handler: specifier: ~3.3.0 version: 3.3.0(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) @@ -9476,6 +9479,19 @@ packages: react-native-gesture-handler: '>= 2.0.0' react-native-reanimated: '>= 2.0.0' + react-native-ease@0.8.0: + resolution: {integrity: sha512-V77tfBOPt8RXZIPY+/VQYqp0OTE1n6Wp9kNaauAIHUxGZ39q3jMd3d2VJLrjH4u/r+/+dZbBdONCmV1pdEtq2Q==} + peerDependencies: + nativewind: '>=4' + react: '*' + react-native: '*' + uniwind: '>=1' + peerDependenciesMeta: + nativewind: + optional: true + uniwind: + optional: true + react-native-gesture-handler@3.3.0: resolution: {integrity: sha512-ZCTlJ09nE3GPbm7J74lvwpxmx1TQ/7MDlHZNLARVYTN82k21mJUJ7BmI9eWjEi8eevHiAudI5O1Cpnt0rJfoJA==} peerDependencies: @@ -15697,17 +15713,15 @@ snapshots: '@sentry/replay': 10.75.2 '@sentry/replay-canvas': 10.75.2 - '@sentry/bundler-plugins@10.75.2(supports-color@8.1.1)(webpack@5.109.2(@swc/core@1.16.1)(@swc/html@1.16.1)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))': + '@sentry/bundler-plugins@10.75.2': dependencies: '@babel/core': 7.29.7(supports-color@8.1.1) - '@sentry/cli': 2.58.6(supports-color@8.1.1) + '@sentry/cli': 2.58.6 '@sentry/core': 10.75.2 dotenv: 17.4.2 find-up: 5.0.0 glob: 13.0.6 magic-string: 0.30.21 - optionalDependencies: - webpack: 5.109.2(@swc/core@1.16.1)(@swc/html@1.16.1)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26) transitivePeerDependencies: - encoding - supports-color @@ -15760,7 +15774,7 @@ snapshots: '@sentry/cli-win32-x64@3.8.0': optional: true - '@sentry/cli@2.58.6(supports-color@8.1.1)': + '@sentry/cli@2.58.6': dependencies: https-proxy-agent: 5.0.1(supports-color@8.1.1) node-fetch: 2.7.0 @@ -15813,10 +15827,10 @@ snapshots: dependencies: '@sentry/core': 10.75.2 - '@sentry/react-native@8.28.0(672b379b6e7e68e5b9eeed6245d458db)': + '@sentry/react-native@8.28.0(9457479bd38008a58fa8d0b0ce8065f8)': dependencies: '@sentry/browser': 10.75.2 - '@sentry/bundler-plugins': 10.75.2(supports-color@8.1.1)(webpack@5.109.2(@swc/core@1.16.1)(@swc/html@1.16.1)(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) + '@sentry/bundler-plugins': 10.75.2 '@sentry/cli': 3.8.0 '@sentry/core': 10.75.2 '@sentry/expo-upload-sourcemaps': 8.28.0(@expo/env@2.5.0(supports-color@8.1.1))(dotenv@17.4.2) @@ -15830,7 +15844,7 @@ snapshots: '@expo/env': 2.5.0(supports-color@8.1.1) dotenv: 17.4.2 expo: 58.0.0-preview.7(@babel/core@7.29.7(supports-color@8.1.1))(@expo/dom-webview@58.0.1)(@expo/metro-runtime@58.0.7)(expo-router@58.0.8)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1)(typescript@6.0.3) - expo-router: 58.0.8(729283f6b97d3a4167f8d9d876f4826b) + expo-router: 58.0.8(71370cef79192531445932e2a9c1508c) expo-updates: 58.0.9(expo-dev-client@58.0.7(expo@58.0.0-preview.7)(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1)))(expo@58.0.0-preview.7)(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1) metro: 0.87.1(supports-color@8.1.1) metro-config: 0.87.1(supports-color@8.1.1) @@ -16276,6 +16290,10 @@ snapshots: dependencies: '@types/react': 19.2.18 + '@types/react-dom@19.2.7(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + '@types/react-reconciler@0.33.0(@types/react@19.3.0)': dependencies: '@types/react': 19.3.0 @@ -22344,6 +22362,11 @@ snapshots: react-native-reanimated: 4.7.0(react-native-worklets@0.13.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)(supports-color@8.1.1))(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) use-latest-callback: 0.2.6(react@19.3.0) + react-native-ease@0.8.0(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0): + dependencies: + react: 19.3.0 + react-native: 0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1) + react-native-gesture-handler@3.3.0(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0): dependencies: '@types/react-test-renderer': 19.3.0