diff --git a/CHANGELOG.md b/CHANGELOG.md index ef51cf9d..3dda8552 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## Next +- Added: `createAnimatedRestyleComponent` helper and a guide explaining why animated Restyle components must nest the animated component inside Restyle (not the other way around) to stay compatible with `react-native-reanimated` 4.4+'s `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` flag [#TBD](https://github.com/Shopify/restyle/pull/TBD) by [tothvoj-gl](https://github.com/tothvoj-gl) + ## 2.4.5 - 2025-03-19 - Fixed: dist folder not being generated when building the project [#302](https://github.com/Shopify/restyle/pull/302) by [kelset](https://github.com/naqvitalha) diff --git a/documentation/docs/guides/animating-restyle-components.md b/documentation/docs/guides/animating-restyle-components.md new file mode 100644 index 00000000..2e4e71cb --- /dev/null +++ b/documentation/docs/guides/animating-restyle-components.md @@ -0,0 +1,118 @@ +--- +id: animating-restyle-components +title: Animating Restyle components +--- + +Restyle doesn't depend on any animation library, but it's common to want to animate a +themed component — for example, animating a `Box`'s `borderRadius` or `backgroundColor` +between two theme values. + +## Nest the animated component inside Restyle, not the other way around + +Given a themed component, there are two ways to make it animatable: + +```tsx +// A: animated outer, Restyle inner +const AnimatedBox = Animated.createAnimatedComponent(createBox()); +``` + +```tsx +// B: Restyle outer, animated inner +const AnimatedBox = createAnimatedRestyleComponent( + Animated.createAnimatedComponent(View), +); +``` + +These look interchangeable, but **only B is safe with `react-native-reanimated` 4.4+**. + +Reanimated 4.4 introduced a default-on performance flag, +`FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS`. Once an animation settles, Reanimated +re-renders the component it wraps with the resolved style values spread on as +top-level props, in addition to `style`: + +``` +borderRadius: 13 +backgroundColor: 'rgba(255,209,102,1)' +``` + +In ordering **A**, the component Reanimated wraps _is_ the Restyle component, so those +resolved values land as `borderRadius` / `backgroundColor` props on it — and Restyle +treats any theme-key prop as a token lookup. A raw number or color string is not a key +in your theme, so `getThemeValue` throws: + +``` +Value '13' does not exist in theme['borderRadii'] +Value 'rgba(255,209,102,1)' does not exist in theme['colors'] +``` + +In ordering **B**, Reanimated wraps a plain `View` that sits _inside_ Restyle's +`BaseComponent`. The settled props get spread onto that inner `View` — a component +Restyle's props never pass back through — so there's nothing to collide with. Restyle +only ever sees the props it was given in JSX (`style`, and whichever theme props you +passed explicitly). + +## `createAnimatedRestyleComponent` + +`createAnimatedRestyleComponent` is a small helper for ordering B: it's `createBox` +with its `Props` generic inferred from the animated component you hand it, so you get +that component's own prop types (its `style` prop in particular) without having to +redeclare them yourself. + +```tsx +import {Animated, View} from 'react-native'; +// or, for react-native-reanimated: +// import Animated from 'react-native-reanimated'; +import {createAnimatedRestyleComponent, useTheme} from '@shopify/restyle'; +import {Theme} from './theme'; + +const AnimatedBox = createAnimatedRestyleComponent( + Animated.createAnimatedComponent(View), +); + +const Example = () => { + const {borderRadii} = useTheme(); + const progress = useSharedValue(0); + + const animatedStyle = useAnimatedStyle(() => ({ + borderRadius: interpolate(progress.value, [0, 1], [ + borderRadii.sm, + borderRadii.lg, + ]), + })); + + return ( + + ); +}; +``` + +Restyle itself never imports an animation library — `createAnimatedRestyleComponent` +just takes whatever already-animated component you construct and wraps it, so this +works the same way with `react-native-reanimated`, the built-in `Animated` API, or +anything else that produces a component accepting a `style` prop. + +The component `createAnimatedRestyleComponent` returns behaves exactly like `Box`: +same restyle functions, same ref forwarding, and it composes with +[variants](/fundamentals/variants) the same way any [custom component](/fundamentals/components/custom-components) +does — pass `createVariant(...)` alongside `boxRestyleFunctions` to +`createRestyleComponent` directly if you need a variant-aware animated component. + +## If you're not using `createAnimatedRestyleComponent` + +The same ordering rule applies if you build your own animated component instead — +with `createRestyleComponent` or `createBox` directly: + +```tsx +const AnimatedBox = createBox>( + Animated.createAnimatedComponent(View), +); +``` + +The important part isn't the helper, it's the nesting order: give Restyle the +already-animated component as its `BaseComponent`, don't wrap a Restyle component in +`Animated.createAnimatedComponent`. diff --git a/documentation/sidebars.js b/documentation/sidebars.js index 4da08813..ce78aeeb 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -61,6 +61,7 @@ module.exports = { collapsed: true, items: [ 'guides/dark-mode', + 'guides/animating-restyle-components', 'guides/fixture-app', 'guides/shopify-design-system', 'guides/migrating-to-v2', diff --git a/src/createAnimatedRestyleComponent.ts b/src/createAnimatedRestyleComponent.ts new file mode 100644 index 00000000..22748718 --- /dev/null +++ b/src/createAnimatedRestyleComponent.ts @@ -0,0 +1,39 @@ +import React from 'react'; + +import createBox from './createBox'; +import {BaseTheme} from './types'; + +/** + * Wraps an already-animated component (e.g. `Animated.createAnimatedComponent(View)` + * from `react-native-reanimated`, or `Animated.View` from the built-in `Animated` API) + * with Restyle's `Box` props, inferring the wrapped component's own prop types (its + * `style` prop in particular) instead of requiring them to be re-declared by hand. + * + * Restyle never imports an animation library itself: the caller constructs the animated + * component and hands it in, so this stays a zero-dependency wrapper around `createBox`. + * + * Ordering matters: nest the animated component *inside* Restyle + * (`createAnimatedRestyleComponent(Animated.createAnimatedComponent(View))`), not the + * other way around (`Animated.createAnimatedComponent(createBox())`). Outside-in, a + * library that re-renders with resolved style values spread as top-level props (as + * react-native-reanimated 4.4+ does once an animation settles, when its + * `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` flag is on) lands those raw values in + * Restyle's theme-token prop namespace. Inside-out, those props are spread on the + * plain wrapped component instead, never reaching Restyle's prop parsing. See the + * "Animating Restyle components" guide for the full explanation and an example. + */ +const createAnimatedRestyleComponent = < + Theme extends BaseTheme, + AnimatedComponentType extends React.ComponentType, + EnableShorthand extends boolean = true, +>( + AnimatedComponent: AnimatedComponentType, +) => { + return createBox< + Theme, + React.ComponentProps, + EnableShorthand + >(AnimatedComponent); +}; + +export default createAnimatedRestyleComponent; diff --git a/src/index.ts b/src/index.ts index 333a0903..de75cb35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export * from './createText'; export {default as createVariant} from './createVariant'; export {default as createBox} from './createBox'; export {default as createText} from './createText'; +export {default as createAnimatedRestyleComponent} from './createAnimatedRestyleComponent'; export {ThemeProvider, ThemeContext} from './context'; export {default as useTheme} from './hooks/useTheme'; export {default as useRestyle} from './hooks/useRestyle'; diff --git a/src/test/createAnimatedRestyleComponent.test.tsx b/src/test/createAnimatedRestyleComponent.test.tsx new file mode 100644 index 00000000..65a6b9a5 --- /dev/null +++ b/src/test/createAnimatedRestyleComponent.test.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import {create as render, act} from 'react-test-renderer'; +import {View} from 'react-native'; + +import createAnimatedRestyleComponent from '../createAnimatedRestyleComponent'; +import createRestyleComponent from '../createRestyleComponent'; +import createVariant, {VariantProps} from '../createVariant'; +import {boxRestyleFunctions, BoxProps} from '../createBox'; +import {ThemeProvider} from '../context'; + +const theme = { + colors: { + primary: '#5A31F4', + }, + spacing: { + s: 8, + }, + borderRadii: { + sm: 4, + lg: 32, + }, + zIndices: { + base: 0, + top: 1, + }, + cardVariants: { + defaults: { + borderRadius: 'sm', + }, + raised: { + borderRadius: 'lg', + }, + }, +}; + +type Theme = typeof theme; + +/** + * Stands in for `Animated.createAnimatedComponent(View)` without depending on + * react-native-reanimated in the test suite. It reproduces the exact mechanism + * behind #355: once "settled" (triggered here via the imperative `settle` method, + * standing in for Reanimated's FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS flag), it + * spreads the given raw style values as top-level props onto the plain `View` it + * renders internally — via its own setState/render, never by re-rendering with new + * props passed down from its parent. That's the crux of what ordering fixes: those + * settled props land on this component's own child, not back up on whatever wraps it. + */ +class MockAnimatedView extends React.Component< + {style?: unknown; [key: string]: unknown}, + {settled: {[key: string]: unknown} | null} +> { + state: {settled: {[key: string]: unknown} | null} = {settled: null}; + + // Intentionally public: tests call this through a ref to simulate the + // imperative settle event a real animation library fires internally. + // eslint-disable-next-line @shopify/react-prefer-private-members + settle(values: {[key: string]: unknown}) { + this.setState({settled: values}); + } + + render() { + const {settled} = this.state; + return ; + } +} + +const AnimatedBox = createAnimatedRestyleComponent< + Theme, + typeof MockAnimatedView +>(MockAnimatedView); + +describe('createAnimatedRestyleComponent', () => { + it('does not throw when a settled animation spreads a raw color value', () => { + let instance: MockAnimatedView | null = null; + const {root} = render( + + (instance = r)} /> + , + ); + + expect(() => { + act(() => { + instance!.settle({backgroundColor: 'rgba(255,209,102,1)'}); + }); + }).not.toThrow(); + + expect(root.findByType(View).props.backgroundColor).toBe( + 'rgba(255,209,102,1)', + ); + }); + + it('does not throw when a settled animation spreads raw borderRadius and zIndex values', () => { + let instance: MockAnimatedView | null = null; + const {root} = render( + + (instance = r)} /> + , + ); + + expect(() => { + act(() => { + instance!.settle({borderRadius: 13, zIndex: 1}); + }); + }).not.toThrow(); + + expect(root.findByType(View).props).toMatchObject({ + borderRadius: 13, + zIndex: 1, + }); + }); + + it('still resolves theme tokens normally', () => { + const {root} = render( + + + , + ); + + expect(root.findByType(View).props.style).toStrictEqual([ + {backgroundColor: '#5A31F4', borderRadius: 32, zIndex: 1}, + ]); + }); + + it("still throws on a genuine typo'd theme key", () => { + expect(() => { + render( + + {/* @ts-expect-error intentionally invalid theme key, to prove typo detection is unaffected */} + + , + ); + }).toThrow("Value 'primaryy' does not exist in theme['colors']"); + }); + + it('supports variants when composed with createVariant, same as a non-animated custom component', () => { + // `Box` (and so `createAnimatedRestyleComponent`, which shares its restyle + // functions) doesn't bundle variant support out of the box — a variant-aware + // component is composed explicitly via createRestyleComponent, same as the + // "Custom components" guide shows for non-animated components. This proves + // that composition still works with an animated base component. + const cardVariant = createVariant({ + themeKey: 'cardVariants', + }); + type Props = BoxProps & + VariantProps & + React.ComponentProps; + const AnimatedCard = createRestyleComponent( + [...boxRestyleFunctions, cardVariant], + MockAnimatedView, + ); + + const {root} = render( + + + , + ); + + expect(root.findByType(View).props.style).toStrictEqual([ + {borderRadius: 32}, + ]); + }); + + it('supports responsive props', () => { + const {root} = render( + + + , + ); + + expect(root.findByType(View).props.style).toStrictEqual([ + {borderRadius: 4}, + ]); + }); + + it('forwards refs to the wrapped animated component', () => { + const spy = jest.fn(); + render( + + + , + ); + + expect(spy).toHaveBeenCalledWith(expect.any(MockAnimatedView)); + }); +});