diff --git a/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js b/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js index c91017f23fc0..2c1fab7ac13a 100644 --- a/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js +++ b/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js @@ -142,6 +142,7 @@ const SUPPORTED_INTERPOLATION_PARAMS: {[string]: true} = { extrapolate: true, extrapolateRight: true, extrapolateLeft: true, + easing: true, }; /** diff --git a/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js b/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js index ad9a1d8c4eca..c8637f8bf84e 100644 --- a/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/Animated-itest.js @@ -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'; @@ -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(); + + function MyApp() { + const progress = useAnimatedValue(0); + _progress = progress; + const translateX = progress.interpolate({ + inputRange: [0, 1], + outputRange: [0, 100], + easing: Easing.quad, + }); + return ( + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + 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(); + + function MyApp() { + const progress = useAnimatedValue(0); + _progress = progress; + const translateX = progress.interpolate({ + inputRange: [0, 1], + outputRange: [0, 100], + easing: Easing.back(), + extrapolate: 'clamp', + }); + return ( + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + 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. // diff --git a/packages/react-native/Libraries/Animated/__tests__/Interpolation-test.js b/packages/react-native/Libraries/Animated/__tests__/Interpolation-test.js index fc0fb879264d..4c35bda4fcd2 100644 --- a/packages/react-native/Libraries/Animated/__tests__/Interpolation-test.js +++ b/packages/react-native/Libraries/Animated/__tests__/Interpolation-test.js @@ -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, + ): ?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); + }); +}); diff --git a/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js b/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js index 891d4393b340..a7f67183a474 100644 --- a/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js +++ b/packages/react-native/Libraries/Animated/nodes/AnimatedInterpolation.js @@ -349,6 +349,100 @@ function checkInfiniteRange< ); } +// Ramer–Douglas–Peucker simplification using vertical distance (the curve's +// independent axis is the input position `t`). Keeps the endpoints and any point +// whose removal would push the piecewise-linear approximation more than +// `epsilon` away from the sampled curve. Produces non-uniform stops — dense +// where the curve bends, sparse where it is near-linear. +function simplifyByVerticalDistance( + points: Array<[number, number]>, + epsilon: number, +): Array<[number, number]> { + if (points.length < 3) { + return points; + } + const [x0, y0] = points[0]; + const [x1, y1] = points[points.length - 1]; + const dx = x1 - x0; + let maxDistance = 0; + let maxIndex = -1; + for (let i = 1; i < points.length - 1; i++) { + const [x, y] = points[i]; + const chordY = dx === 0 ? y0 : y0 + ((y1 - y0) * (x - x0)) / dx; + const distance = Math.abs(y - chordY); + if (distance > maxDistance) { + maxDistance = distance; + maxIndex = i; + } + } + if (maxDistance > epsilon) { + const left = simplifyByVerticalDistance( + points.slice(0, maxIndex + 1), + epsilon, + ); + const right = simplifyByVerticalDistance(points.slice(maxIndex), epsilon); + // Drop the duplicated shared point at the split. + return left.slice(0, -1).concat(right); + } + return [points[0], points[points.length - 1]]; +} + +// Samples an `easing` function and simplifies it (RDP) into a compact set of +// non-uniform `[position, value]` stops that the native interpolation node +// applies to each segment's normalized ratio (binary search + linear interp). +// This mirrors the CSS `linear()` easing representation. The tolerance targets a +// sub-pixel error using the interpolation's numeric output span when known. +function sampleEasingStops( + easing: (input: number) => number, + outputRange: ReadonlyArray, +): Array<[number, number]> { + // Dense sampling resolution of the easing curve before simplification. + const DENSE_SAMPLES = 256; + // Target approximation error, in output units (≈ sub-pixel for layout/ + // transform props). Used to derive the simplification tolerance from the span. + const TARGET_ERROR = 0.25; + // Bounds on the (ratio-space) simplification tolerance. + const MIN_TOLERANCE = 1e-4; + const MAX_TOLERANCE = 1e-2; + + // Evenly spaced [t, easing(t)] samples. + // e.g. quad samples: [[0, 0], [0.25, 0.0625], [0.5, 0.25], [0.75, 0.5625], [1, 1]]. + const dense: Array<[number, number]> = []; + for (let i = 0; i <= DENSE_SAMPLES; i++) { + const t = i / DENSE_SAMPLES; + dense.push([t, easing(t)]); + } + + let epsilon = MAX_TOLERANCE; + if (typeof outputRange[0] === 'number') { + let min = outputRange[0]; + let max = outputRange[0]; + for (const value of outputRange) { + if (typeof value === 'number') { + if (value < min) { + min = value; + } + if (value > max) { + max = value; + } + } + } + const span = max - min; + if (span > 0) { + epsilon = TARGET_ERROR / span; + } + } else { + // Non-numeric output (e.g. colors): components live in [0, 255]. + epsilon = TARGET_ERROR / 255; + } + epsilon = Math.min(MAX_TOLERANCE, Math.max(MIN_TOLERANCE, epsilon)); + + // Drops samples within `epsilon` of the chord, keeping a sparse subset. E.g. for + // epsilon in [0.0625, 0.25) the quad samples [[0, 0], [0.25, 0.0625], [0.5, 0.25], [0.75, 0.5625], [1, 1]] + // is trimmed to [[0, 0], [0.5, 0.25], [1, 1]]. + return simplifyByVerticalDistance(dense, epsilon); +} + export default class AnimatedInterpolation< OutputT extends InterpolationConfigSupportedOutputType, > extends AnimatedWithChildren { @@ -439,6 +533,17 @@ export default class AnimatedInterpolation< outputType = 'platform_color'; } + // An interpolation `easing` is a JS-only function. Rather than drop it (the + // native driver would run the segment linearly), sample + simplify it into a + // set of `[position, value]` stops the native node applies per segment. Works + // for every output type since easing acts on the normalized ratio, not the + // output values. + const easing = this._config.easing; + const easingStops = + easing != null && easing !== Easing.linear + ? sampleEasingStops(easing, this._config.outputRange) + : undefined; + return { inputRange: this._config.inputRange, outputRange, @@ -448,6 +553,7 @@ export default class AnimatedInterpolation< extrapolateRight: this._config.extrapolateRight || this._config.extrapolate || 'extend', type: 'interpolation', + easingStops, debugID: this.__getDebugID(), }; } diff --git a/packages/react-native/ReactCommon/react/renderer/animated/nodes/InterpolationAnimatedNode.cpp b/packages/react-native/ReactCommon/react/renderer/animated/nodes/InterpolationAnimatedNode.cpp index 4f3ca966b2b0..9d617a77f35a 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/nodes/InterpolationAnimatedNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/animated/nodes/InterpolationAnimatedNode.cpp @@ -12,11 +12,13 @@ #include "InterpolationAnimatedNode.h" #include +#include #include #include #include #include #include +#include namespace facebook::react { @@ -52,6 +54,47 @@ InterpolationAnimatedNode::InterpolationAnimatedNode( extrapolateLeft_ = nodeConfig["extrapolateLeft"].asString(); extrapolateRight_ = nodeConfig["extrapolateRight"].asString(); + + // Optional non-uniform easing stops baked from a JS interpolation `easing` + // function, as [position, value] pairs. Absent for interpolations without + // custom easing. + if (auto easingStopsIt = nodeConfig.find("easingStops"); + easingStopsIt != nodeConfig.items().end()) { + const auto& easingStops = easingStopsIt->second; + react_native_assert(easingStops.type() == folly::dynamic::ARRAY); + for (const auto& stop : easingStops) { + react_native_assert( + stop.type() == folly::dynamic::ARRAY && stop.size() == 2); + easingStopInputs_.push_back(stop[0].asDouble()); + easingStopOutputs_.push_back(stop[1].asDouble()); + } + } +} + +double InterpolationAnimatedNode::easeRatio(double ratio) const { + // No easing, or out-of-range ratio (extrapolation) — leave it untouched. + if (easingStopInputs_.size() < 2 || ratio < 0.0 || ratio > 1.0) { + return ratio; + } + // Binary search for the stop segment [lower, upper] bracketing `ratio`. + const auto it = std::upper_bound( + easingStopInputs_.begin(), easingStopInputs_.end(), ratio); + if (it == easingStopInputs_.begin()) { + return easingStopOutputs_.front(); + } + if (it == easingStopInputs_.end()) { + return easingStopOutputs_.back(); + } + const auto upper = static_cast(it - easingStopInputs_.begin()); + const auto lower = upper - 1; + const auto inputLo = easingStopInputs_[lower]; + const auto inputHi = easingStopInputs_[upper]; + if (inputHi == inputLo) { + return easingStopOutputs_[upper]; + } + const auto weight = (ratio - inputLo) / (inputHi - inputLo); + return easingStopOutputs_[lower] + + (easingStopOutputs_[upper] - easingStopOutputs_[lower]) * weight; } void InterpolationAnimatedNode::update() { @@ -91,12 +134,30 @@ double InterpolationAnimatedNode::interpolateValue(double value) { } index--; + const auto inputMin = inputRanges_[index]; + const auto inputMax = inputRanges_[index + 1]; + const auto outputMin = defaultOutputRanges_[index]; + const auto outputMax = defaultOutputRanges_[index + 1]; + + if (!easingStopInputs_.empty() && inputMin != inputMax) { + const auto ratio = (value - inputMin) / (inputMax - inputMin); + if (ratio >= 0.0 && ratio <= 1.0) { + // In-range: map the eased ratio straight to the output. The easing may + // overshoot [0, 1] (e.g. Easing.back/elastic); that overshoot must be + // preserved, not clamped — it comes from the easing, not from an + // out-of-range input. This matches JS, where easing runs after + // extrapolation handling. Out-of-range inputs fall through to linear + // extrapolation below (the stops only cover [0, 1]). + return outputMin + easeRatio(ratio) * (outputMax - outputMin); + } + } + return interpolate( value, - inputRanges_[index], - inputRanges_[index + 1], - defaultOutputRanges_[index], - defaultOutputRanges_[index + 1], + inputMin, + inputMax, + outputMin, + outputMax, extrapolateLeft_, extrapolateRight_); } @@ -127,7 +188,7 @@ double InterpolationAnimatedNode::interpolateColor(double value) { } } - auto ratio = (value - inputMin) / (inputMax - inputMin); + auto ratio = easeRatio((value - inputMin) / (inputMax - inputMin)); auto outputMinA = alphaFromHostPlatformColor(outputMin); auto outputMinR = redFromHostPlatformColor(outputMin); @@ -193,7 +254,7 @@ double InterpolationAnimatedNode::interpolatePlatformColor(double value) { } } - auto ratio = (value - inputMin) / (inputMax - inputMin); + auto ratio = easeRatio((value - inputMin) / (inputMax - inputMin)); auto outputMinA = alphaFromHostPlatformColor(outputMin); auto outputMinR = redFromHostPlatformColor(outputMin); diff --git a/packages/react-native/ReactCommon/react/renderer/animated/nodes/InterpolationAnimatedNode.h b/packages/react-native/ReactCommon/react/renderer/animated/nodes/InterpolationAnimatedNode.h index 607857da6516..a6b29a6fd745 100644 --- a/packages/react-native/ReactCommon/react/renderer/animated/nodes/InterpolationAnimatedNode.h +++ b/packages/react-native/ReactCommon/react/renderer/animated/nodes/InterpolationAnimatedNode.h @@ -31,12 +31,24 @@ class InterpolationAnimatedNode final : public ValueAnimatedNode { double interpolateColor(double value); double interpolatePlatformColor(double value); + // Applies the optional easing stops to a segment's normalized ratio via binary + // search + linear interpolation. Returns the ratio unchanged when no easing is + // configured or when the ratio falls outside [0, 1] (so extrapolation behavior + // is preserved). + double easeRatio(double ratio) const; + SurfaceId resolveConnectedRootTag() const; std::vector inputRanges_; std::vector defaultOutputRanges_; std::vector colorOutputRanges_; std::vector platformColorOutputRanges_; + // Non-uniform easing stops (RDP-simplified, CSS `linear()`-style) baked from a + // JS interpolation `easing` function. `easingStopInputs_` are the stop + // positions in [0, 1] (sorted), `easingStopOutputs_` the eased values. Both + // empty when the interpolation has no custom easing. + std::vector easingStopInputs_; + std::vector easingStopOutputs_; std::string extrapolateLeft_; std::string extrapolateRight_;