Skip to content

Commit 1a0f22b

Browse files
zeyapmeta-codesync[bot]
authored andcommitted
support native interpolation easing
Summary: ## Changelog: [General] [Added] - support native driven AnimatedValue interpolation easing Interpolation with easing on AnimatedValue was not supported with native driver, example like below simply doesn't work, because easing function is called on JS thread. This is not solved with shared backend. ``` const progress = useAnimatedValue(0); const easedX = progress.interpolate({ inputRange: [0, 1], outputRange: [0, DISTANCE], easing: Easing.inOut(Easing.cubic), }); Animated.timing(progress, { toValue: 1, duration: 1500, useNativeDriver: true, }).start(); // JS error: Interpolation property 'easing' is not supported by native animated module ``` ## How it works The JS `easing` function is sampled and baked into the native interpolation config as a compact set of non-uniform `[position, value]` stops — the same representation as CSS `linear()`: - JS (`AnimatedInterpolation`): densely samples the easing curve, then simplifies it with Ramer–Douglas–Peucker into non-uniform stops. The simplification tolerance is derived from the interpolation's output span so the on-screen error stays ~sub-pixel — flat curves collapse to a few stops, curvy ones keep more (bounded by the dense-sample budget). `easingStops` is emitted only when an `easing` is set (and is not the linear identity). - Native (`InterpolationAnimatedNode`): applies the stops to each segment's normalized ratio via binary search + linear interpolation. `easing` is now an accepted interpolation param, so the "not supported" error is gone. Overshoot is preserved: easings that leave `[0, 1]` (e.g. `Easing.back`, `Easing.elastic`) keep their excursion even under `extrapolate: 'clamp'`, matching the JS driver — `clamp`/`identity` only apply to out-of-range input, not to the easing's own excursion. Works for all output types since easing acts on the normalized ratio, not the output values. ## Known limitation Color/platform_color interpolation under an overshoot easing can push channel values outside `[0, 255]`, which currently wrap on the native `uint8_t` cast (JS instead emits out-of-gamut). Not addressed here. Differential Revision: D108760799
1 parent cffe14f commit 1a0f22b

6 files changed

Lines changed: 485 additions & 7 deletions

File tree

packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ const SUPPORTED_INTERPOLATION_PARAMS: {[string]: true} = {
142142
extrapolate: true,
143143
extrapolateRight: true,
144144
extrapolateLeft: true,
145+
easing: true,
145146
};
146147

