From 7b2ebdc7d978ec96badb6a9b6f60ced2051190d5 Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Wed, 17 Jun 2026 08:52:33 -0700 Subject: [PATCH 1/3] (Redo D108193641) remove `useNativeDriver` under featureflag animatedForceNativeDriver" (#57250) Summary: ## Changelog: [General] [Added] - remove `useNativeDriver` under featureflag animatedForceNativeDriver When `animatedForceNativeDriver` is enabled, it forces `useNativeDriver` to `true` for all Animated animations and events, overriding the config (explicit `false` set by user will be no-op). Has no effect unless the shared animated backend is enabled, which is required to support native driver for all props. When calling `NativeAnimatedHelper.isNativeDriverForced`, do null check first for backward compatibility in rn-macos Also using this flag to gate the js animation logic that could be cleaned up when this path is fully working. Reviewed By: javache, bmsdave Differential Revision: D108880837 --- .../Animated/AnimatedImplementation.js | 19 +++++- .../Animated/NativeAnimatedAllowlist.js | 36 +++++++++++ .../__tests__/AnimatedBackend-itest.js | 59 +++++++++++++++++++ .../Animated/animations/Animation.js | 1 + .../Animated/animations/DecayAnimation.js | 1 + .../Animated/animations/SpringAnimation.js | 1 + .../Animated/animations/TimingAnimation.js | 1 + .../ReactNativeFeatureFlags.config.js | 11 ++++ .../private/animated/NativeAnimatedHelper.js | 25 +++++++- .../featureflags/ReactNativeFeatureFlags.js | 8 ++- 10 files changed, 155 insertions(+), 7 deletions(-) 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..c91017f23fc0 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, } : {}), }; 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/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/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. */ From 8e9bfecdcd2720477f1f9243147df07c9dc28fd8 Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Wed, 17 Jun 2026 08:52:33 -0700 Subject: [PATCH 2/3] Lint changed iOS podspecs in CI with pod lib lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: The RNTester integration build only proves that pods work *together* inside one workspace — it does not build every pod from source, and pods outside RNTester's dependency closure are never validated on their own. As a result, a podspec can ship with a defect that only shows up when the pod is built in isolation: a malformed compiler flag, or a header/dependency that the pod uses but never declares (which surfaces as a `'yoga/Yoga.h' file not found` style build error for open-source consumers). This adds a pull-request job that runs `pod lib lint` on the `*.podspec` files a change actually touches. Each pod is linted in isolation, so a dependency that is used-but-not-declared fails to resolve and the job fails. Because React Native's pods are not published to a spec repo, the script exposes every local podspec via `--include-podspecs`, letting the linter resolve inter-pod dependencies from the checkout. The job is scoped to changed podspecs via `dorny/paths-filter`, so PRs that touch no podspec do no extra work. Changelog: [Internal] - Add CI linting for changed iOS podspecs Differential Revision: D108889958 --- .github/actions/lint-ios-podspecs/action.yml | 24 ++++++ .github/workflows/test-all.yml | 25 ++++++ scripts/lint-changed-podspecs.sh | 82 ++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 .github/actions/lint-ios-podspecs/action.yml create mode 100755 scripts/lint-changed-podspecs.sh 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/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" From d8166de1fb833d1fd3e02ca5a63f4b285f1e8718 Mon Sep 17 00:00:00 2001 From: Zeya Peng Date: Wed, 17 Jun 2026 08:52:33 -0700 Subject: [PATCH 3/3] support native interpolation easing (#57237) 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), }); // case 1: driven by Animated.timing Animated.timing(progress, { toValue: 1, duration: 1500, useNativeDriver: true, }).start(); // case 2: driven by native event // 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. This is sometimes exchangeable with the native driven easing on Animated.timing. But one unique use case is when you need to interpolate native event driven animated value (e.g. scroll offset from scroll event) differently to derive multiple animation values. I find it impossible to replace with another existing Animated API for this use case. 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_;