Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,3 @@ android/generated
# React Native Nitro Modules
nitrogen/
.playwright-cli/
*.png
19 changes: 14 additions & 5 deletions example/app.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -16,15 +20,20 @@
],
"web": {
"bundler": "metro",
"output": "single"
"output": "single",
"favicon": "./assets/favicon.png"
},
"platforms": [
"ios",
"android",
"web"
],
"android": {
"package": "com.janic.easeexample"
"package": "com.janic.easeexample",
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#1a1a2e"
}
},
"ios": {
"bundleIdentifier": "com.janic.easeexample"
Expand Down
26 changes: 15 additions & 11 deletions example/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import { Stack } from 'expo-router';

import { MaxWidthContainer } from '../src/components/MaxWidthContainer';

export default function RootLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#1a1a2e' },
headerTintColor: '#fff',
headerTitleStyle: { fontWeight: '700' },
headerBackButtonDisplayMode: 'minimal',
contentStyle: { backgroundColor: '#1a1a2e' },
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
</Stack>
<MaxWidthContainer>
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#1a1a2e' },
headerTintColor: '#fff',
headerTitleStyle: { fontWeight: '700' },
headerBackButtonDisplayMode: 'minimal',
contentStyle: { backgroundColor: '#1a1a2e' },
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
</Stack>
</MaxWidthContainer>
);
}
Binary file added example/assets/adaptive-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example/assets/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added example/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 32 additions & 0 deletions example/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#1a1a2e" />
<title>Ease</title>
<style id="expo-reset">
html,
body {
height: 100%;
}
body {
overflow: hidden;
background-color: #1a1a2e;
}
#root {
display: flex;
height: 100%;
flex: 1;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
24 changes: 24 additions & 0 deletions example/src/components/MaxWidthContainer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<View style={styles.inner}>{children}</View>
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
},
inner: {
flex: 1,
width: '100%',
maxWidth: MAX_WIDTH,
},
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
105 changes: 92 additions & 13 deletions src/EaseView.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,80 @@ const EASING_PRESETS: Record<string, CubicBezier> = {
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<string, { duration: number; easing: string }>();

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;
Expand Down Expand Up @@ -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
Expand All @@ -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. */
Expand Down Expand Up @@ -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';

Expand Down
Loading