147148
/**

packages/react-native/Libraries/Animated/__tests__/Animated-itest.js

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import ensureInstance from '../../../src/private/__tests__/utilities/ensureInsta
1717
import * as ReactNativeFeatureFlags from '../../../src/private/featureflags/ReactNativeFeatureFlags';
1818
import * as Fantom from '@react-native/fantom';
1919
import {createRef} from 'react';
20-
import {Animated, View, useAnimatedValue} from 'react-native';
20+
import {Animated, Easing, View, useAnimatedValue} from 'react-native';
2121
import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist';
2222
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';
2323

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

90+
// A native-driven interpolation with a custom `easing` should follow the easing
91+
// curve, not run linearly. The driver animates linearly 0 -> 1; the eased
92+
// interpolation maps it to translateX 0 -> 100 with Easing.quad (t^2). At the
93+
// midpoint (driver = 0.5) the eased value is 0.5^2 * 100 = 25 (a linear mapping
94+
// would be 50). The easing is baked into the native interpolation config as an
95+
// `easingStops` lookup table, so the native driver reproduces the curve.
96+
test('native-driven interpolation honors custom easing', () => {
97+
let _progress;
98+
const viewRef = createRef<HostInstance>();
99+
100+
function MyApp() {
101+
const progress = useAnimatedValue(0);
102+
_progress = progress;
103+
const translateX = progress.interpolate({
104+
inputRange: [0, 1],
105+
outputRange: [0, 100],
106+
easing: Easing.quad,
107+
});
108+
return (
109+
<Animated.View
110+
ref={viewRef}
111+
style={[{width: 100, height: 100}, {transform: [{translateX}]}]}
112+
/>
113+
);
114+
}
115+
116+
const root = Fantom.createRoot();
117+
118+
Fantom.runTask(() => {
119+
root.render(<MyApp />);
120+
});
121+
122+
const viewElement = ensureInstance(viewRef.current, ReactNativeElement);
123+
124+
Fantom.runTask(() => {
125+
Animated.timing(_progress, {
126+
toValue: 1,
127+
duration: 1000, // 1 second
128+
easing: Easing.linear,
129+
useNativeDriver: true,
130+
}).start();
131+
});
132+
133+
Fantom.unstable_produceFramesForDuration(500 + DEFERRED_START_MS);
134+
135+
const transform =
136+
// $FlowFixMe[incompatible-use]
137+
Fantom.unstable_getDirectManipulationProps(viewElement).transform[0];
138+
139+
// Driver is 50% through (linear timing), but the interpolation's quad easing
140+
// reshapes it: 0.5^2 * 100 = 25, not the linear 50.
141+
expect(transform.translateX).toBeCloseTo(25, 0.001);
142+
143+
Fantom.unstable_produceFramesForDuration(500);
144+
145+
// Animation complete; final committed position is the full 100.
146+
Fantom.runWorkLoop();
147+
expect(viewElement.getBoundingClientRect().x).toBe(100);
148+
});
149+
150+
// When the easing leaves [0, 1] (Easing.back dips below 0 early), that excursion
151+
// must be preserved even under `extrapolate: 'clamp'`. The driver runs 0 -> 1, so
152+
// the input is always in range — `clamp` should only affect out-of-range *input*,
153+
// never the easing's own excursion. Pre-fix the native driver clamped it away
154+
// (translateX pinned to 0); JS keeps it negative. This guards that parity.
155+
test('native-driven interpolation preserves easing overshoot under clamp', () => {
156+
let _progress;
157+
const viewRef = createRef<HostInstance>();
158+
159+
function MyApp() {
160+
const progress = useAnimatedValue(0);
161+
_progress = progress;
162+
const translateX = progress.interpolate({
163+
inputRange: [0, 1],
164+
outputRange: [0, 100],
165+
easing: Easing.back(),
166+
extrapolate: 'clamp',
167+
});
168+
return (
169+
<Animated.View
170+
ref={viewRef}
171+
style={[{width: 100, height: 100}, {transform: [{translateX}]}]}
172+
/>
173+
);
174+
}
175+
176+
const root = Fantom.createRoot();
177+
178+
Fantom.runTask(() => {
179+
root.render(<MyApp />);
180+
});
181+
182+
const viewElement = ensureInstance(viewRef.current, ReactNativeElement);
183+
184+
Fantom.runTask(() => {
185+
Animated.timing(_progress, {
186+
toValue: 1,
187+
duration: 1000,
188+
easing: Easing.linear,
189+
useNativeDriver: true,
190+
}).start();
191+
});
192+
193+
// ~20% through: Easing.back(0.2) ≈ -0.046 -> translateX ≈ -4.6, i.e. negative.
194+
// If the excursion were clamped (the bug), translateX would stay at 0.
195+
Fantom.unstable_produceFramesForDuration(200 + DEFERRED_START_MS);
196+
const transform =
197+
// $FlowFixMe[incompatible-use]
198+
Fantom.unstable_getDirectManipulationProps(viewElement).transform[0];
199+
expect(transform.translateX).toBeLessThan(0);
200+
201+
// Completes at the in-range endpoint (Easing.back(1) === 1 -> 100).
202+
Fantom.unstable_produceFramesForDuration(800);
203+
Fantom.runWorkLoop();
204+
expect(viewElement.getBoundingClientRect().x).toBe(100);
205+
});
206+
90207
// Validate that a `useNativeDriver` timing animation does not begin progressing
91208
// until the end of the event loop tick it was started in.
92209
//

packages/react-native/Libraries/Animated/__tests__/Interpolation-test.js

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,3 +406,184 @@ describe('Interpolation', () => {
406406
},
407407
);
408408
});
409+
410+
describe('Interpolation easingStops (native easing baking)', () => {
411+
// Returns the non-uniform [position, value] easing stops emitted in the native
412+
// config for an eased numeric interpolation (or undefined when no easing).
413+
function getEasingStops(
414+
config: InterpolationConfigType<number>,
415+
): ?Array<[number, number]> {
416+
return new AnimatedInterpolation(
417+
// $FlowFixMe[incompatible-type]
418+
{},
419+
config,
420+
).__getNativeConfig().easingStops;
421+
}
422+
423+
// Mirrors the native easeRatio(): binary-search the bracketing stops and
424+
// linearly interpolate. Out-of-[0,1] ratios pass through (extrapolation).
425+
function reconstructEaseRatio(
426+
stops: Array<[number, number]>,
427+
): (ratio: number) => number {
428+
return ratio => {
429+
if (stops.length < 2 || ratio < 0 || ratio > 1) {
430+
return ratio;
431+
}
432+
const upper = stops.findIndex(stop => stop[0] > ratio);
433+
if (upper === -1) {
434+
return stops[stops.length - 1][1];
435+
}
436+
if (upper === 0) {
437+
return stops[0][1];
438+
}
439+
const [xLo, yLo] = stops[upper - 1];
440+
const [xHi, yHi] = stops[upper];
441+
if (xHi === xLo) {
442+
return yHi;
443+
}
444+
return yLo + (yHi - yLo) * ((ratio - xLo) / (xHi - xLo));
445+
};
446+
}
447+
448+
// Max error, in OUTPUT units, between the baked stops and the true easing
449+
// curve across the [0, 1] domain (sampled finely). This is what actually
450+
// shows up on screen, e.g. pixels for a translate.
451+
function maxOutputError(
452+
easing: (input: number) => number,
453+
span: number,
454+
): number {
455+
const stops = getEasingStops({
456+
inputRange: [0, 1],
457+
outputRange: [0, span],
458+
easing,
459+
});
460+
if (stops == null) {
461+
throw new Error('expected easingStops to be emitted');
462+
}
463+
const approx = reconstructEaseRatio(stops);
464+
let maxErr = 0;
465+
for (let i = 0; i <= 1000; i++) {
466+
const t = i / 1000;
467+
const err = Math.abs(approx(t) - easing(t)) * span;
468+
if (err > maxErr) {
469+
maxErr = err;
470+
}
471+
}
472+
return maxErr;
473+
}
474+
475+
// Computed once; reused for both the exact-output and the stop-count assertions.
476+
const customLinear = getEasingStops({
477+
inputRange: [0, 1],
478+
outputRange: [0, 100],
479+
easing: (t: number) => t,
480+
});
481+
const quad = getEasingStops({
482+
inputRange: [0, 1],
483+
outputRange: [0, 100],
484+
easing: Easing.quad,
485+
});
486+
const bounce = getEasingStops({
487+
inputRange: [0, 1],
488+
outputRange: [0, 100],
489+
easing: Easing.bounce,
490+
});
491+
const sine = getEasingStops({
492+
inputRange: [0, 1],
493+
outputRange: [0, 10],
494+
easing: Easing.inOut(Easing.sin),
495+
});
496+
497+
it('omits easingStops when easing is linear by identity or absent', () => {
498+
const base = {inputRange: [0, 1], outputRange: [0, 100]};
499+
expect(getEasingStops(base)).toBe(undefined);
500+
expect(getEasingStops({...base, easing: Easing.linear})).toBe(undefined);
501+
});
502+
503+
it('bakes the curve into exact stops whose count adapts to curvature', () => {
504+
// A custom linear fn (not the Easing.linear reference, so not short-circuited)
505+
// collapses to the two endpoints: every interior sample lies on the chord.
506+
expect(customLinear).toEqual([
507+
[0, 0],
508+
[1, 1],
509+
]);
510+
511+
// Constant-curvature quad -> RDP's midpoint splitting yields uniform 1/16
512+
// spacing; each value is the eased ratio (k/16)^2, independent of span.
513+
expect(quad).toEqual([
514+
[0, 0],
515+
[0.0625, 0.00390625],
516+
[0.125, 0.015625],
517+
[0.1875, 0.03515625],
518+
[0.25, 0.0625],
519+
[0.3125, 0.09765625],
520+
[0.375, 0.140625],
521+
[0.4375, 0.19140625],
522+
[0.5, 0.25],
523+
[0.5625, 0.31640625],
524+
[0.625, 0.390625],
525+
[0.6875, 0.47265625],
526+
[0.75, 0.5625],
527+
[0.8125, 0.66015625],
528+
[0.875, 0.765625],
529+
[0.9375, 0.87890625],
530+
[1, 1],
531+
]);
532+
533+
// A sine S-curve is placed non-uniformly: stops cluster at the two bends and
534+
// leave a large gap across the near-linear middle (0.35 -> 0.62).
535+
expect(sine).toEqual([
536+
[0, 0],
537+
[0.10546875, 0.02719633730973936],
538+
[0.21875, 0.1134947733186315],
539+
[0.34765625, 0.26973064452088],
540+
[0.62109375, 0.6856585969759188],
541+
[0.7421875, 0.8447702723685335],
542+
[0.875, 0.9619397662556434],
543+
[1, 1],
544+
]);
545+
546+
// Stop count rises with curvature — 2 (flat) -> 17 (quad) -> 45 (bounce) —
547+
// and is always bounded by the dense-sample budget (256 + 1 = 257).
548+
expect(bounce?.length).toBe(45);
549+
expect(bounce?.length).toBeLessThanOrEqual(257);
550+
});
551+
552+
it('grows the stop count with output span, capped by the tolerance floor', () => {
553+
// Bigger span -> smaller tolerance -> more stops for the same curve...
554+
expect(
555+
getEasingStops({
556+
inputRange: [0, 1],
557+
outputRange: [0, 10],
558+
easing: Easing.quad,
559+
})?.length,
560+
).toBe(9);
561+
expect(quad?.length).toBe(17); // span 100, computed once above
562+
expect(
563+
getEasingStops({
564+
inputRange: [0, 1],
565+
outputRange: [0, 1000],
566+
easing: Easing.quad,
567+
})?.length,
568+
).toBe(33);
569+
// ...until epsilon hits its floor: a smooth curve caps out (65) rather than
570+
// densifying toward the dense-sample budget.
571+
expect(
572+
getEasingStops({
573+
inputRange: [0, 1],
574+
outputRange: [0, 100000],
575+
easing: Easing.quad,
576+
})?.length,
577+
).toBe(65);
578+
});
579+
580+
it('keeps on-screen error ~sub-pixel until the tolerance floor', () => {
581+
// Below the floor (span up to ~2500) error stays sub-pixel as span grows.
582+
for (const span of [1, 10, 100, 1000]) {
583+
expect(maxOutputError(Easing.quad, span)).toBeLessThan(0.3);
584+
}
585+
// Past the floor the error grows only with the floor tolerance (1e-4): ~1px
586+
// at span 10000, not the tens a fixed-resolution LUT would accumulate.
587+
expect(maxOutputError(Easing.quad, 10000)).toBeLessThan(1.5);
588+
});
589+
});

0 commit comments

Comments
 (0)