Skip to content

Commit 4e77739

Browse files
committed
Polyfill experimental 'spring()' timing function for native
Uses React Native Animated API to polyfill a 2016 proposal for the spring() timing function in CSS. https://lists.w3.org/Archives/Public/www-style/2016Jun/0181.html
1 parent 31ad06a commit 4e77739

6 files changed

Lines changed: 189 additions & 11 deletions

File tree

apps/examples/src/components/App.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,7 @@ const styles = css.create({
739739
backgroundColor: 'red',
740740
transitionDuration: '500ms',
741741
transitionProperty: 'transform',
742-
transitionTimingFunction: 'ease'
742+
transitionTimingFunction: 'spring(1,100,10,0)'
743743
},
744744
objContain: {
745745
objectFit: 'contain'

packages/react-strict-dom/src/native/modules/useStyleTransition.js

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88
*/
99

1010
import type {
11+
CompositeAnimation,
1112
ReactNativeStyle,
1213
ReactNativeStyleValue,
1314
ReactNativeTransform
1415
} from '../../types/renderer.native';
1516

1617
import { useEffect, useRef, useState } from 'react';
17-
import { warnMsg } from '../../shared/logUtils';
18+
import { errorMsg, warnMsg } from '../../shared/logUtils';
1819
import { Animated, Easing } from 'react-native';
1920

2021
type AnimatedStyle = {
@@ -191,6 +192,74 @@ function transitionStyleHasChanged(
191192
return false;
192193
}
193194

195+
function getAnimation(
196+
animatedValue: Animated.Value,
197+
duration: number,
198+
timingFunction: string | null,
199+
shouldUseNativeDriver: boolean
200+
): CompositeAnimation {
201+
// Based on https://lists.w3.org/Archives/Public/www-style/2016Jun/0181.html
202+
// spring(mass, stiffness, damping, initialVelocity)
203+
if (timingFunction != null && timingFunction.includes('spring(')) {
204+
const chunk = timingFunction.split('spring(')[1];
205+
206+
const closingParenIndex = chunk.indexOf(')');
207+
if (closingParenIndex === -1) {
208+
errorMsg(
209+
`spring() timing function of "${timingFunction}" is missing closing parenthesis.`
210+
);
211+
return Animated.timing(animatedValue, {
212+
duration,
213+
easing: getEasingFunction(null),
214+
toValue: 1,
215+
useNativeDriver: shouldUseNativeDriver
216+
});
217+
}
218+
219+
const str = chunk.split(')')[0];
220+
let [mass = 1, stiffness = 100, damping = 10, initialVelocity = 0] =
221+
str === '' ? [] : str.split(',').map((point) => parseFloat(point.trim()));
222+
223+
if (mass <= 0) {
224+
errorMsg(
225+
`spring() timing function "mass" must be greater than 0. Received ${mass}. Defaulting to 1.`
226+
);
227+
mass = 1;
228+
}
229+
if (stiffness <= 0) {
230+
errorMsg(
231+
`spring() timing function "stiffness" must be greater than 0. Received ${stiffness}. Defaulting to 100.`
232+
);
233+
stiffness = 100;
234+
}
235+
if (damping < 0) {
236+
errorMsg(
237+
`spring() timing function "damping" must be greater than or equal to 0. Received ${damping}. Defaulting to 10.`
238+
);
239+
damping = 10;
240+
}
241+
if (initialVelocity == null) {
242+
initialVelocity = 0;
243+
}
244+
245+
return Animated.spring(animatedValue, {
246+
damping,
247+
mass,
248+
stiffness,
249+
toValue: 1,
250+
useNativeDriver: shouldUseNativeDriver,
251+
velocity: initialVelocity
252+
});
253+
}
254+
255+
return Animated.timing(animatedValue, {
256+
duration,
257+
easing: getEasingFunction(timingFunction),
258+
toValue: 1,
259+
useNativeDriver: shouldUseNativeDriver
260+
});
261+
}
262+
194263
export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle {
195264
const {
196265
transitionDelay: _delay,
@@ -260,12 +329,12 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle {
260329

261330
const animation = Animated.sequence([
262331
Animated.delay(delay),
263-
Animated.timing(animatedValue, {
264-
toValue: 1,
332+
getAnimation(
333+
animatedValue,
265334
duration,
266-
easing: getEasingFunction(timingFunction),
267-
useNativeDriver: shouldUseNativeDriver
268-
})
335+
timingFunction,
336+
shouldUseNativeDriver
337+
)
269338
]);
270339
animation.start();
271340

packages/react-strict-dom/src/types/renderer.native.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99

1010
// $FlowFixMe(nonstrict-import)
1111
import type AnimatedNode from 'react-native/Libraries/Animated/nodes/AnimatedNode';
12+
import type {
13+
// $FlowFixMe(nonstrict-import)
14+
CompositeAnimation
15+
} from 'react-native/Libraries/Animated/Animated';
1216
import type {
1317
// $FlowFixMe(nonstrict-import)
1418
Props as TextInputProps
@@ -141,6 +145,7 @@ type ReactNativeStyleValue =
141145
type ReactNativeStyle = { [string]: ?ReactNativeStyleValue };
142146

143147
export type {
148+
CompositeAnimation,
144149
ReactNativeProps,
145150
ReactNativeStyle,
146151
ReactNativeStyleValue,

packages/react-strict-dom/tests/__mocks__/react-native/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export const Animated = {
3030
}),
3131
stop: jest.fn()
3232
};
33+
}),
34+
spring: jest.fn(() => {
35+
return {
36+
start: jest.fn()
37+
};
3338
})
3439
};
3540

packages/react-strict-dom/tests/__snapshots__/html-test.native.js.snap-native

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,7 +1043,7 @@ exports[`<html.*> style polyfills "transition" properties backgroundColor trans
10431043
/>
10441044
`;
10451045

1046-
exports[`<html.*> style polyfills "transition" properties cubic-bezier easing: end 1`] = `
1046+
exports[`<html.*> style polyfills "transition" properties cubic-bezier() timing function: end 1`] = `
10471047
<Animated.View
10481048
animated={true}
10491049
style={
@@ -1065,7 +1065,7 @@ exports[`<html.*> style polyfills "transition" properties cubic-bezier easing:
10651065
/>
10661066
`;
10671067

1068-
exports[`<html.*> style polyfills "transition" properties cubic-bezier easing: start 1`] = `
1068+
exports[`<html.*> style polyfills "transition" properties cubic-bezier() timing function: start 1`] = `
10691069
<Animated.View
10701070
animated={true}
10711071
style={
@@ -1328,6 +1328,41 @@ exports[`<html.*> style polyfills "transition" properties other transforms: tra
13281328
/>
13291329
`;
13301330

1331+
exports[`<html.*> style polyfills "transition" properties spring() timing function: end 1`] = `
1332+
<Animated.View
1333+
animated={true}
1334+
style={
1335+
{
1336+
"boxSizing": "content-box",
1337+
"opacity": {
1338+
"inputRange": [
1339+
0,
1340+
1,
1341+
],
1342+
"outputRange": [
1343+
1,
1344+
0,
1345+
],
1346+
},
1347+
"position": "static",
1348+
}
1349+
}
1350+
/>
1351+
`;
1352+
1353+
exports[`<html.*> style polyfills "transition" properties spring() timing function: start 1`] = `
1354+
<Animated.View
1355+
animated={true}
1356+
style={
1357+
{
1358+
"boxSizing": "content-box",
1359+
"opacity": 1,
1360+
"position": "static",
1361+
}
1362+
}
1363+
/>
1364+
`;
1365+
13311366
exports[`<html.*> style polyfills "transition" properties transform transition: end 1`] = `
13321367
<Animated.View
13331368
animated={true}

packages/react-strict-dom/tests/html-test.native.js

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,10 +701,9 @@ describe('<html.*>', () => {
701701
expect(Easing.out).toHaveBeenCalled();
702702
});
703703

704-
test('cubic-bezier easing', () => {
704+
test('cubic-bezier() timing function', () => {
705705
const BEZIER_STR = 'cubic-bezier( 0.1, 0.2,0.3 ,0.4)';
706706
let root;
707-
// cubic-bezier easing
708707
act(() => {
709708
root = create(<html.div style={styles.opacity(1, BEZIER_STR)} />);
710709
});
@@ -717,6 +716,71 @@ describe('<html.*>', () => {
717716
expect(Easing.bezier).toHaveBeenCalledWith(0.1, 0.2, 0.3, 0.4);
718717
});
719718

719+
test('spring() timing function', () => {
720+
// spring(mass, stiffness, damping, initialVelocity)
721+
const SPRING_STR = 'spring( 1, 2,3 , 4 )';
722+
let root;
723+
act(() => {
724+
root = create(<html.div style={styles.opacity(1, SPRING_STR)} />);
725+
});
726+
expect(root.toJSON()).toMatchSnapshot('start');
727+
expect(Animated.spring).not.toHaveBeenCalled();
728+
expect(Animated.timing).not.toHaveBeenCalled();
729+
act(() => {
730+
root.update(<html.div style={styles.opacity(0, SPRING_STR)} />);
731+
});
732+
expect(root.toJSON()).toMatchSnapshot('end');
733+
// Animated.spring(damping, mass, stiffness, toValue, useNativeDriver, velocity)
734+
expect(Animated.spring).toHaveBeenCalledWith(
735+
expect.anything(),
736+
expect.objectContaining({
737+
damping: 3,
738+
mass: 1,
739+
stiffness: 2,
740+
toValue: 1,
741+
useNativeDriver: true,
742+
velocity: 4
743+
})
744+
);
745+
expect(Animated.timing).not.toHaveBeenCalled();
746+
747+
// Test that we replace invalid spring params and print error msg
748+
const SPRING_BAD_STR = 'spring(0,0,-1,0)';
749+
let badRoot;
750+
act(() => {
751+
badRoot = create(
752+
<html.div style={styles.opacity(1, SPRING_BAD_STR)} />
753+
);
754+
});
755+
act(() => {
756+
badRoot.update(
757+
<html.div style={styles.opacity(0, SPRING_BAD_STR)} />
758+
);
759+
});
760+
expect(console.error).toHaveBeenCalledWith(
761+
expect.stringContaining('"mass" must be greater than 0')
762+
);
763+
expect(console.error).toHaveBeenCalledWith(
764+
expect.stringContaining('"stiffness" must be greater than 0')
765+
);
766+
expect(console.error).toHaveBeenCalledWith(
767+
expect.stringContaining(
768+
'"damping" must be greater than or equal to 0'
769+
)
770+
);
771+
expect(Animated.spring).toHaveBeenCalledWith(
772+
expect.anything(),
773+
expect.objectContaining({
774+
damping: 10,
775+
mass: 1,
776+
stiffness: 100,
777+
toValue: 1,
778+
useNativeDriver: true,
779+
velocity: 0
780+
})
781+
);
782+
});
783+
720784
test('transition all properties (opacity and transform)', () => {
721785
let root;
722786
// transition all properties (opacity and transform)

0 commit comments

Comments
 (0)