Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ const SUPPORTED_INTERPOLATION_PARAMS: {[string]: true} = {
extrapolate: true,
extrapolateRight: true,
extrapolateLeft: true,
easing: true,
};

/**
Expand Down
119 changes: 118 additions & 1 deletion packages/react-native/Libraries/Animated/__tests__/Animated-itest.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import ensureInstance from '../../../src/private/__tests__/utilities/ensureInsta
import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
import * as Fantom from '@react-native/fantom';
import {createRef} from 'react';
import {Animated, View, useAnimatedValue} from 'react-native';
import {Animated, Easing, View, useAnimatedValue} from 'react-native';
import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist';
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';

Expand Down Expand Up @@ -87,6 +87,123 @@ test('moving box by 100 points', () => {
expect(viewElement.getBoundingClientRect().x).toBe(100);
});

// A native-driven interpolation with a custom `easing` should follow the easing
// curve, not run linearly. The driver animates linearly 0 -> 1; the eased
// interpolation maps it to translateX 0 -> 100 with Easing.quad (t^2). At the
// midpoint (driver = 0.5) the eased value is 0.5^2 * 100 = 25 (a linear mapping
// would be 50). The easing is baked into the native interpolation config as an
// `easingStops` lookup table, so the native driver reproduces the curve.
test('native-driven interpolation honors custom easing', () => {
let _progress;
const viewRef = createRef<HostInstance>();

function MyApp() {
const progress = useAnimatedValue(0);
_progress = progress;
const translateX = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
easing: Easing.quad,
});
return (
<Animated.View
ref={viewRef}
style={[{width: 100, height: 100}, {transform: [{translateX}]}]}
/>
);
}

const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(<MyApp />);
});

const viewElement = ensureInstance(viewRef.current, ReactNativeElement);

Fantom.runTask(() => {
Animated.timing(_progress, {
toValue: 1,
duration: 1000, // 1 second
easing: Easing.linear,
useNativeDriver: true,
}).start();
});

Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);

const transform =
// $FlowFixMe[incompatible-use]
Fantom.unstable_getDirectManipulationProps(viewElement).transform[0];

// Driver is 50% through (linear timing), but the interpolation's quad easing
// reshapes it: 0.5^2 * 100 = 25, not the linear 50.
expect(transform.translateX).toBeCloseTo(25, 0.001);

Fantom.unstable_produceFramesForDuration(500);

// Animation complete; final committed position is the full 100.
Fantom.runWorkLoop();
expect(viewElement.getBoundingClientRect().x).toBe(100);
});

// When the easing leaves [0, 1] (Easing.back dips below 0 early), that excursion
// must be preserved even under `extrapolate: 'clamp'`. The driver runs 0 -> 1, so
// the input is always in range — `clamp` should only affect out-of-range *input*,
// never the easing's own excursion. Pre-fix the native driver clamped it away
// (translateX pinned to 0); JS keeps it negative. This guards that parity.
test('native-driven interpolation preserves easing overshoot under clamp', () => {
let _progress;
const viewRef = createRef<HostInstance>();

function MyApp() {
const progress = useAnimatedValue(0);
_progress = progress;
const translateX = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
easing: Easing.back(),
extrapolate: 'clamp',
});
return (
<Animated.View
ref={viewRef}
style={[{width: 100, height: 100}, {transform: [{translateX}]}]}
/>
);
}

const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(<MyApp />);
});

const viewElement = ensureInstance(viewRef.current, ReactNativeElement);

Fantom.runTask(() => {
Animated.timing(_progress, {
toValue: 1,
duration: 1000,
easing: Easing.linear,
useNativeDriver: true,
}).start();
});

// ~20% through: Easing.back(0.2) ≈ -0.046 -> translateX ≈ -4.6, i.e. negative.
// If the excursion were clamped (the bug), translateX would stay at 0.
Fantom.unstable_produceFramesForDuration(200 + DEFERRED_START_MS);
const transform =
// $FlowFixMe[incompatible-use]
Fantom.unstable_getDirectManipulationProps(viewElement).transform[0];
expect(transform.translateX).toBeLessThan(0);

// Completes at the in-range endpoint (Easing.back(1) === 1 -> 100).
Fantom.unstable_produceFramesForDuration(800);
Fantom.runWorkLoop();
expect(viewElement.getBoundingClientRect().x).toBe(100);
});

// Validate that a `useNativeDriver` timing animation does not begin progressing
// until the end of the event loop tick it was started in.
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,3 +406,184 @@ describe('Interpolation', () => {
},
);
});

describe('Interpolation easingStops (native easing baking)', () => {
// Returns the non-uniform [position, value] easing stops emitted in the native
// config for an eased numeric interpolation (or undefined when no easing).
function getEasingStops(
config: InterpolationConfigType<number>,
): ?Array<[number, number]> {
return new AnimatedInterpolation(
// $FlowFixMe[incompatible-type]
{},
config,
).__getNativeConfig().easingStops;
}

// Mirrors the native easeRatio(): binary-search the bracketing stops and
// linearly interpolate. Out-of-[0,1] ratios pass through (extrapolation).
function reconstructEaseRatio(
stops: Array<[number, number]>,
): (ratio: number) => number {
return ratio => {
if (stops.length < 2 || ratio < 0 || ratio > 1) {
return ratio;
}
const upper = stops.findIndex(stop => stop[0] > ratio);
if (upper === -1) {
return stops[stops.length - 1][1];
}
if (upper === 0) {
return stops[0][1];
}
const [xLo, yLo] = stops[upper - 1];
const [xHi, yHi] = stops[upper];
if (xHi === xLo) {
return yHi;
}
return yLo + (yHi - yLo) * ((ratio - xLo) / (xHi - xLo));
};
}

// Max error, in OUTPUT units, between the baked stops and the true easing
// curve across the [0, 1] domain (sampled finely). This is what actually
// shows up on screen, e.g. pixels for a translate.
function maxOutputError(
easing: (input: number) => number,
span: number,
): number {
const stops = getEasingStops({
inputRange: [0, 1],
outputRange: [0, span],
easing,
});
if (stops == null) {
throw new Error('expected easingStops to be emitted');
}
const approx = reconstructEaseRatio(stops);
let maxErr = 0;
for (let i = 0; i <= 1000; i++) {
const t = i / 1000;
const err = Math.abs(approx(t) - easing(t)) * span;
if (err > maxErr) {
maxErr = err;
}
}
return maxErr;
}

// Computed once; reused for both the exact-output and the stop-count assertions.
const customLinear = getEasingStops({
inputRange: [0, 1],
outputRange: [0, 100],
easing: (t: number) => t,
});
const quad = getEasingStops({
inputRange: [0, 1],
outputRange: [0, 100],
easing: Easing.quad,
});
const bounce = getEasingStops({
inputRange: [0, 1],
outputRange: [0, 100],
easing: Easing.bounce,
});
const sine = getEasingStops({
inputRange: [0, 1],
outputRange: [0, 10],
easing: Easing.inOut(Easing.sin),
});

it('omits easingStops when easing is linear by identity or absent', () => {
const base = {inputRange: [0, 1], outputRange: [0, 100]};
expect(getEasingStops(base)).toBe(undefined);
expect(getEasingStops({...base, easing: Easing.linear})).toBe(undefined);
});

it('bakes the curve into exact stops whose count adapts to curvature', () => {
// A custom linear fn (not the Easing.linear reference, so not short-circuited)
// collapses to the two endpoints: every interior sample lies on the chord.
expect(customLinear).toEqual([
[0, 0],
[1, 1],
]);

// Constant-curvature quad -> RDP's midpoint splitting yields uniform 1/16
// spacing; each value is the eased ratio (k/16)^2, independent of span.
expect(quad).toEqual([
[0, 0],
[0.0625, 0.00390625],
[0.125, 0.015625],
[0.1875, 0.03515625],
[0.25, 0.0625],
[0.3125, 0.09765625],
[0.375, 0.140625],
[0.4375, 0.19140625],
[0.5, 0.25],
[0.5625, 0.31640625],
[0.625, 0.390625],
[0.6875, 0.47265625],
[0.75, 0.5625],
[0.8125, 0.66015625],
[0.875, 0.765625],
[0.9375, 0.87890625],
[1, 1],
]);

// A sine S-curve is placed non-uniformly: stops cluster at the two bends and
// leave a large gap across the near-linear middle (0.35 -> 0.62).
expect(sine).toEqual([
[0, 0],
[0.10546875, 0.02719633730973936],
[0.21875, 0.1134947733186315],
[0.34765625, 0.26973064452088],
[0.62109375, 0.6856585969759188],
[0.7421875, 0.8447702723685335],
[0.875, 0.9619397662556434],
[1, 1],
]);

// Stop count rises with curvature — 2 (flat) -> 17 (quad) -> 45 (bounce) —
// and is always bounded by the dense-sample budget (256 + 1 = 257).
expect(bounce?.length).toBe(45);
expect(bounce?.length).toBeLessThanOrEqual(257);
});

it('grows the stop count with output span, capped by the tolerance floor', () => {
// Bigger span -> smaller tolerance -> more stops for the same curve...
expect(
getEasingStops({
inputRange: [0, 1],
outputRange: [0, 10],
easing: Easing.quad,
})?.length,
).toBe(9);
expect(quad?.length).toBe(17); // span 100, computed once above
expect(
getEasingStops({
inputRange: [0, 1],
outputRange: [0, 1000],
easing: Easing.quad,
})?.length,
).toBe(33);
// ...until epsilon hits its floor: a smooth curve caps out (65) rather than
// densifying toward the dense-sample budget.
expect(
getEasingStops({
inputRange: [0, 1],
outputRange: [0, 100000],
easing: Easing.quad,
})?.length,
).toBe(65);
});

it('keeps on-screen error ~sub-pixel until the tolerance floor', () => {
// Below the floor (span up to ~2500) error stays sub-pixel as span grows.
for (const span of [1, 10, 100, 1000]) {
expect(maxOutputError(Easing.quad, span)).toBeLessThan(0.3);
}
// Past the floor the error grows only with the floor tolerance (1e-4): ~1px
// at span 10000, not the tens a fixed-resolution LUT would accumulate.
expect(maxOutputError(Easing.quad, 10000)).toBeLessThan(1.5);
});
});
Loading
Loading