From 1a0f22b7d2ca3ed4f4da792cebd7ed714a4e590f Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Tue, 16 Jun 2026 13:20:03 -0700 Subject: [PATCH] support native interpolation easing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Animated/NativeAnimatedAllowlist.js | 1 + .../Animated/__tests__/Animated-itest.js | 119 +++++++++++- .../Animated/__tests__/Interpolation-test.js | 181 ++++++++++++++++++ .../Animated/nodes/AnimatedInterpolation.js | 106 ++++++++++ .../nodes/InterpolationAnimatedNode.cpp | 73 ++++++- .../nodes/InterpolationAnimatedNode.h | 12 ++ 6 files changed, 485 insertions(+), 7 deletions(-) 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_;