diff --git a/.github/actions/lint-ios-podspecs/action.yml b/.github/actions/lint-ios-podspecs/action.yml new file mode 100644 index 000000000000..b2d119d6587f --- /dev/null +++ b/.github/actions/lint-ios-podspecs/action.yml @@ -0,0 +1,24 @@ +name: lint-ios-podspecs +description: Lint the CocoaPods podspecs changed by a pull request with `pod lib lint` +inputs: + ruby-version: + description: The version of ruby that must be used + default: 2.6.10 + podspecs: + description: Space-separated list of changed *.podspec paths to lint + required: true +runs: + using: composite + steps: + - name: Setup xcode + uses: ./.github/actions/setup-xcode + - name: Setup ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ inputs.ruby-version }} + - name: Install gems + shell: bash + run: bundle install + - name: Lint changed podspecs + shell: bash + run: ./scripts/lint-changed-podspecs.sh ${{ inputs.podspecs }} diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 2815287602c6..65d10d975b40 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -89,6 +89,31 @@ jobs: - 'packages/debugger-shell/**' - 'scripts/debugger-shell/**' + lint_ios_podspecs: + runs-on: macos-15 + timeout-minutes: 60 + needs: check_code_changes + if: | + github.repository == 'react/react-native' && + needs.check_code_changes.outputs.any_code_change == 'true' && + needs.check_code_changes.outputs.should_test_ios == 'true' + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Detect changed podspecs + uses: dorny/paths-filter@209e61402dbca8aa44f967535da6666b284025ed + id: changed_podspecs + with: + list-files: shell + filters: | + podspecs: + - 'packages/react-native/**/*.podspec' + - name: Lint changed podspecs + if: ${{ steps.changed_podspecs.outputs.podspecs == 'true' }} + uses: ./.github/actions/lint-ios-podspecs + with: + podspecs: ${{ steps.changed_podspecs.outputs.podspecs_files }} + prebuild_apple_dependencies: needs: check_code_changes if: | diff --git a/packages/react-native/Libraries/Animated/AnimatedImplementation.js b/packages/react-native/Libraries/Animated/AnimatedImplementation.js index 46a08d2e8954..15f1230a47f7 100644 --- a/packages/react-native/Libraries/Animated/AnimatedImplementation.js +++ b/packages/react-native/Libraries/Animated/AnimatedImplementation.js @@ -20,6 +20,7 @@ import type {DecayAnimationConfig} from './animations/DecayAnimation'; import type {SpringAnimationConfig} from './animations/SpringAnimation'; import type {TimingAnimationConfig} from './animations/TimingAnimation'; +import NativeAnimatedHelper from '../../src/private/animated/NativeAnimatedHelper'; import {AnimatedEvent, attachNativeEventImpl} from './AnimatedEvent'; import DecayAnimation from './animations/DecayAnimation'; import SpringAnimation from './animations/SpringAnimation'; @@ -200,7 +201,11 @@ const springImpl = function ( }, _isUsingNativeDriver: function (): boolean { - return config.useNativeDriver || false; + return ( + NativeAnimatedHelper.isNativeDriverForced?.() || + config.useNativeDriver || + false + ); }, } ); @@ -254,7 +259,11 @@ const timingImpl = function ( }, _isUsingNativeDriver: function (): boolean { - return config.useNativeDriver || false; + return ( + NativeAnimatedHelper.isNativeDriverForced?.() || + config.useNativeDriver || + false + ); }, } ); @@ -296,7 +305,11 @@ const decayImpl = function ( }, _isUsingNativeDriver: function (): boolean { - return config.useNativeDriver || false; + return ( + NativeAnimatedHelper.isNativeDriverForced?.() || + config.useNativeDriver || + false + ); }, } ); diff --git a/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js b/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js index c5cecfc828c6..2c1fab7ac13a 100644 --- a/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js +++ b/packages/react-native/Libraries/Animated/NativeAnimatedAllowlist.js @@ -78,6 +78,42 @@ const SUPPORTED_STYLES: {[string]: true} = { top: true, /* flex */ flex: true, + flexGrow: true, + flexShrink: true, + flexBasis: true, + aspectRatio: true, + /* margin */ + margin: true, + marginLeft: true, + marginRight: true, + marginTop: true, + marginBottom: true, + marginStart: true, + marginEnd: true, + marginHorizontal: true, + marginVertical: true, + /* padding */ + padding: true, + paddingLeft: true, + paddingRight: true, + paddingTop: true, + paddingBottom: true, + paddingStart: true, + paddingEnd: true, + paddingHorizontal: true, + paddingVertical: true, + /* border width */ + borderWidth: true, + borderLeftWidth: true, + borderRightWidth: true, + borderTopWidth: true, + borderBottomWidth: true, + borderStartWidth: true, + borderEndWidth: true, + /* gap */ + gap: true, + rowGap: true, + columnGap: true, } : {}), }; @@ -106,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__/AnimatedBackend-itest.js b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js index adeaf7e485cf..57b72ea67715 100644 --- a/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js +++ b/packages/react-native/Libraries/Animated/__tests__/AnimatedBackend-itest.js @@ -21,6 +21,65 @@ import {Animated, View, useAnimatedValue} from 'react-native'; import {allowStyleProp} from 'react-native/Libraries/Animated/NativeAnimatedAllowlist'; import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement'; +// marginLeft (and the other margin props) are only on the native animated +// allowlist when the shared backend is enabled. This test deliberately does NOT +// call allowStyleProp('marginLeft') — it verifies the prop is supported natively +// out of the box under useSharedAnimatedBackend. +test('animate marginLeft layout prop', () => { + const viewRef = createRef(); + + let _animatedMarginLeft; + let _marginLeftAnimation; + + function MyApp() { + const animatedMarginLeft = useAnimatedValue(0); + _animatedMarginLeft = animatedMarginLeft; + return ( + + ); + } + + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + Fantom.runTask(() => { + _marginLeftAnimation = Animated.timing(_animatedMarginLeft, { + toValue: 100, + duration: 200, + useNativeDriver: true, + }).start(); + }); + + Fantom.unstable_produceFramesForDuration(100); + + expect(root.getRenderedOutput({props: ['marginLeft']}).toJSX()).toEqual( + , + ); + + Fantom.unstable_produceFramesForDuration(100); + + // TODO: this shouldn't be necessary since animation should be stopped after duration + Fantom.runTask(() => { + _marginLeftAnimation?.stop(); + }); + + expect(root.getRenderedOutput({props: ['marginLeft']}).toJSX()).toEqual( + , + ); +}); + test('animated opacity', () => { let _opacity; let _opacityAnimation; 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/animations/Animation.js b/packages/react-native/Libraries/Animated/animations/Animation.js index 7322ec03c6b3..83e1a715379a 100644 --- a/packages/react-native/Libraries/Animated/animations/Animation.js +++ b/packages/react-native/Libraries/Animated/animations/Animation.js @@ -70,6 +70,7 @@ export default class Animation { previousAnimation: ?Animation, animatedValue: AnimatedValue, ): void { + // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out if (!this._useNativeDriver && animatedValue.__isNative === true) { throw new Error( 'Attempting to run JS driven animation on animated node ' + diff --git a/packages/react-native/Libraries/Animated/animations/DecayAnimation.js b/packages/react-native/Libraries/Animated/animations/DecayAnimation.js index 35eb106f5a2b..d6b834b032ee 100644 --- a/packages/react-native/Libraries/Animated/animations/DecayAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/DecayAnimation.js @@ -85,6 +85,7 @@ export default class DecayAnimation extends Animation { this._startTime = Date.now(); const useNativeDriver = this.__startAnimationIfNative(animatedValue); + // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out if (!useNativeDriver) { this._animationFrame = requestAnimationFrame(() => this.onUpdate()); } diff --git a/packages/react-native/Libraries/Animated/animations/SpringAnimation.js b/packages/react-native/Libraries/Animated/animations/SpringAnimation.js index cb70e4454117..f04a527469b3 100644 --- a/packages/react-native/Libraries/Animated/animations/SpringAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/SpringAnimation.js @@ -225,6 +225,7 @@ export default class SpringAnimation extends Animation { const start = () => { const useNativeDriver = this.__startAnimationIfNative(animatedValue); + // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out if (!useNativeDriver) { this.onUpdate(); } diff --git a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js index c464334cc376..dffb737a9882 100644 --- a/packages/react-native/Libraries/Animated/animations/TimingAnimation.js +++ b/packages/react-native/Libraries/Animated/animations/TimingAnimation.js @@ -129,6 +129,7 @@ export default class TimingAnimation extends Animation { this._startTime = Date.now(); const useNativeDriver = this.__startAnimationIfNative(animatedValue); + // TODO: T274006331 - Remove js-only animation once shared backend is fully rolled out if (!useNativeDriver) { // Animations that sometimes have 0 duration and sometimes do not // still need to use the native driver when duration is 0 so as to 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_; diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 6233fd7a5211..a1f4834ec10e 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -959,6 +959,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + animatedForceNativeDriver: { + defaultValue: false, + metadata: { + dateAdded: '2026-06-10', + description: + 'When enabled, forces `useNativeDriver` to `true` for all Animated animations and events, overriding the config (including an explicit `false`). Has no effect unless the shared animated backend is enabled, which is required to support native driver for all props.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, animatedShouldDebounceQueueFlush: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/animated/NativeAnimatedHelper.js b/packages/react-native/src/private/animated/NativeAnimatedHelper.js index 9afd64374e5c..74a76280692a 100644 --- a/packages/react-native/src/private/animated/NativeAnimatedHelper.js +++ b/packages/react-native/src/private/animated/NativeAnimatedHelper.js @@ -417,17 +417,35 @@ function assertNativeAnimatedModule(): void { let _warnedMissingNativeAnimated = false; +// Whether the native driver should be forced on for every animation, overriding +// the config (including an explicit `useNativeDriver: false`). This is only safe +// when the shared animated backend is enabled — that backend is what makes every +// prop drivable natively. Forcing native without it would break animations of +// props the legacy native driver doesn't support. +function isNativeDriverForced(): boolean { + return ( + ReactNativeFeatureFlags.animatedForceNativeDriver() && + ReactNativeFeatureFlags.cxxNativeAnimatedEnabled() && + // eslint-disable-next-line + ReactNativeFeatureFlags.useSharedAnimatedBackend() + ); +} + function shouldUseNativeDriver( config: Readonly<{...AnimationConfig, ...}> | EventConfig, ): boolean { - if (config.useNativeDriver == null) { + const forceNativeDriver = isNativeDriverForced(); + + if (config.useNativeDriver == null && !forceNativeDriver) { console.warn( 'Animated: `useNativeDriver` was not specified. This is a required ' + 'option and must be explicitly set to `true` or `false`', ); } - if (config.useNativeDriver === true && !NativeAnimatedModule) { + const useNativeDriver = forceNativeDriver || config.useNativeDriver === true; + + if (useNativeDriver === true && !NativeAnimatedModule) { if (process.env.NODE_ENV !== 'test') { if (!_warnedMissingNativeAnimated) { console.warn( @@ -443,7 +461,7 @@ function shouldUseNativeDriver( return false; } - return config.useNativeDriver || false; + return useNativeDriver; } function transformDataType(value: number | string): number | string { @@ -469,6 +487,7 @@ export default { assertNativeAnimatedModule, generateNewAnimationId, generateNewNodeTag, + isNativeDriverForced, // $FlowExpectedError[unsafe-getters-setters] - unsafe getter lint suppression // $FlowExpectedError[missing-type-arg] - unsafe getter lint suppression get nativeEventEmitter(): NativeEventEmitter { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index fea6a341e9af..71e0b730c834 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<9665e3af57529f1b50d02c79e7a869eb>> + * @generated SignedSource<<68e9dbd18bfcb5e7d5cad27d8663ce66>> * @flow strict * @noformat */ @@ -30,6 +30,7 @@ import { export type ReactNativeFeatureFlagsJsOnly = Readonly<{ jsOnlyTestFlag: Getter, animatedDeferStartOfTimingAnimations: Getter, + animatedForceNativeDriver: Getter, animatedShouldDebounceQueueFlush: Getter, animatedShouldSyncValueBeforeStartCallback: Getter, animatedShouldUseSingleOp: Getter, @@ -145,6 +146,11 @@ export const jsOnlyTestFlag: Getter = createJavaScriptFlagGetter('jsOnl */ export const animatedDeferStartOfTimingAnimations: Getter = createJavaScriptFlagGetter('animatedDeferStartOfTimingAnimations', false); +/** + * When enabled, forces `useNativeDriver` to `true` for all Animated animations and events, overriding the config (including an explicit `false`). Has no effect unless the shared animated backend is enabled, which is required to support native driver for all props. + */ +export const animatedForceNativeDriver: Getter = createJavaScriptFlagGetter('animatedForceNativeDriver', false); + /** * Enables an experimental flush-queue debouncing in Animated.js. */ diff --git a/scripts/lint-changed-podspecs.sh b/scripts/lint-changed-podspecs.sh new file mode 100755 index 000000000000..52be18cf47a8 --- /dev/null +++ b/scripts/lint-changed-podspecs.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +# Lints the podspecs passed as arguments — intended for the *.podspec files that +# a pull request changes. Each pod is linted in isolation with `pod lib lint`, +# which builds it from source using ONLY its declared dependencies. That catches +# podspec defects the RNTester integration build misses, e.g. a malformed +# compiler flag, or a header/dependency that the pod uses but does not declare +# (which surfaces as a "'yoga/Yoga.h' file not found" style build error). +# +# React Native's pods are not published to a CocoaPods spec repo, so we expose +# every local podspec via `--include-podspecs`. That lets the linter resolve +# inter-pod dependencies (React-Core, Yoga, React-Fabric, the third-party deps, +# …) from this checkout instead of from trunk. A dependency that is *used but +# not declared* still fails to resolve its headers, which is exactly the class +# of breakage this check exists to prevent. + +set -eo pipefail + +SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(dirname "$SCRIPTS")" + +if [ "$#" -eq 0 ]; then + echo "No changed podspecs to lint." + exit 0 +fi + +cd "$ROOT" + +# Make every local podspec resolvable as a dependency source during lint. The +# `**` glob also covers the root React.podspec and third-party-podspecs/. Loaded +# this way each spec resolves by path (like a `:path` install), so we must NOT +# set the INSTALL_YOGA_* env vars that process-podspecs.sh uses only for `push`. +INCLUDE_PODSPECS='packages/react-native/**/*.podspec' + +# Lint options follow scripts/process-podspecs.sh; dependency resolution differs +# (we rely on --include-podspecs rather than a published spec repo). +LINT_OPT=( + --verbose + --allow-warnings + --fail-fast + --private + --swift-version=3.0 + --include-podspecs="$INCLUDE_PODSPECS" + --sources=https://github.com/CocoaPods/Specs.git +) + +echo "Linting $# changed podspec(s): $*" + +status=0 +for podspec in "$@"; do + if [ ! -f "$podspec" ]; then + # The podspec was deleted/renamed in this change; nothing to lint. + echo "Skipping '$podspec' (no longer present)." + continue + fi + + case "$podspec" in + */__fixtures__/*) + # Codegen test fixtures are not real pods; do not attempt to lint them. + echo "Skipping '$podspec' (codegen test fixture)." + continue + ;; + esac + + echo "::group::pod lib lint $podspec" + # Lint both the default (frameworks) and the static-library builds, mirroring + # process-podspecs.sh, since linkage-specific issues can hide in either. + for extra in "" "--use-libraries"; do + # shellcheck disable=SC2086 + if ! bundle exec pod lib lint "$podspec" "${LINT_OPT[@]}" $extra; then + echo "::error file=$podspec::pod lib lint failed for $podspec ${extra:-(frameworks)}" + status=1 + fi + done + echo "::endgroup::" +done + +exit "$status"