From 2ff21d858781804e4440ae227f1d6efe67fc0991 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Tue, 24 Jun 2025 21:04:00 -0700 Subject: [PATCH] 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 --- apps/examples/src/components/App.js | 2 +- .../src/native/modules/useStyleTransition.js | 81 +++++++++++++++++-- .../src/types/renderer.native.js | 5 ++ .../tests/__mocks__/react-native/index.js | 5 ++ .../html-test.native.js.snap-native | 39 ++++++++- .../tests/html-test.native.js | 68 +++++++++++++++- 6 files changed, 189 insertions(+), 11 deletions(-) diff --git a/apps/examples/src/components/App.js b/apps/examples/src/components/App.js index ef1c02f5..8e623e76 100644 --- a/apps/examples/src/components/App.js +++ b/apps/examples/src/components/App.js @@ -739,7 +739,7 @@ const styles = css.create({ backgroundColor: 'red', transitionDuration: '500ms', transitionProperty: 'transform', - transitionTimingFunction: 'ease' + transitionTimingFunction: 'spring(1,100,10,0)' }, objContain: { objectFit: 'contain' diff --git a/packages/react-strict-dom/src/native/modules/useStyleTransition.js b/packages/react-strict-dom/src/native/modules/useStyleTransition.js index d217757b..82d6fe7d 100644 --- a/packages/react-strict-dom/src/native/modules/useStyleTransition.js +++ b/packages/react-strict-dom/src/native/modules/useStyleTransition.js @@ -8,13 +8,14 @@ */ import type { + CompositeAnimation, ReactNativeStyle, ReactNativeStyleValue, ReactNativeTransform } from '../../types/renderer.native'; import { useEffect, useRef, useState } from 'react'; -import { warnMsg } from '../../shared/logUtils'; +import { errorMsg, warnMsg } from '../../shared/logUtils'; import { Animated, Easing } from 'react-native'; type AnimatedStyle = { @@ -191,6 +192,74 @@ function transitionStyleHasChanged( return false; } +function getAnimation( + animatedValue: Animated.Value, + duration: number, + timingFunction: string | null, + shouldUseNativeDriver: boolean +): CompositeAnimation { + // Based on https://lists.w3.org/Archives/Public/www-style/2016Jun/0181.html + // spring(mass, stiffness, damping, initialVelocity) + if (timingFunction != null && timingFunction.trim().startsWith('spring(')) { + const chunk = timingFunction.split('spring(')[1]; + + const closingParenIndex = chunk.indexOf(')'); + if (closingParenIndex === -1) { + errorMsg( + `spring() timing function of "${timingFunction}" is missing closing parenthesis.` + ); + return Animated.timing(animatedValue, { + duration, + easing: getEasingFunction(null), + toValue: 1, + useNativeDriver: shouldUseNativeDriver + }); + } + + const str = chunk.split(')')[0]; + let [mass = 1, stiffness = 100, damping = 10, initialVelocity = 0] = + str === '' ? [] : str.split(',').map((point) => parseFloat(point.trim())); + + if (mass <= 0) { + errorMsg( + `spring() timing function "mass" must be greater than 0. Received ${mass}. Defaulting to 1.` + ); + mass = 1; + } + if (stiffness <= 0) { + errorMsg( + `spring() timing function "stiffness" must be greater than 0. Received ${stiffness}. Defaulting to 100.` + ); + stiffness = 100; + } + if (damping < 0) { + errorMsg( + `spring() timing function "damping" must be greater than or equal to 0. Received ${damping}. Defaulting to 10.` + ); + damping = 10; + } + if (initialVelocity == null) { + initialVelocity = 0; + } + + return Animated.spring(animatedValue, { + damping, + mass, + stiffness, + toValue: 1, + useNativeDriver: shouldUseNativeDriver, + velocity: initialVelocity + }); + } + + return Animated.timing(animatedValue, { + duration, + easing: getEasingFunction(timingFunction), + toValue: 1, + useNativeDriver: shouldUseNativeDriver + }); +} + export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { const { transitionDelay: _delay, @@ -260,12 +329,12 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { const animation = Animated.sequence([ Animated.delay(delay), - Animated.timing(animatedValue, { - toValue: 1, + getAnimation( + animatedValue, duration, - easing: getEasingFunction(timingFunction), - useNativeDriver: shouldUseNativeDriver - }) + timingFunction, + shouldUseNativeDriver + ) ]); animation.start(); diff --git a/packages/react-strict-dom/src/types/renderer.native.js b/packages/react-strict-dom/src/types/renderer.native.js index 879be682..65005a5a 100644 --- a/packages/react-strict-dom/src/types/renderer.native.js +++ b/packages/react-strict-dom/src/types/renderer.native.js @@ -9,6 +9,10 @@ // $FlowFixMe(nonstrict-import) import type AnimatedNode from 'react-native/Libraries/Animated/nodes/AnimatedNode'; +import type { + // $FlowFixMe(nonstrict-import) + CompositeAnimation +} from 'react-native/Libraries/Animated/Animated'; import type { // $FlowFixMe(nonstrict-import) Props as TextInputProps @@ -141,6 +145,7 @@ type ReactNativeStyleValue = type ReactNativeStyle = { [string]: ?ReactNativeStyleValue }; export type { + CompositeAnimation, ReactNativeProps, ReactNativeStyle, ReactNativeStyleValue, diff --git a/packages/react-strict-dom/tests/__mocks__/react-native/index.js b/packages/react-strict-dom/tests/__mocks__/react-native/index.js index 03d83062..fe784686 100644 --- a/packages/react-strict-dom/tests/__mocks__/react-native/index.js +++ b/packages/react-strict-dom/tests/__mocks__/react-native/index.js @@ -30,6 +30,11 @@ export const Animated = { }), stop: jest.fn() }; + }), + spring: jest.fn(() => { + return { + start: jest.fn() + }; }) }; diff --git a/packages/react-strict-dom/tests/__snapshots__/html-test.native.js.snap-native b/packages/react-strict-dom/tests/__snapshots__/html-test.native.js.snap-native index 123e6666..72478e09 100644 --- a/packages/react-strict-dom/tests/__snapshots__/html-test.native.js.snap-native +++ b/packages/react-strict-dom/tests/__snapshots__/html-test.native.js.snap-native @@ -1043,7 +1043,7 @@ exports[` style polyfills "transition" properties backgroundColor trans /> `; -exports[` style polyfills "transition" properties cubic-bezier easing: end 1`] = ` +exports[` style polyfills "transition" properties cubic-bezier() timing function: end 1`] = ` style polyfills "transition" properties cubic-bezier easing: /> `; -exports[` style polyfills "transition" properties cubic-bezier easing: start 1`] = ` +exports[` style polyfills "transition" properties cubic-bezier() timing function: start 1`] = ` style polyfills "transition" properties other transforms: tra /> `; +exports[` style polyfills "transition" properties spring() timing function: end 1`] = ` + +`; + +exports[` style polyfills "transition" properties spring() timing function: start 1`] = ` + +`; + exports[` style polyfills "transition" properties transform transition: end 1`] = ` ', () => { expect(Easing.out).toHaveBeenCalled(); }); - test('cubic-bezier easing', () => { + test('cubic-bezier() timing function', () => { const BEZIER_STR = 'cubic-bezier( 0.1, 0.2,0.3 ,0.4)'; let root; - // cubic-bezier easing act(() => { root = create(); }); @@ -717,6 +716,71 @@ describe('', () => { expect(Easing.bezier).toHaveBeenCalledWith(0.1, 0.2, 0.3, 0.4); }); + test('spring() timing function', () => { + // spring(mass, stiffness, damping, initialVelocity) + const SPRING_STR = 'spring( 1, 2,3 , 4 )'; + let root; + act(() => { + root = create(); + }); + expect(root.toJSON()).toMatchSnapshot('start'); + expect(Animated.spring).not.toHaveBeenCalled(); + expect(Animated.timing).not.toHaveBeenCalled(); + act(() => { + root.update(); + }); + expect(root.toJSON()).toMatchSnapshot('end'); + // Animated.spring(damping, mass, stiffness, toValue, useNativeDriver, velocity) + expect(Animated.spring).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + damping: 3, + mass: 1, + stiffness: 2, + toValue: 1, + useNativeDriver: true, + velocity: 4 + }) + ); + expect(Animated.timing).not.toHaveBeenCalled(); + + // Test that we replace invalid spring params and print error msg + const SPRING_BAD_STR = 'spring(0,0,-1,0)'; + let badRoot; + act(() => { + badRoot = create( + + ); + }); + act(() => { + badRoot.update( + + ); + }); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('"mass" must be greater than 0') + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('"stiffness" must be greater than 0') + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + '"damping" must be greater than or equal to 0' + ) + ); + expect(Animated.spring).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + damping: 10, + mass: 1, + stiffness: 100, + toValue: 1, + useNativeDriver: true, + velocity: 0 + }) + ); + }); + test('transition all properties (opacity and transform)', () => { let root; // transition all properties (opacity and transform)