diff --git a/.gitignore b/.gitignore index 26f7a3b..cdaf25a 100644 --- a/.gitignore +++ b/.gitignore @@ -85,4 +85,3 @@ android/generated # React Native Nitro Modules nitrogen/ .playwright-cli/ -*.png diff --git a/example/app.json b/example/app.json index 466d381..0596f32 100644 --- a/example/app.json +++ b/example/app.json @@ -1,11 +1,15 @@ { - "name": "EaseExample", - "displayName": "EaseExample", + "name": "Ease", + "displayName": "Ease", "expo": { - "name": "EaseExample", + "name": "Ease", "slug": "ease-example", "scheme": "ease-example", "version": "1.0.0", + "icon": "./assets/icon.png", + "splash": { + "backgroundColor": "#1a1a2e" + }, "plugins": [ [ "expo-router", @@ -16,7 +20,8 @@ ], "web": { "bundler": "metro", - "output": "single" + "output": "single", + "favicon": "./assets/favicon.png" }, "platforms": [ "ios", @@ -24,7 +29,11 @@ "web" ], "android": { - "package": "com.janic.easeexample" + "package": "com.janic.easeexample", + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#1a1a2e" + } }, "ios": { "bundleIdentifier": "com.janic.easeexample" diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index 191adf2..786cac5 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -1,17 +1,21 @@ import { Stack } from 'expo-router'; +import { MaxWidthContainer } from '../src/components/MaxWidthContainer'; + export default function RootLayout() { return ( - - - + + + + + ); } diff --git a/example/assets/adaptive-icon.png b/example/assets/adaptive-icon.png new file mode 100644 index 0000000..6e026c1 Binary files /dev/null and b/example/assets/adaptive-icon.png differ diff --git a/example/assets/favicon.png b/example/assets/favicon.png new file mode 100644 index 0000000..aaf0f3c Binary files /dev/null and b/example/assets/favicon.png differ diff --git a/example/assets/icon.png b/example/assets/icon.png new file mode 100644 index 0000000..e93ef0d Binary files /dev/null and b/example/assets/icon.png differ diff --git a/example/package.json b/example/package.json index 26f4650..d3c45f1 100644 --- a/example/package.json +++ b/example/package.json @@ -10,7 +10,7 @@ "web": "expo start --web", "prebuild": "expo prebuild", "build:android": "expo prebuild --platform android --clean && cd android && ./gradlew assembleDebug --no-daemon --console=plain -PreactNativeArchitectures=arm64-v8a", - "build:ios": "expo prebuild --platform ios --clean && xcodebuild -workspace ios/EaseExample.xcworkspace -scheme EaseExample -configuration Debug -sdk iphonesimulator -arch x86_64 build" + "build:ios": "expo prebuild --platform ios --clean && xcodebuild -workspace ios/Ease.xcworkspace -scheme Ease -configuration Debug -sdk iphonesimulator -arch x86_64 build" }, "dependencies": { "@expo/metro-runtime": "~55.0.6", diff --git a/example/public/index.html b/example/public/index.html new file mode 100644 index 0000000..5145852 --- /dev/null +++ b/example/public/index.html @@ -0,0 +1,32 @@ + + + + + + + + Ease + + + + +
+ + diff --git a/example/src/components/MaxWidthContainer.tsx b/example/src/components/MaxWidthContainer.tsx new file mode 100644 index 0000000..2caaf03 --- /dev/null +++ b/example/src/components/MaxWidthContainer.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; + +const MAX_WIDTH = 600; + +export function MaxWidthContainer({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + }, + inner: { + flex: 1, + width: '100%', + maxWidth: MAX_WIDTH, + }, +}); diff --git a/package.json b/package.json index 6066688..9f52fd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-ease", - "version": "0.3.0", + "version": "0.4.0", "description": "Lightweight declarative animations powered by platform APIs", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/src/EaseView.web.tsx b/src/EaseView.web.tsx index 5c4d8b9..1592df9 100644 --- a/src/EaseView.web.tsx +++ b/src/EaseView.web.tsx @@ -30,6 +30,80 @@ const EASING_PRESETS: Record = { easeInOut: [0.42, 0, 0.58, 1], }; +// --------------------------------------------------------------------------- +// Spring simulation → CSS linear() easing +// --------------------------------------------------------------------------- + +/** Simulate a damped spring from 0 → 1 and return settling duration + sample points. */ +function simulateSpring( + damping: number, + stiffness: number, + mass: number, +): { durationMs: number; points: number[] } { + const dt = 1 / 120; // 120 Hz simulation + const maxTime = 10; // 10s safety cap + let x = 0; + let v = 0; + const samples: number[] = [0]; + let step = 0; + + while (step * dt < maxTime) { + const a = (-stiffness * (x - 1) - damping * v) / mass; + v += a * dt; + x += v * dt; + step++; + // Downsample to ~60 fps (every 2nd sample) + if (step % 2 === 0) { + samples.push(Math.round(x * 10000) / 10000); + } + // Settled? + if (Math.abs(x - 1) < 0.001 && Math.abs(v) < 0.001) break; + } + + // Ensure last point is exactly 1 + samples[samples.length - 1] = 1; + + return { + durationMs: Math.round(step * dt * 1000), + points: samples, + }; +} + +/** Cache for computed spring easing strings (keyed by damping-stiffness-mass). */ +const springCache = new Map(); + +function getSpringEasing( + damping: number, + stiffness: number, + mass: number, +): { duration: number; easing: string } { + const key = `${damping}-${stiffness}-${mass}`; + let cached = springCache.get(key); + if (cached) return cached; + + const { durationMs, points } = simulateSpring(damping, stiffness, mass); + const easing = `linear(${points.join(', ')})`; + cached = { duration: durationMs, easing }; + springCache.set(key, cached); + return cached; +} + +/** Detect CSS linear() support (lazy, cached). */ +let linearSupported: boolean | null = null; +function supportsLinearEasing(): boolean { + if (linearSupported != null) return linearSupported; + try { + const el = document.createElement('div'); + el.style.transitionTimingFunction = 'linear(0, 1)'; + linearSupported = el.style.transitionTimingFunction !== ''; + } catch { + linearSupported = false; + } + return linearSupported; +} + +const SPRING_FALLBACK_EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'; + export type EaseViewProps = { animate?: AnimateProps; initialAnimate?: AnimateProps; @@ -154,9 +228,19 @@ function resolvePerCategoryConfigs( } function resolveEasing(transition: SingleTransition | undefined): string { - if (!transition || transition.type !== 'timing') { - return 'cubic-bezier(0.42, 0, 0.58, 1)'; + if (!transition || transition.type === 'none') { + return 'linear'; + } + if (transition.type === 'spring') { + const d = transition.damping ?? 15; + const s = transition.stiffness ?? 120; + const m = transition.mass ?? 1; + if (supportsLinearEasing()) { + return getSpringEasing(d, s, m).easing; + } + return SPRING_FALLBACK_EASING; } + // timing const easing = transition.easing ?? 'easeInOut'; const bezier: CubicBezier = Array.isArray(easing) ? easing @@ -168,10 +252,11 @@ function resolveDuration(transition: SingleTransition | undefined): number { if (!transition) return 300; if (transition.type === 'timing') return transition.duration ?? 300; if (transition.type === 'none') return 0; - const damping = transition.damping ?? 15; - const mass = transition.mass ?? 1; - const tau = (2 * mass) / damping; - return Math.round(tau * 4 * 1000); + // Spring: use simulation-derived duration (incorporates stiffness) + const d = transition.damping ?? 15; + const s = transition.stiffness ?? 120; + const m = transition.mass ?? 1; + return getSpringEasing(d, s, m).duration; } /** Counter for unique keyframe names. */ @@ -237,13 +322,7 @@ export function EaseView({ }) .map((key) => { const cfg = categoryConfigs[key]; - const springEasing = - cfg.type === 'spring' - ? 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' - : null; - return `${CSS_PROP_MAP[key]} ${cfg.duration}ms ${ - springEasing ?? cfg.easing - }`; + return `${CSS_PROP_MAP[key]} ${cfg.duration}ms ${cfg.easing}`; }) .join(', ') || 'none';