Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/actions/lint-ios-podspecs/action.yml
Original file line number Diff line number Diff line change
@@ -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 }}
25 changes: 25 additions & 0 deletions .github/workflows/test-all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
19 changes: 16 additions & 3 deletions packages/react-native/Libraries/Animated/AnimatedImplementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -200,7 +201,11 @@ const springImpl = function (
},

_isUsingNativeDriver: function (): boolean {
return config.useNativeDriver || false;
return (
NativeAnimatedHelper.isNativeDriverForced?.() ||
config.useNativeDriver ||
false
);
},
}
);
Expand Down Expand Up @@ -254,7 +259,11 @@ const timingImpl = function (
},

_isUsingNativeDriver: function (): boolean {
return config.useNativeDriver || false;
return (
NativeAnimatedHelper.isNativeDriverForced?.() ||
config.useNativeDriver ||
false
);
},
}
);
Expand Down Expand Up @@ -296,7 +305,11 @@ const decayImpl = function (
},

_isUsingNativeDriver: function (): boolean {
return config.useNativeDriver || false;
return (
NativeAnimatedHelper.isNativeDriverForced?.() ||
config.useNativeDriver ||
false
);
},
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
: {}),
};
Expand Down Expand Up @@ -106,6 +142,7 @@ const SUPPORTED_INTERPOLATION_PARAMS: {[string]: true} = {
extrapolate: true,
extrapolateRight: true,
extrapolateLeft: true,
easing: true,
};

/**
Expand Down
119 changes: 118 additions & 1 deletion packages/react-native/Libraries/Animated/__tests__/Animated-itest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<HostInstance>();

function MyApp() {
const progress = useAnimatedValue(0);
_progress = progress;
const translateX = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
easing: Easing.quad,
});
return (
<Animated.View
ref={viewRef}
style={[{width: 100, height: 100}, {transform: [{translateX}]}]}
/>
);
}

const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(<MyApp />);
});

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<HostInstance>();

function MyApp() {
const progress = useAnimatedValue(0);
_progress = progress;
const translateX = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, 100],
easing: Easing.back(),
extrapolate: 'clamp',
});
return (
<Animated.View
ref={viewRef}
style={[{width: 100, height: 100}, {transform: [{translateX}]}]}
/>
);
}

const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(<MyApp />);
});

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.
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HostInstance>();

let _animatedMarginLeft;
let _marginLeftAnimation;

function MyApp() {
const animatedMarginLeft = useAnimatedValue(0);
_animatedMarginLeft = animatedMarginLeft;
return (
<Animated.View
ref={viewRef}
style={[
{
width: 100,
height: 100,
marginLeft: animatedMarginLeft,
},
]}
/>
);
}

const root = Fantom.createRoot();

Fantom.runTask(() => {
root.render(<MyApp />);
});

Fantom.runTask(() => {
_marginLeftAnimation = Animated.timing(_animatedMarginLeft, {
toValue: 100,
duration: 200,
useNativeDriver: true,
}).start();
});

Fantom.unstable_produceFramesForDuration(100);

expect(root.getRenderedOutput({props: ['marginLeft']}).toJSX()).toEqual(
<rn-view marginLeft="50" />,
);

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(
<rn-view marginLeft="100" />,
);
});

test('animated opacity', () => {
let _opacity;
let _opacityAnimation;
Expand Down
Loading
Loading