diff --git a/.changeset/mean-donuts-hear.md b/.changeset/mean-donuts-hear.md new file mode 100644 index 00000000..31f45d80 --- /dev/null +++ b/.changeset/mean-donuts-hear.md @@ -0,0 +1,9 @@ +--- +"@plextv/react-native-lightning-example": patch +"@plextv/react-native-lightning": patch +"@plextv/react-lightning-plugin-reanimated": patch +"@plextv/react-lightning": patch +"@plextv/react-lightning-storybook": patch +--- + +Add support for reanimated animation builders diff --git a/apps/react-native-lightning-example/src/index.tsx b/apps/react-native-lightning-example/src/index.tsx index 0e34c8bf..a912c877 100644 --- a/apps/react-native-lightning-example/src/index.tsx +++ b/apps/react-native-lightning-example/src/index.tsx @@ -14,6 +14,7 @@ import { import { AppRegistry, Button } from 'react-native'; import { ErrorBoundary } from './ErrorBoundary'; import { keyMap } from './keyMap'; +import { AnimationBuilderTest } from './pages/AnimationBuilderTest'; import { AnimationTest } from './pages/AnimationTest'; import { ComponentTest } from './pages/ComponentTest'; import { FlashListTest } from './pages/FlashListTest'; @@ -52,6 +53,7 @@ const CustomStack = createCustomNavigator(); const screens = { Layout: 'layout', Animation: 'animation', + AnimationBuilder: 'animationBuilder', Library: 'library', Simple: 'simple', Components: 'components', @@ -94,6 +96,11 @@ const MainApp = () => { color={'rgba(55, 55, 22, 1)'} onPress={() => nav.navigate('Animation')} /> + + + + ); +}; + +const AnimationBuilderTest = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export { AnimationBuilderTest }; diff --git a/apps/react-native-lightning-example/vite.config.mjs b/apps/react-native-lightning-example/vite.config.mjs index 4beaae02..0ced185a 100644 --- a/apps/react-native-lightning-example/vite.config.mjs +++ b/apps/react-native-lightning-example/vite.config.mjs @@ -33,6 +33,7 @@ const config = defineConfig((env) => ({ __DEV__: JSON.stringify( (env.mode ?? process.env.NODE_ENV) !== 'production', ), + 'process.env.NODE_ENV': JSON.stringify(env.mode), }, })); diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 276c4cb2..2ae7d6ae 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -19,6 +19,7 @@ const config: StorybookConfig = { __DEV__: JSON.stringify( (config.mode ?? process.env.NODE_ENV) !== 'production', ), + 'process.env.NODE_ENV': JSON.stringify(config.mode), }; config.plugins = [ diff --git a/apps/storybook/.storybook/preview.tsx b/apps/storybook/.storybook/preview.tsx index c8646943..0295b23e 100644 --- a/apps/storybook/.storybook/preview.tsx +++ b/apps/storybook/.storybook/preview.tsx @@ -14,10 +14,10 @@ const preview: Preview = { order: [ 'Getting Started', ['Introduction', 'Quick Start'], - '@plextv∕react-lightning', - '@plextv∕react-lightning-components', - '@plextv∕react-native-lightning', - '@plextv∕react-native-lightning-components', + 'react-lightning', + 'react-lightning-components', + 'react-native-lightning', + 'react-native-lightning-components', 'Plugins', ], }, diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 83820d29..9c7feb95 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -25,6 +25,7 @@ "@plextv/react-lightning-components": "workspace:*", "@plextv/react-lightning-plugin-flexbox": "workspace:*", "@plextv/react-lightning-plugin-flexbox-lite": "workspace:*", + "@plextv/react-lightning-plugin-reanimated": "workspace:*", "@plextv/react-native-lightning": "workspace:*", "@plextv/react-native-lightning-components": "workspace:*", "@storybook/addon-essentials": "8.6.12", diff --git a/apps/storybook/src/components/StorybookDecorator.tsx b/apps/storybook/src/components/StorybookDecorator.tsx index 76487f54..f6e47528 100644 --- a/apps/storybook/src/components/StorybookDecorator.tsx +++ b/apps/storybook/src/components/StorybookDecorator.tsx @@ -8,13 +8,13 @@ import { DefaultStoryHeight, DefaultStoryWidth } from '../helpers/constants'; type Props = { story: () => JSX.Element; - tags: string[]; + tags?: string[]; canvasOptions?: Partial; }; export function StorybookDecorator({ story: Story, - tags = [], + tags, canvasOptions, }: Props) { const options: RenderOptions = useMemo( @@ -41,7 +41,7 @@ export function StorybookDecorator({ 'RoundedWithShadow', 'RoundedWithBorderAndShadow', ], - plugins: tags.includes('reactNative') ? getPlugins() : [flexPlugin()], + plugins: tags?.includes('reactNative') ? getPlugins() : [flexPlugin()], ...canvasOptions, }), [tags, canvasOptions], diff --git a/apps/storybook/src/plugin-flexbox-lite/FlexboxLite.stories.tsx b/apps/storybook/src/plugin-flexbox-lite/FlexboxLite.stories.tsx index 0a345e10..190b7941 100644 --- a/apps/storybook/src/plugin-flexbox-lite/FlexboxLite.stories.tsx +++ b/apps/storybook/src/plugin-flexbox-lite/FlexboxLite.stories.tsx @@ -32,7 +32,7 @@ const ExampleBox = () => { }; export default { - title: 'Plugins/@plextv∕react-lightning-plugin-flexbox-lite/FlexBoxLite', + title: 'Plugins/react-lightning-plugin-flexbox-lite/FlexBoxLite', decorators: [ (Story) => ( = ({ visible, ...props }) => { + const color = useMemo(() => randomColor(), []); + + return visible ? ( + + ) : null; +}; + +export const AnimationSampler: FC<{ + buttonPrefix: string; + buttonSuffix?: string; + entering?: ReanimatedAnimation; + exiting?: ReanimatedAnimation; +}> = ({ buttonPrefix, buttonSuffix = '', entering, exiting }) => { + const [visible, setVisible] = useState(true); + + return ( + + + + + + ); +}; diff --git a/apps/storybook/src/plugin-reanimated/Fade.stories.tsx b/apps/storybook/src/plugin-reanimated/Fade.stories.tsx new file mode 100644 index 00000000..65ee62e2 --- /dev/null +++ b/apps/storybook/src/plugin-reanimated/Fade.stories.tsx @@ -0,0 +1,61 @@ +import { + FadeIn, + FadeInDown, + FadeInLeft, + FadeInRight, + FadeInUp, + FadeOut, + FadeOutDown, + FadeOutLeft, + FadeOutRight, + FadeOutUp, +} from '@plextv/react-lightning-plugin-reanimated'; +import { Column } from '@plextv/react-native-lightning-components'; +import type { Meta } from '@storybook/react'; +import { AnimationSampler } from './AnimationSampler'; + +export default { + title: 'Plugins/react-lightning-plugin-reanimated/Fade', + tags: ['reactNative'], +} as Meta; + +// The rest of the story definitions +export const Fade = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/apps/storybook/src/plugin-reanimated/Slide.stories.tsx b/apps/storybook/src/plugin-reanimated/Slide.stories.tsx new file mode 100644 index 00000000..c28056d6 --- /dev/null +++ b/apps/storybook/src/plugin-reanimated/Slide.stories.tsx @@ -0,0 +1,53 @@ +import { + SlideInDown, + SlideInLeft, + SlideInRight, + SlideInUp, + SlideOutDown, + SlideOutLeft, + SlideOutRight, + SlideOutUp, +} from '@plextv/react-lightning-plugin-reanimated'; +import { Column } from '@plextv/react-native-lightning-components'; +import type { Meta } from '@storybook/react'; +import { AnimationSampler } from './AnimationSampler'; + +export default { + title: 'Plugins/react-lightning-plugin-reanimated/Slide', + tags: ['reactNative'], +} as Meta; + +// The rest of the story definitions +export const Slide = () => { + return ( + + + + + + + + + + ); +}; diff --git a/apps/storybook/src/react-lightning-components/layout/Column.stories.tsx b/apps/storybook/src/react-lightning-components/layout/Column.stories.tsx index 82098814..040001a3 100644 --- a/apps/storybook/src/react-lightning-components/layout/Column.stories.tsx +++ b/apps/storybook/src/react-lightning-components/layout/Column.stories.tsx @@ -18,7 +18,7 @@ const ExampleBox = () => { }; export default { - title: '@plextv∕react-lightning-components/Layout/Column', + title: 'react-lightning-components/Layout/Column', component: Column, argTypes: { justifyContent: { diff --git a/apps/storybook/src/react-lightning-components/layout/Row.stories.tsx b/apps/storybook/src/react-lightning-components/layout/Row.stories.tsx index 116486f8..2b73b85e 100644 --- a/apps/storybook/src/react-lightning-components/layout/Row.stories.tsx +++ b/apps/storybook/src/react-lightning-components/layout/Row.stories.tsx @@ -18,7 +18,7 @@ const ExampleBox = () => { }; export default { - title: '@plextv∕react-lightning-components/Layout/Row', + title: 'react-lightning-components/Layout/Row', component: Row, argTypes: { justifyContent: { diff --git a/apps/storybook/src/react-lightning-components/text/StyledText.stories.tsx b/apps/storybook/src/react-lightning-components/text/StyledText.stories.tsx index 6626616a..ea66e2fd 100644 --- a/apps/storybook/src/react-lightning-components/text/StyledText.stories.tsx +++ b/apps/storybook/src/react-lightning-components/text/StyledText.stories.tsx @@ -24,7 +24,7 @@ const PhoneFriendSecondLine = { }; export default { - title: '@plextv∕react-lightning-components/Text/StyledText', + title: 'react-lightning-components/Text/StyledText', component: StyledText, }; diff --git a/apps/storybook/src/react-lightning/Components.mdx b/apps/storybook/src/react-lightning/Components.mdx index 3734e9f2..7807e9fe 100644 --- a/apps/storybook/src/react-lightning/Components.mdx +++ b/apps/storybook/src/react-lightning/Components.mdx @@ -1,7 +1,7 @@ import { Canvas, Meta, Source, Story, Controls } from '@storybook/blocks'; import * as ComponentsStories from './Components.stories' - + # Lightning Components diff --git a/apps/storybook/src/react-lightning/Components.stories.tsx b/apps/storybook/src/react-lightning/Components.stories.tsx index c865d615..e5a17dc9 100644 --- a/apps/storybook/src/react-lightning/Components.stories.tsx +++ b/apps/storybook/src/react-lightning/Components.stories.tsx @@ -1,7 +1,7 @@ import type { Meta } from '@storybook/react'; export default { - title: '@plextv∕react-lightning/Lightning Components', + title: 'react-lightning/Lightning Components', component: () => , tags: ['!dev'], } as Meta; diff --git a/apps/storybook/src/react-lightning/Focus.mdx b/apps/storybook/src/react-lightning/Focus.mdx index 14745a4f..dee61506 100644 --- a/apps/storybook/src/react-lightning/Focus.mdx +++ b/apps/storybook/src/react-lightning/Focus.mdx @@ -1,7 +1,7 @@ import { Canvas, Meta, Source } from '@storybook/blocks'; import { SimpleFocusTree } from './examples/focus/FocusTree.stories' - + # How focus works diff --git a/apps/storybook/src/react-lightning/examples/focus/AutoFocus.stories.tsx b/apps/storybook/src/react-lightning/examples/focus/AutoFocus.stories.tsx index 6329f1cf..de21e434 100644 --- a/apps/storybook/src/react-lightning/examples/focus/AutoFocus.stories.tsx +++ b/apps/storybook/src/react-lightning/examples/focus/AutoFocus.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import { FocusableImage } from '../../../components/FocusableImage'; export default { - title: '@plextv∕react-lightning/Examples/Focus/AutoFocus', + title: 'react-lightning/Examples/Focus/AutoFocus', } as Meta; export const AutoFocus = () => { diff --git a/apps/storybook/src/react-lightning/examples/focus/FocusGroup.stories.tsx b/apps/storybook/src/react-lightning/examples/focus/FocusGroup.stories.tsx index bb7e6d8f..3f2bdbe9 100644 --- a/apps/storybook/src/react-lightning/examples/focus/FocusGroup.stories.tsx +++ b/apps/storybook/src/react-lightning/examples/focus/FocusGroup.stories.tsx @@ -5,7 +5,7 @@ import Button from '../../../components/Button'; import { FocusableImage } from '../../../components/FocusableImage'; export default { - title: '@plextv∕react-lightning/Examples/Focus/Focus Group', + title: 'react-lightning/Examples/Focus/Focus Group', argTypes: {}, } as Meta; diff --git a/apps/storybook/src/react-lightning/examples/focus/FocusLayers.stories.tsx b/apps/storybook/src/react-lightning/examples/focus/FocusLayers.stories.tsx index 7c5de76a..786e8214 100644 --- a/apps/storybook/src/react-lightning/examples/focus/FocusLayers.stories.tsx +++ b/apps/storybook/src/react-lightning/examples/focus/FocusLayers.stories.tsx @@ -10,7 +10,7 @@ import { useEffect, useRef, useState } from 'react'; import Button from '../../../components/Button'; export default { - title: '@plextv∕react-lightning/Examples/Focus/Focus Layers', + title: 'react-lightning/Examples/Focus/Focus Layers', argTypes: {}, } as Meta; diff --git a/apps/storybook/src/react-lightning/examples/focus/FocusRedirection.stories.tsx b/apps/storybook/src/react-lightning/examples/focus/FocusRedirection.stories.tsx index ecb52044..6c6aa4bf 100644 --- a/apps/storybook/src/react-lightning/examples/focus/FocusRedirection.stories.tsx +++ b/apps/storybook/src/react-lightning/examples/focus/FocusRedirection.stories.tsx @@ -9,7 +9,7 @@ import { forwardRef, type ReactNode, useState } from 'react'; import { FocusableImage } from '../../../components/FocusableImage'; export default { - title: '@plextv∕react-lightning/Examples/Focus/FocusRedirection', + title: 'react-lightning/Examples/Focus/FocusRedirection', } as Meta; const Redirector = forwardRef< diff --git a/apps/storybook/src/react-lightning/examples/focus/FocusTree.stories.tsx b/apps/storybook/src/react-lightning/examples/focus/FocusTree.stories.tsx index 532d3b7e..9f240dd8 100644 --- a/apps/storybook/src/react-lightning/examples/focus/FocusTree.stories.tsx +++ b/apps/storybook/src/react-lightning/examples/focus/FocusTree.stories.tsx @@ -3,7 +3,7 @@ import type { Meta } from '@storybook/react'; import Button from '../../../components/Button'; export default { - title: '@plextv∕react-lightning/Examples/Focus/Focus Tree', + title: 'react-lightning/Examples/Focus/Focus Tree', argTypes: {}, } as Meta; diff --git a/apps/storybook/src/react-lightning/examples/focus/TrapFocus.stories.tsx b/apps/storybook/src/react-lightning/examples/focus/TrapFocus.stories.tsx index 12681fdf..6a9bda0c 100644 --- a/apps/storybook/src/react-lightning/examples/focus/TrapFocus.stories.tsx +++ b/apps/storybook/src/react-lightning/examples/focus/TrapFocus.stories.tsx @@ -7,7 +7,7 @@ import { } from '../../../components/FocusableImage'; export default { - title: '@plextv∕react-lightning/Examples/Focus/TrapFocus', + title: 'react-lightning/Examples/Focus/TrapFocus', } as Meta; const trapUpStyle: LightningElement['style'] = { diff --git a/apps/storybook/src/react-lightning/examples/render/RenderToTexture.stories.tsx b/apps/storybook/src/react-lightning/examples/render/RenderToTexture.stories.tsx index a7ffd068..79dc72c0 100644 --- a/apps/storybook/src/react-lightning/examples/render/RenderToTexture.stories.tsx +++ b/apps/storybook/src/react-lightning/examples/render/RenderToTexture.stories.tsx @@ -8,7 +8,7 @@ type Props = { const View: FC = (props) => ; export default { - title: '@plextv∕react-lightning/Examples/Render/Render To Texture', + title: 'react-lightning/Examples/Render/Render To Texture', component: View, argTypes: { rtt: { diff --git a/apps/storybook/src/react-lightning/examples/text/Text.stories.tsx b/apps/storybook/src/react-lightning/examples/text/Text.stories.tsx index 9b56d957..16935254 100644 --- a/apps/storybook/src/react-lightning/examples/text/Text.stories.tsx +++ b/apps/storybook/src/react-lightning/examples/text/Text.stories.tsx @@ -13,7 +13,7 @@ type Props = { }; export default { - title: '@plextv∕react-lightning/Examples/Text/Text Wrapping', + title: 'react-lightning/Examples/Text/Text Wrapping', argTypes: { text: { type: { name: 'string', required: false }, diff --git a/apps/storybook/src/react-native-lightning-components/layout/Column.stories.tsx b/apps/storybook/src/react-native-lightning-components/layout/Column.stories.tsx index a42df456..de4f7610 100644 --- a/apps/storybook/src/react-native-lightning-components/layout/Column.stories.tsx +++ b/apps/storybook/src/react-native-lightning-components/layout/Column.stories.tsx @@ -18,7 +18,7 @@ const ExampleBox = () => { }; export default { - title: '@plextv∕react-native-lightning-components/Layout/Column', + title: 'react-native-lightning-components/Layout/Column', component: Column, argTypes: { justifyContent: { diff --git a/apps/storybook/src/react-native-lightning-components/layout/Row.stories.tsx b/apps/storybook/src/react-native-lightning-components/layout/Row.stories.tsx index ee1484ac..ce088cd3 100644 --- a/apps/storybook/src/react-native-lightning-components/layout/Row.stories.tsx +++ b/apps/storybook/src/react-native-lightning-components/layout/Row.stories.tsx @@ -18,7 +18,7 @@ const ExampleBox = () => { }; export default { - title: '@plextv∕react-native-lightning-components/Layout/Row', + title: 'react-native-lightning-components/Layout/Row', component: Row, argTypes: { justifyContent: { diff --git a/apps/storybook/src/react-native-lightning-components/lists/FlashList.stories.tsx b/apps/storybook/src/react-native-lightning-components/lists/FlashList.stories.tsx index 3ed10972..4730ab0f 100644 --- a/apps/storybook/src/react-native-lightning-components/lists/FlashList.stories.tsx +++ b/apps/storybook/src/react-native-lightning-components/lists/FlashList.stories.tsx @@ -6,7 +6,7 @@ import { View } from 'react-native'; import ScrollItem from '../../components/ScrollItem'; export default { - title: '@plextv∕react-native-lightning-components/Lists/FlashList', + title: 'react-native-lightning-components/Lists/FlashList', component: FlashList, tags: ['reactNative'], } as Meta; diff --git a/apps/storybook/src/react-native-lightning/lists/VirtualizedList.stories.tsx b/apps/storybook/src/react-native-lightning/lists/VirtualizedList.stories.tsx index 228618f5..97059eca 100644 --- a/apps/storybook/src/react-native-lightning/lists/VirtualizedList.stories.tsx +++ b/apps/storybook/src/react-native-lightning/lists/VirtualizedList.stories.tsx @@ -4,7 +4,7 @@ import { View, VirtualizedList } from 'react-native'; import ScrollItem from '../../components/ScrollItem'; export default { - title: '@plextv∕react-native-lightning/Lists/VirtualizedList', + title: 'react-native-lightning/Lists/VirtualizedList', component: VirtualizedList, tags: ['reactNative'], } as Meta; diff --git a/packages/plugin-reanimated/README.md b/packages/plugin-reanimated/README.md new file mode 100644 index 00000000..f0179d5d --- /dev/null +++ b/packages/plugin-reanimated/README.md @@ -0,0 +1,3 @@ +## @plextv/react-lightning-plugin-reanimated + +A bare bones drop-in replacement for react-native-reanimated to support animations. \ No newline at end of file diff --git a/packages/plugin-reanimated/src/animation/AnimatedValue.ts b/packages/plugin-reanimated/src/animation/AnimatedValue.ts index 784eaddd..34458069 100644 --- a/packages/plugin-reanimated/src/animation/AnimatedValue.ts +++ b/packages/plugin-reanimated/src/animation/AnimatedValue.ts @@ -18,8 +18,7 @@ export class AnimatedValue { public type: AnimationType; public value: AnimatableValue; public lngAnimation: AnimationSettings; - - private _callback?: AnimationCallback; + public callback?: AnimationCallback; public constructor( type: TType, @@ -30,7 +29,7 @@ export class AnimatedValue { this.type = type; this.value = value; this.lngAnimation = this._getLightningAnimationSettings(config); - this._callback = callback; + this.callback = callback; } private _getLightningAnimationSettings( diff --git a/packages/plugin-reanimated/src/builders/Fade.ts b/packages/plugin-reanimated/src/builders/Fade.ts new file mode 100644 index 00000000..de571d2f --- /dev/null +++ b/packages/plugin-reanimated/src/builders/Fade.ts @@ -0,0 +1,236 @@ +import { + type EntryAnimationsValues, + type ExitAnimationsValues, + FadeIn as ReanimatedFadeIn, + FadeInDown as ReanimatedFadeInDown, + FadeInLeft as ReanimatedFadeInLeft, + FadeInRight as ReanimatedFadeInRight, + FadeInUp as ReanimatedFadeInUp, + FadeOut as ReanimatedFadeOut, + FadeOutDown as ReanimatedFadeOutDown, + FadeOutLeft as ReanimatedFadeOutLeft, + FadeOutRight as ReanimatedFadeOutRight, + FadeOutUp as ReanimatedFadeOutUp, +} from 'react-native-reanimated-original'; +import { withTiming } from '../exports/withTiming'; +import { createBuilderWrapper } from './createBuilderWrapper'; + +export const FadeIn = createBuilderWrapper( + ReanimatedFadeIn, + function (this: ReanimatedFadeIn) { + return () => ({ + animations: { + alpha: withTiming(1, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + alpha: 0.00001, + }, + callback: this.callbackV, + }); + }, +); + +export const FadeInRight = createBuilderWrapper( + ReanimatedFadeInRight, + function (this: ReanimatedFadeInRight) { + return (values: EntryAnimationsValues) => ({ + animations: { + alpha: withTiming(1, { + duration: this.durationV, + easing: this.easingV, + }), + x: withTiming(values.targetOriginX, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + alpha: 0.00001, + x: values.targetOriginX + 25, + }, + callback: this.callbackV, + }); + }, +); + +export const FadeInLeft = createBuilderWrapper( + ReanimatedFadeInLeft, + function (this: ReanimatedFadeInLeft) { + return (values: EntryAnimationsValues) => ({ + animations: { + alpha: withTiming(1, { + duration: this.durationV, + easing: this.easingV, + }), + x: withTiming(values.targetOriginX, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + alpha: 0.00001, + x: values.targetOriginX - 25, + }, + callback: this.callbackV, + }); + }, +); + +export const FadeInUp = createBuilderWrapper( + ReanimatedFadeInUp, + function (this: ReanimatedFadeInUp) { + return (values: EntryAnimationsValues) => ({ + animations: { + alpha: withTiming(1, { + duration: this.durationV, + easing: this.easingV, + }), + y: withTiming(values.targetOriginY, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + alpha: 0.00001, + y: values.targetOriginY - 25, + }, + callback: this.callbackV, + }); + }, +); + +export const FadeInDown = createBuilderWrapper( + ReanimatedFadeInDown, + function (this: ReanimatedFadeInDown) { + return (values: EntryAnimationsValues) => ({ + animations: { + alpha: withTiming(1, { + duration: this.durationV, + easing: this.easingV, + }), + y: withTiming(values.targetOriginY, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + alpha: 0.00001, + y: values.targetOriginY + 25, + }, + callback: this.callbackV, + }); + }, +); + +export const FadeOut = createBuilderWrapper( + ReanimatedFadeOut, + function (this: ReanimatedFadeOut) { + return () => ({ + animations: { + alpha: withTiming(0, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + alpha: 1, + }, + callback: this.callbackV, + }); + }, +); + +export const FadeOutRight = createBuilderWrapper( + ReanimatedFadeOutRight, + function (this: ReanimatedFadeOutRight) { + return (values: ExitAnimationsValues) => ({ + animations: { + alpha: withTiming(0, { + duration: this.durationV, + easing: this.easingV, + }), + x: withTiming(values.currentOriginX + 25, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + alpha: 1, + x: values.currentOriginX, + }, + callback: this.callbackV, + }); + }, +); + +export const FadeOutLeft = createBuilderWrapper( + ReanimatedFadeOutLeft, + function (this: ReanimatedFadeOutLeft) { + return (values: ExitAnimationsValues) => ({ + animations: { + alpha: withTiming(0, { + duration: this.durationV, + easing: this.easingV, + }), + x: withTiming(values.currentOriginX - 25, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + alpha: 1, + x: values.currentOriginX, + }, + callback: this.callbackV, + }); + }, +); + +export const FadeOutUp = createBuilderWrapper( + ReanimatedFadeOutUp, + function (this: ReanimatedFadeOutUp) { + return (values: ExitAnimationsValues) => ({ + animations: { + alpha: withTiming(0, { + duration: this.durationV, + easing: this.easingV, + }), + y: withTiming(values.currentOriginY - 25, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + alpha: 1, + y: values.currentOriginY, + }, + callback: this.callbackV, + }); + }, +); + +export const FadeOutDown = createBuilderWrapper( + ReanimatedFadeOutDown, + function (this: ReanimatedFadeOutDown) { + return (values: ExitAnimationsValues) => ({ + animations: { + alpha: withTiming(0, { + duration: this.durationV, + easing: this.easingV, + }), + y: withTiming(values.currentOriginY + 25, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + alpha: 1, + y: values.currentOriginY, + }, + callback: this.callbackV, + }); + }, +); diff --git a/packages/plugin-reanimated/src/builders/Slide.ts b/packages/plugin-reanimated/src/builders/Slide.ts new file mode 100644 index 00000000..040dde3a --- /dev/null +++ b/packages/plugin-reanimated/src/builders/Slide.ts @@ -0,0 +1,182 @@ +import { + type EntryAnimationsValues, + type ExitAnimationsValues, + SlideInDown as ReanimatedSlideInDown, + SlideInLeft as ReanimatedSlideInLeft, + SlideInRight as ReanimatedSlideInRight, + SlideInUp as ReanimatedSlideInUp, + SlideOutDown as ReanimatedSlideOutDown, + SlideOutLeft as ReanimatedSlideOutLeft, + SlideOutRight as ReanimatedSlideOutRight, + SlideOutUp as ReanimatedSlideOutUp, +} from 'react-native-reanimated-original'; +import { withTiming } from '../exports/withTiming'; +import { createBuilderWrapper } from './createBuilderWrapper'; + +export const SlideInRight = createBuilderWrapper( + ReanimatedSlideInRight, + function (this: ReanimatedSlideInRight) { + return (values: EntryAnimationsValues) => ({ + animations: { + x: withTiming(values.targetOriginX, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + x: values.targetOriginX + values.windowWidth, + }, + callback: this.callbackV, + }); + }, +); + +export const SlideInLeft = createBuilderWrapper( + ReanimatedSlideInLeft, + function (this: ReanimatedSlideInLeft) { + return (values: EntryAnimationsValues) => ({ + animations: { + x: withTiming(values.targetOriginX, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + x: values.targetOriginX - values.windowWidth, + }, + callback: this.callbackV, + }); + }, +); + +export const SlideInUp = createBuilderWrapper( + ReanimatedSlideInUp, + function (this: ReanimatedSlideInUp) { + return (values: EntryAnimationsValues) => ({ + animations: { + y: withTiming(values.targetOriginY, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + y: -values.windowHeight, + }, + callback: this.callbackV, + }); + }, +); + +export const SlideInDown = createBuilderWrapper( + ReanimatedSlideInDown, + function (this: ReanimatedSlideInDown) { + return (values: EntryAnimationsValues) => ({ + animations: { + y: withTiming(values.targetOriginY, { + duration: this.durationV, + easing: this.easingV, + }), + }, + initialValues: { + y: values.targetOriginY + values.windowHeight, + }, + callback: this.callbackV, + }); + }, +); + +export const SlideOutRight = createBuilderWrapper( + ReanimatedSlideOutRight, + function (this: ReanimatedSlideOutRight) { + return (values: ExitAnimationsValues) => ({ + animations: { + x: withTiming( + Math.max( + values.currentOriginX + values.windowWidth, + values.windowWidth, + ), + { + duration: this.durationV, + easing: this.easingV, + }, + ), + }, + initialValues: { + x: values.currentOriginX, + }, + callback: this.callbackV, + }); + }, +); + +export const SlideOutLeft = createBuilderWrapper( + ReanimatedSlideOutLeft, + function (this: ReanimatedSlideOutLeft) { + return (values: ExitAnimationsValues) => ({ + animations: { + x: withTiming( + Math.min( + values.currentOriginX - values.windowWidth, + -values.windowWidth, + ), + { + duration: this.durationV, + easing: this.easingV, + }, + ), + }, + initialValues: { + x: values.currentOriginX, + }, + callback: this.callbackV, + }); + }, +); + +export const SlideOutUp = createBuilderWrapper( + ReanimatedSlideOutUp, + function (this: ReanimatedSlideOutUp) { + return (values: ExitAnimationsValues) => ({ + animations: { + y: withTiming( + Math.min( + values.currentOriginY - values.windowHeight, + -values.windowHeight, + ), + { + duration: this.durationV, + easing: this.easingV, + }, + ), + }, + initialValues: { + y: values.currentOriginY, + }, + callback: this.callbackV, + }); + }, +); + +export const SlideOutDown = createBuilderWrapper( + ReanimatedSlideOutDown, + function (this: ReanimatedSlideOutDown) { + return (values: ExitAnimationsValues) => ({ + animations: { + y: withTiming( + Math.max( + values.currentOriginY + values.windowHeight, + values.windowHeight, + ), + { + duration: this.durationV, + easing: this.easingV, + }, + ), + }, + initialValues: { + y: values.currentOriginY, + }, + callback: this.callbackV, + }); + }, +); diff --git a/packages/plugin-reanimated/src/builders/createBuilderWrapper.ts b/packages/plugin-reanimated/src/builders/createBuilderWrapper.ts new file mode 100644 index 00000000..daee5b5b --- /dev/null +++ b/packages/plugin-reanimated/src/builders/createBuilderWrapper.ts @@ -0,0 +1,36 @@ +import type { BaseAnimationBuilder } from 'react-native-reanimated-original'; +import type { Constructor } from 'type-fest'; + +/** + * Creates a wrapper around a BaseAnimationBuilder class to customize its build behavior. + * @param OriginalClass The original BaseAnimationBuilder class to wrap. + * @param buildFn A function that defines the custom build behavior. + * @returns A new class that extends the original class with the custom build behavior. + */ +export function createBuilderWrapper< + T extends BaseAnimationBuilder, + TClass extends Constructor, +>( + OriginalClass: TClass, + buildFn: (this: T, originalBuildFn: T['build']) => ReturnType, +) { + class LightningAnimationBuilder extends (OriginalClass as Constructor) { + private _buildFn: T['build']; + + // Override the static createInstance method to return an instance of this wrapper class + static createInstance() { + return new LightningAnimationBuilder(); + } + + constructor() { + super(); + + this._buildFn = this.build.bind(this); + + // Override the build method to add custom behavior + this.build = () => buildFn.call(this as unknown as T, this._buildFn); + } + } + + return LightningAnimationBuilder as unknown as TClass; +} diff --git a/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx b/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx index fda226e8..3ee01618 100644 --- a/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx +++ b/packages/plugin-reanimated/src/exports/createAnimatedComponent.tsx @@ -1,85 +1,341 @@ import type { LightningElement, - LightningViewElement, + LightningElementProps, + LightningViewElementStyle, + Rect, + RendererNode, } from '@plextv/react-lightning'; -import { forwardRef, useEffect, useMemo, useRef } from 'react'; +import { + Component, + type ComponentType, + type ForwardedRef, + forwardRef, +} from 'react'; +import type { NativeMethods, StyleProp, ViewStyle } from 'react-native'; import type { - Falsy, - RecursiveArray, - RegisteredStyle, - ViewProps, - ViewStyle, -} from 'react-native'; + BaseAnimationBuilder, + LayoutAnimationFunction, +} from 'react-native-reanimated-original'; import { isAnimatedStyle } from '../isAnimatedStyle'; -import { useMergeRefs } from '../mergeRefs'; import type { AnimatedStyle } from '../types/AnimatedStyle'; +import type { ReanimatedAnimation } from '../types/ReanimatedAnimation'; +import { toLightningAnimationAndStyles } from '../utils/toLightningAnimationAndStyles'; -function groupStyles( - styleArray: RecursiveArray>, +type NativeLightningElement = NativeMethods & LightningElement; + +type AnimatedProps = T & + Pick & { + style?: StyleProp; + forwardedRef?: ForwardedRef; + layout?: ReanimatedAnimation; + entering?: ReanimatedAnimation; + exiting?: ReanimatedAnimation; + }; + +function flattenStyles( + style: StyleProp, + animatedStyles: Set, + flattenedStyles: Partial, +): void; +function flattenStyles( + style: StyleProp, +): [Set, Partial]; +function flattenStyles( + style: StyleProp, + animatedStyles: Set = new Set(), + flattenedStyles: Partial = {}, ) { - return styleArray.reduce<[AnimatedStyle[], Partial]>( - (acc, s) => { - if (isAnimatedStyle(s)) { - acc[0].push(s); - } else if (s != null && s !== false) { - if (Array.isArray(s)) { - const [animatedStyles, flatStyles] = groupStyles(s); - - acc[0].push(...animatedStyles); - Object.assign(acc[1], flatStyles); - } else { - Object.assign(acc[1], s); - } + if (!style) { + return [animatedStyles, flattenedStyles]; + } + + if (Array.isArray(style)) { + for (let i = 0; i < style.length; i++) { + const s = style[i]; + + if (s != null && s !== false) { + flattenStyles(s as StyleProp, animatedStyles, flattenedStyles); } + } + } else if (isAnimatedStyle(style)) { + animatedStyles.add(style); + } else if (style != null && style !== false) { + Object.assign(flattenedStyles, style); + } - return acc; - }, - [[], {}], + return [animatedStyles, flattenedStyles]; +} + +function isAnimationBuilder( + layoutAnimationOrBuilder: ReanimatedAnimation, +): layoutAnimationOrBuilder is BaseAnimationBuilder { + return ( + !!layoutAnimationOrBuilder && + 'build' in layoutAnimationOrBuilder && + typeof layoutAnimationOrBuilder.build === 'function' ); } -function useNormalizedStyles( - style: RecursiveArray>, +function getBuilder(layoutAnimationOrBuilder?: ReanimatedAnimation) { + if (!layoutAnimationOrBuilder) { + return null; + } + + if (isAnimationBuilder(layoutAnimationOrBuilder)) { + return layoutAnimationOrBuilder.build(); + } else if (typeof layoutAnimationOrBuilder === 'function') { + return layoutAnimationOrBuilder; + } else if (import.meta.env.DEV) { + console.warn( + 'This animation is not supported in React Lightning: ', + layoutAnimationOrBuilder, + ); + } + + return null; +} + +function buildTransitions( + builder: LayoutAnimationFunction | null, + layout: (Rect & { globalX: number; globalY: number }) | null, ) { - const [animatedStyles, flatStyles] = useMemo( - () => groupStyles(style), - [style], - ); - const ref = useRef(null); + if (!builder) { + return null; + } - useEffect(() => { - if (!ref.current || !animatedStyles) { - return; + const { x, y, width, height, globalX, globalY } = layout || { + x: 0, + y: 0, + globalX: 0, + globalY: 0, + width: 0, + height: 0, + }; + + const animation = builder({ + targetOriginX: x, + targetOriginY: y, + targetWidth: width, + targetHeight: height, + targetGlobalOriginX: globalX, + targetGlobalOriginY: globalY, + targetBorderRadius: 0, + windowWidth: 1920, + windowHeight: 1080, + currentOriginX: x, + currentOriginY: y, + currentWidth: width, + currentHeight: height, + currentGlobalOriginX: globalX, + currentGlobalOriginY: globalY, + currentBorderRadius: 0, + }); + + if (!animation) { + return null; + } + + // return toLightningAnimationAndStyles(animation.animations); + return animation; +} + +export function createAnimatedComponent( + ComponentToAnimate: ComponentType>, +) { + class AnimatedComponent extends Component> { + static displayName = + `LightningAnimated(${ComponentToAnimate.displayName || ComponentToAnimate.name || 'Component'})`; + + private _ref: NativeLightningElement | null = null; + private _animatedStyles: Set = new Set(); + private _styles: Partial | null = null; + private _cachedBuilders = new WeakMap< + ReanimatedAnimation, + LayoutAnimationFunction | null + >(); + + constructor(props: AnimatedProps) { + super(props); + + this._transformStyles(); + } + + componentDidMount(): void { + this._runAnimation(this._getCachedBuilder(this.props.entering)); + } + + private _getCachedBuilder(animation?: ReanimatedAnimation) { + if (!animation) { + return null; + } + + if (!this._cachedBuilders.has(animation)) { + this._cachedBuilders.set(animation, getBuilder(animation)); + } + + return this._cachedBuilders.get(animation) || null; + } + + componentDidUpdate(prevProps: Readonly>): void { + const styleChanged = this.props.style !== prevProps.style; + const layoutChanged = this.props.layout !== prevProps.layout; + + if (styleChanged) { + this._transformStyles(); + } + + if (styleChanged || layoutChanged) { + this._runAnimation(this._getCachedBuilder(this.props.layout)); + } } - for (const animatedStyle of animatedStyles) { - if (ref.current) { - animatedStyle.viewsRef?.add(ref.current); + componentWillUnmount(): void { + if (!this.props.exiting || !this._ref) { + return; } + + this._ref.deferNodeRemoval = (destroy) => { + this._runAnimation(this._getCachedBuilder(this.props.exiting), destroy); + }; + } + + render() { + return ( + + ); } - return () => { - for (const animatedStyle of animatedStyles) { - if (ref.current) { - animatedStyle.viewsRef?.delete(ref.current); + _resolveComponentRef = (ref: NativeLightningElement | null) => { + const componentRef = ref as NativeLightningElement & { + getAnimatableRef?: () => NativeLightningElement; + }; + + // Component can specify ref which should be animated when animated version of the component is created. + // Otherwise, we animate the component itself. + if (componentRef?.getAnimatableRef) { + return componentRef.getAnimatableRef(); + } + + return componentRef; + }; + + _setRefs = (ref: NativeLightningElement | null) => { + if (!ref || this._ref === ref) { + return; + } + + const forwardedRef = this.props.forwardedRef; + const newRef = this._resolveComponentRef(ref); + + if (typeof forwardedRef === 'function') { + // Handle function-based refs. String-based refs are handled as functions. + forwardedRef(newRef); + } else if (typeof forwardedRef === 'object' && forwardedRef != null) { + // Handle createRef-based refs + forwardedRef.current = newRef; + } + + for (const animatedStyle of this._animatedStyles) { + // Remove old refs and add our new ref to our animated styles + if (this._ref) { + animatedStyle.viewsRef.delete(this._ref); } + + animatedStyle.viewsRef.add(newRef); } + + this._ref = newRef; }; - }, [animatedStyles]); - return [flatStyles, ref]; -} + _transformStyles() { + const [newAnimatedStyles, flattenedStyles] = flattenStyles( + this.props.style, + ); -export function createAnimatedComponent(Component: JSX.ElementType) { - return forwardRef((props, forwardedRef) => { - const { style, ...otherProps } = props; - const styleArray = useMemo( - () => (Array.isArray(style) ? style : [style]), - [style], - ); - const [normalizedStyles, ref] = useNormalizedStyles(styleArray); - const refs = useMergeRefs(forwardedRef, ref); + if (this._ref) { + // Remove refs for any animated styles that were removed + for (const oldAnimatedStyle of this._animatedStyles) { + oldAnimatedStyle.viewsRef.delete(this._ref); + } - return ; - }); + for (const newAnimatedStyle of newAnimatedStyles) { + newAnimatedStyle.viewsRef.add(this._ref); + } + } + + this._animatedStyles = newAnimatedStyles; + this._styles = flattenedStyles; + } + + private _runAnimation( + builder: LayoutAnimationFunction | null, + callback?: () => void, + ) { + if (!this._ref || !builder) { + callback?.(); + return; + } + + const el = this._ref; + + this._ref.measure((x, y, width, height) => { + const absRect = this._ref?.getBoundingClientRect(); + + const layoutAnimation = buildTransitions(builder, { + x, + y, + globalX: absRect?.left || 0, + globalY: absRect?.top || 0, + width, + height, + }); + + if (!layoutAnimation) { + callback?.(); + return; + } + + const lightningAnimation = toLightningAnimationAndStyles( + layoutAnimation.animations, + ); + + el.once('animationFinished', () => { + if (layoutAnimation.callback) { + layoutAnimation.callback(true); + } + callback?.(); + }); + + for (const [key, value] of Object.entries( + layoutAnimation.initialValues, + )) { + el.setNodeProp( + key as keyof RendererNode, + value, + false, + ); + } + + el?.setProps({ + style: lightningAnimation.style as LightningViewElementStyle, + transition: lightningAnimation.transition, + }); + }); + } + } + + return forwardRef>( + (props, forwardedRef) => { + return ( + )} + forwardedRef={forwardedRef} + /> + ); + }, + ); } diff --git a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts index ee04fca6..8ccb72a5 100644 --- a/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts +++ b/packages/plugin-reanimated/src/exports/useAnimatedStyle.ts @@ -1,65 +1,26 @@ import type { - Animatable, LightningElement, LightningElementStyle, } from '@plextv/react-lightning'; -import { convertCSSTransformToLightning } from '@plextv/react-lightning-plugin-css-transform'; -import type { Transform } from '@plextv/react-lightning-plugin-flexbox'; import type { DependencyList } from 'react'; import { useCallback, useEffect, useRef } from 'react'; import type { Mutable } from 'react-native-reanimated/lib/typescript/commonTypes'; import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/commonTypes'; import type { useAnimatedStyle as useAnimatedStyleRN } from 'react-native-reanimated-original'; -import { AnimatedValue } from '../animation/AnimatedValue'; +import type { AnimatedObject } from '../types/AnimatedObject'; import type { AnimatedStyle } from '../types/AnimatedStyle'; -import { getTransitionProperty } from '../utils/getTransitionProperty'; +import { toLightningAnimationAndStyles } from '../utils/toLightningAnimationAndStyles'; type UseAnimatedStyleFn = ( ...args: Parameters ) => AnimatedStyle; -type AnimatedObject = { - [K in keyof T]: T[K] | AnimatedValue; -}; - -type AnimatableTransform = Record< - keyof Transform, - number | string | AnimatedValue ->; - -type LightningTransition = NonNullable< - Animatable['transition'] ->; - -type DefaultStyleWithLightningTransform = Omit & { - transform?: Transform; -}; - function computeAndSetStyles( updater: () => AnimatedObject, views: Set, ): void { const computedStyle = updater(); - const style: DefaultStyleWithLightningTransform = {}; - const transition: LightningTransition = {}; - - for (const key in computedStyle) { - const prop = key as keyof AnimatedObject; - const value = computedStyle[prop]; - - // If the transform is a string, just pass thru for the css plugin to - // handle, though this should not happen since you normally wouldn't set a - // transform to a string from react-reanimated - if (prop === 'transform' && value && typeof value !== 'string') { - applyTransforms( - style, - transition, - value as unknown as AnimatableTransform | AnimatableTransform[], - ); - } else { - applyStyle(style, transition, prop, value); - } - } + const { transition, style } = toLightningAnimationAndStyles(computedStyle); for (const view of views) { view.setProps({ @@ -72,80 +33,7 @@ function computeAndSetStyles( } } -function applyTransforms( - style: DefaultStyleWithLightningTransform, - transition: LightningTransition, - animatableTransforms: AnimatableTransform | AnimatableTransform[], -) { - if (Array.isArray(animatableTransforms)) { - for (const animatableTransform of animatableTransforms) { - applyTransform(style, transition, animatableTransform); - } - } else { - applyTransform(style, transition, animatableTransforms); - } -} - -function applyTransform( - style: DefaultStyleWithLightningTransform, - transition: LightningTransition, - animatableTransform: AnimatableTransform, -) { - for (const [key, value] of Object.entries(animatableTransform)) { - const actualValue = value instanceof AnimatedValue ? value.value : value; - - switch (key) { - case 'translate': - case 'translateX': - case 'translateY': - // Using our lightning style transform instead of RN - style.transform = { - ...style.transform, - ...convertCSSTransformToLightning(key, actualValue), - }; - - if (value instanceof AnimatedValue) { - if (key === 'translate' || key === 'translateX') { - transition.x = value.lngAnimation; - } - - if (key === 'translate' || key === 'translateY') { - transition.y = value.lngAnimation; - } - } - - break; - case 'scale': - applyStyle(style, transition, 'scaleX', value as AnimatedValue); - applyStyle(style, transition, 'scaleY', value as AnimatedValue); - break; - case 'rotate': - applyStyle(style, transition, 'rotation', value as AnimatedValue); - break; - default: - applyStyle(style, transition, key as keyof DefaultStyle, value); - break; - } - } -} - -function applyStyle( - style: DefaultStyleWithLightningTransform, - transition: LightningTransition, - prop: K, - value: AnimatedObject[K] | (AnimatedObject[K] & string), -) { - if (value instanceof AnimatedValue) { - const transitionProp = getTransitionProperty(prop as keyof DefaultStyle); - - // biome-ignore lint/suspicious/noExplicitAny: Just passing through - (style as any)[prop] = value.value as T[K]; - transition[transitionProp] = value.lngAnimation; - } else { - // biome-ignore lint/suspicious/noExplicitAny: Just passing through - (style as any)[prop] = value; - } -} +let idCount = 0; export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { const viewsRef = useRef(new Set()); @@ -166,8 +54,7 @@ export const useAnimatedStyle: UseAnimatedStyleFn = (updater, dependencies) => { }, [updater]); useEffect(() => { - // Generate a random id - const id = Math.floor(Math.random() * 1000000); + const id = idCount++; for (const dep of inputs) { if (dep && typeof dep === 'object' && 'addListener' in dep) { diff --git a/packages/plugin-reanimated/src/index.ts b/packages/plugin-reanimated/src/index.ts index b24e8801..b8b33da8 100644 --- a/packages/plugin-reanimated/src/index.ts +++ b/packages/plugin-reanimated/src/index.ts @@ -7,6 +7,7 @@ import { View } from './exports/View'; const Noop = () => null; +export type * from 'react-native-reanimated-original'; export * from 'react-native-reanimated-original'; // Overrides @@ -21,8 +22,37 @@ export default { View, }; +export { + FadeIn, + FadeInDown, + FadeInLeft, + FadeInRight, + FadeInUp, + FadeOut, + FadeOutDown, + FadeOutLeft, + FadeOutRight, + FadeOutUp, +} from './builders/Fade'; + +export { + SlideInDown, + SlideInLeft, + SlideInRight, + SlideInUp, + SlideOutDown, + SlideOutLeft, + SlideOutRight, + SlideOutUp, +} from './builders/Slide'; + export { useAnimatedStyle } from './exports/useAnimatedStyle'; export { useComposedEventHandler } from './exports/useComposedEventHandler'; export { withRepeat } from './exports/withRepeat'; export { withSpring } from './exports/withSpring'; export { withTiming } from './exports/withTiming'; + +export type { AnimatedObject } from './types/AnimatedObject'; +export type { AnimatedStyle } from './types/AnimatedStyle'; +export type { AnimationType } from './types/AnimationType'; +export type { ReanimatedAnimation } from './types/ReanimatedAnimation'; diff --git a/packages/plugin-reanimated/src/types/AnimatedObject.ts b/packages/plugin-reanimated/src/types/AnimatedObject.ts new file mode 100644 index 00000000..30b2c5be --- /dev/null +++ b/packages/plugin-reanimated/src/types/AnimatedObject.ts @@ -0,0 +1,5 @@ +import type { AnimatedValue } from '../animation/AnimatedValue'; + +export type AnimatedObject = { + [K in keyof T]: T[K] | AnimatedValue; +}; diff --git a/packages/plugin-reanimated/src/types/ReanimatedAnimation.ts b/packages/plugin-reanimated/src/types/ReanimatedAnimation.ts new file mode 100644 index 00000000..6c161f60 --- /dev/null +++ b/packages/plugin-reanimated/src/types/ReanimatedAnimation.ts @@ -0,0 +1,10 @@ +import type { + ILayoutAnimationBuilder, + Keyframe, + LayoutAnimationFunction, +} from 'react-native-reanimated-original'; + +export type ReanimatedAnimation = + | ILayoutAnimationBuilder + | LayoutAnimationFunction + | Keyframe; diff --git a/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts b/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts new file mode 100644 index 00000000..13873cfc --- /dev/null +++ b/packages/plugin-reanimated/src/utils/toLightningAnimationAndStyles.ts @@ -0,0 +1,135 @@ +import type { + Animatable, + LightningElementStyle, +} from '@plextv/react-lightning'; +import { convertCSSTransformToLightning } from '@plextv/react-lightning-plugin-css-transform'; +import type { Transform } from '@plextv/react-lightning-plugin-flexbox'; +import type { DefaultStyle } from 'react-native-reanimated/lib/typescript/hook/commonTypes'; +import { AnimatedValue } from '../animation/AnimatedValue'; +import type { AnimatedObject } from '../types/AnimatedObject'; +import { getTransitionProperty } from '../utils/getTransitionProperty'; + +type AnimatableTransform = Record< + keyof Transform, + number | string | AnimatedValue +>; + +type LightningTransition = NonNullable< + Animatable['transition'] +>; + +type DefaultStyleWithLightningTransform = Omit & { + transform?: Transform; +}; + +function applyTransforms( + style: DefaultStyleWithLightningTransform, + transition: LightningTransition, + animatableTransforms: AnimatableTransform | AnimatableTransform[], +) { + if (Array.isArray(animatableTransforms)) { + for (const animatableTransform of animatableTransforms) { + applyTransform(style, transition, animatableTransform); + } + } else { + applyTransform(style, transition, animatableTransforms); + } +} + +function applyTransform( + style: DefaultStyleWithLightningTransform, + transition: LightningTransition, + animatableTransform: AnimatableTransform, +) { + for (const [key, value] of Object.entries(animatableTransform)) { + const actualValue = value instanceof AnimatedValue ? value.value : value; + + switch (key) { + case 'translate': + case 'translateX': + case 'translateY': + // Using our lightning style transform instead of RN + style.transform = { + ...(style.transform ?? {}), + ...convertCSSTransformToLightning(key, actualValue), + }; + + if (value instanceof AnimatedValue) { + if (key === 'translate' || key === 'translateX') { + transition.x = value.lngAnimation; + } + + if (key === 'translate' || key === 'translateY') { + transition.y = value.lngAnimation; + } + } + + break; + case 'scale': + case 'scaleX': + case 'scaleY': + if (key === 'scale' || key === 'scaleX') { + applyStyle(style, transition, 'scaleX', value as AnimatedValue); + } + + if (key === 'scale' || key === 'scaleY') { + applyStyle(style, transition, 'scaleY', value as AnimatedValue); + } + break; + case 'rotate': + applyStyle(style, transition, 'rotation', value as AnimatedValue); + break; + default: + applyStyle(style, transition, key as keyof DefaultStyle, value); + break; + } + } +} + +function applyStyle( + style: DefaultStyleWithLightningTransform, + transition: LightningTransition, + prop: K, + value: AnimatedObject[K] | (AnimatedObject[K] & string), +) { + if (value instanceof AnimatedValue) { + const transitionProp = getTransitionProperty(prop as keyof DefaultStyle); + + // biome-ignore lint/suspicious/noExplicitAny: Just passing through + (style as any)[transitionProp] = value.value as T[K]; + transition[transitionProp] = value.lngAnimation; + } else { + // biome-ignore lint/suspicious/noExplicitAny: Just passing through + (style as any)[prop] = value; + } +} + +export function toLightningAnimationAndStyles( + computedStyle: AnimatedObject, +): { + transition: LightningTransition; + style: DefaultStyleWithLightningTransform; +} { + const style: DefaultStyleWithLightningTransform = {}; + const transition: LightningTransition = {}; + + for (const key in computedStyle) { + const prop = key as keyof AnimatedObject; + const value = computedStyle[prop]; + + // If the transform is a string, just pass thru for the css plugin to + // handle, though this should not happen since you normally wouldn't set a + // transform to a string from react-reanimated + if (prop === 'transform' && value && typeof value !== 'string') { + applyTransforms( + style, + transition, + value as unknown as AnimatableTransform | AnimatableTransform[], + ); + } else { + applyStyle(style, transition, prop, value); + } + } + + return { transition, style }; +} diff --git a/packages/react-lightning/src/element/LightningViewElement.ts b/packages/react-lightning/src/element/LightningViewElement.ts index 21783fac..16b12e76 100644 --- a/packages/react-lightning/src/element/LightningViewElement.ts +++ b/packages/react-lightning/src/element/LightningViewElement.ts @@ -98,6 +98,9 @@ export class LightningViewElement< private _visible = true; private _hasStagedUpdates = false; private _eventEmitter = new EventEmitter(); + private _deferTarget: LightningElement | null = null; + private _deferNodeRemovalHandler: ((destroy: () => void) => void) | null = + null; public get visible(): boolean { return this._visible; @@ -174,13 +177,39 @@ export class LightningViewElement< public set style(_style: TStyleProps) { // If you set the style prop, it creates a new CSSStyleDeclaration object. // We'll create a new proxy and reset the styles. - this._styleProxy = new Proxy({}, this._createStyleProxyHandler()); + this._styleProxy = new Proxy({}, this._styleProxyHandler); this.setProps({ style: {}, } as TProps); } + /** + * If a handler here is set, the lightning node will not be immediately + * removed. The destroy function passed to the handler must be called to + * complete cleanup. This is to allow for things like animations to complete + * before the node is removed. + */ + public set deferNodeRemoval(handler: ((destroy: () => void) => void) | null) { + this._deferNodeRemovalHandler = handler; + + this.deferTarget = handler ? this : null; + } + + public set deferTarget(value: LightningElement | null) { + this._deferTarget?.off('deferredDestroyComplete', this._destroyFinalize); + this._deferTarget = value; + this._deferTarget?.once('deferredDestroyComplete', this._destroyFinalize); + + // Optimize child iteration + for (let i = 0; i < this.children.length; i++) { + const child = this.children[i]; + if (child) { + child.deferTarget = value; + } + } + } + public get isTextElement() { return false; } @@ -237,7 +266,7 @@ export class LightningViewElement< this._styleProxy = new Proxy( this.props.style ?? {}, - this._createStyleProxyHandler(), + this._styleProxyHandler, ); if (import.meta.env.DEV) { @@ -267,10 +296,16 @@ export class LightningViewElement< this.children[i]?.destroy(); } - this.parent = null; + this.parent?.removeChild(this); this.children.length = 0; // More efficient than reassigning - this._renderer.destroyNode(this.node); + if (this._deferNodeRemovalHandler) { + this._deferNodeRemovalHandler(() => { + this.emit('deferredDestroyComplete'); + }); + } else if (!this._deferTarget) { + this._renderer.destroyNode(this.node); + } delete LightningViewElement.allElements[this.id]; @@ -281,18 +316,19 @@ export class LightningViewElement< ...args: Parameters['on']> ): (() => void) => { this._eventEmitter.on(...args); - return () => this._eventEmitter.off(...args); }; public once = ( ...args: Parameters['once']> ): (() => void) => { this._eventEmitter.once(...args); - return () => this._eventEmitter.off(...args); }; - public off = this._eventEmitter.off.bind(this._eventEmitter); - public emit = this._eventEmitter.emit.bind(this._eventEmitter); + + public off: IEventEmitter['off'] = (...args) => + this._eventEmitter.off(...args); + public emit: IEventEmitter['emit'] = (...args) => + this._eventEmitter.emit(...args); public setLightningNode(node: RendererNode) { const oldNode = this.node; @@ -305,8 +341,11 @@ export class LightningViewElement< this.node = node; - for (const c of this.children) { - c.node.parent = node; + for (let i = 0; i < this.children.length; i++) { + const child = this.children[i]; + if (child) { + child.node.parent = node; + } } oldNode.destroy(); @@ -334,6 +373,10 @@ export class LightningViewElement< child.parent = this; + if (this._deferTarget) { + child.deferTarget = this._deferTarget; + } + this._eventEmitter.emit('childAdded', child, index); } @@ -344,7 +387,9 @@ export class LightningViewElement< this.children.splice(index, 1); } - child.node.parent = null; + if (!child._deferTarget) { + child.node.parent = null; + } this._eventEmitter.emit('childRemoved', child, index); } @@ -489,9 +534,10 @@ export class LightningViewElement< if (this._visible !== prevVisible) { this._eventEmitter.emit('visibilityChanged', this._visible); - this.children.forEach((child) => { - child.recalculateVisibility(); - }); + // Optimize child iteration - use traditional for loop for better performance + for (let i = 0; i < this.children.length; i++) { + this.children[i]?.recalculateVisibility(); + } } const currentFocusable = this.focusable; @@ -526,6 +572,12 @@ export class LightningViewElement< return `${this.constructor.name} id=${this.id}${this.focusable ? ' focusable' : ''}${this.visible ? ' visible' : ''}${expanded ? ` props=${JSON.stringify(this.props)}` : ''}`; } + private _destroyFinalize = () => { + this._deferTarget?.off('deferredDestroyComplete', this._destroyFinalize); + this.node.parent = null; + this._renderer.destroyNode(this.node); + }; + // Don't pass down the `data` prop to the lightning node. private _createNode({ data: _data, @@ -591,7 +643,11 @@ export class LightningViewElement< this.recalculateVisibility(); } - if (payload.style && Object.keys(payload.style).length) { + // Check for style changes before computing Object.keys() + const hasStyleChanges = + payload.style && Object.keys(payload.style).length > 0; + + if (hasStyleChanges) { this._eventEmitter.emit( 'stylesChanged', this.props.style as Partial, @@ -729,20 +785,16 @@ export class LightningViewElement< const finalStyle: Partial = {}; if (style !== undefined && style !== null) { - const styleEntries = Object.entries(style); - - for (let i = 0; i < styleEntries.length; i++) { - const [key, value] = styleEntries[i] as [ - keyof TStyleProps, - TStyleProps[keyof TStyleProps], - ]; + // Optimize by using for...in loop instead of Object.entries() + for (const key in style) { + const value = style[key as keyof TStyleProps]; if (value == null) { continue; } - if (!initial && this.props.transition?.[key]) { - this.animateStyle(key, value); + if (!initial && this.props.transition?.[key as keyof TStyleProps]) { + this.animateStyle(key as keyof TStyleProps, value); } else if (initial && key === 'initialDimensions') { const rect = value as NonNullable; @@ -824,37 +876,34 @@ export class LightningViewElement< } // Setting any styling applied to the style attribute on an element - private _createStyleProxyHandler = (): ProxyHandler> => { - return { - get: (target, prop) => { - if (prop === AllStyleProps) { - return target; - } + private _styleProxyHandler: ProxyHandler> = { + get: (target, prop) => { + if (prop === AllStyleProps) { + return target; + } - return ( - this._stagedUpdates?.style?.[ - prop as keyof LightningViewElementStyle - ] ?? target[prop as keyof TStyleProps] - ); - }, - set: (target, prop, value) => { - const key = prop as keyof TStyleProps; + return ( + this._stagedUpdates?.style?.[prop as keyof LightningViewElementStyle] ?? + target[prop as keyof TStyleProps] + ); + }, + set: (target, prop, value) => { + const key = prop as keyof TStyleProps; - if (this.style[key] === value) { - return true; - } + if (this.style[key] === value) { + return true; + } - target[key] = value; + target[key] = value; - this.setProps({ - style: { - [key]: value, - }, - } as Partial); + this.setProps({ + style: { + [key]: value, + }, + } as Partial); - return true; - }, - }; + return true; + }, }; private _transformProps(props: Partial) { diff --git a/packages/react-lightning/src/render/createHostConfig.ts b/packages/react-lightning/src/render/createHostConfig.ts index 89aed3f6..4acf2366 100644 --- a/packages/react-lightning/src/render/createHostConfig.ts +++ b/packages/react-lightning/src/render/createHostConfig.ts @@ -162,7 +162,6 @@ export function createHostConfig( removeChild(parentInstance, child) { if (child) { parentInstance.removeChild(child); - child.destroy(); } }, @@ -170,7 +169,9 @@ export function createHostConfig( removeChildFromContainer() {}, - detachDeletedInstance() {}, + detachDeletedInstance(instance) { + instance.destroy(); + }, commitUpdate(instance, updatePayload) { if (Object.keys(updatePayload).length === 0) { diff --git a/packages/react-lightning/src/types/LightningElementEvents.ts b/packages/react-lightning/src/types/LightningElementEvents.ts index 536aba49..e6a3ebaf 100644 --- a/packages/react-lightning/src/types/LightningElementEvents.ts +++ b/packages/react-lightning/src/types/LightningElementEvents.ts @@ -12,6 +12,10 @@ import type { LightningElementStyle } from './Styles'; export interface LightningElementEvents extends FocusEvents { initialized: () => void; destroy: () => void; + /** + * Fired when the lightning node has been destroyed after a deferred removal. + */ + deferredDestroyComplete: () => void; childAdded: (child: LightningElement, index: number) => void; childRemoved: (child: LightningElement, index: number) => void; beforeRender: () => void; diff --git a/packages/react-lightning/src/utils/findClosestElement.spec.ts b/packages/react-lightning/src/utils/findClosestElement.spec.ts index 8e4a9986..a5f9ad20 100644 --- a/packages/react-lightning/src/utils/findClosestElement.spec.ts +++ b/packages/react-lightning/src/utils/findClosestElement.spec.ts @@ -296,8 +296,8 @@ suite('findClosestElement', () => { */ const elements = createLayout(500, 500, [ { height: 48, width: 243, x: 160, y: 24 }, - { height: 96, width: 820, x: 160, y: 88 }, - { height: 64, width: 544, x: 160, y: 264 }, + { height: 96, width: 450, x: 160, y: 88 }, + { height: 64, width: 150, x: 160, y: 264 }, ]); const tests: TestCases = [ [1, Direction.Up, null], diff --git a/packages/react-lightning/src/utils/findClosestElement.ts b/packages/react-lightning/src/utils/findClosestElement.ts index 5026f691..0b8cc05c 100644 --- a/packages/react-lightning/src/utils/findClosestElement.ts +++ b/packages/react-lightning/src/utils/findClosestElement.ts @@ -102,29 +102,23 @@ function calculateShortestDistance( ): number | null { const euclidean = getDistance(direction, source, target); const displacement = getDisplacement(direction, source, target); - const alignment = getAlignment(direction, source, target); + const overlap = getOverlap(direction, source, target); + const alignment = getAlignment(direction, source, overlap); if (euclidean === null) { return null; } - return ( - euclidean + - displacement - - alignment - - Math.sqrt(getOverlap(direction, source, target)) - ); + return euclidean + displacement - alignment - Math.sqrt(overlap); } function getAlignment( direction: Direction, source: Dimensions, - target: Dimensions, + overlap: number, ): number { const isHorizontal = direction & Direction.Horizontal; - const bias = - getOverlap(direction, source, target) / - (isHorizontal ? source.width : source.height); + const bias = overlap / (isHorizontal ? source.width : source.height); return bias * 5; } @@ -139,7 +133,7 @@ function getDisplacement( const bias = isHorizontal ? h1 / 2 : w1 / 2; const weight = isHorizontal ? 30 : 2; - return (distance + bias) * weight; + return Math.abs((distance + bias) * weight); } export function getOverlap( @@ -164,7 +158,7 @@ export function getOverlap( break; } - return length; + return Math.abs(length); } function isOverlap( diff --git a/packages/react-native-lightning/src/exports/Button.tsx b/packages/react-native-lightning/src/exports/Button.tsx index e385463d..ecb8b636 100644 --- a/packages/react-native-lightning/src/exports/Button.tsx +++ b/packages/react-native-lightning/src/exports/Button.tsx @@ -1,44 +1,60 @@ -import type { LightningViewElement } from '@plextv/react-lightning'; -import { useFocus } from '@plextv/react-lightning'; -import type { RefAttributes } from 'react'; -import type { ButtonProps as RNButtonProps, ViewStyle } from 'react-native'; +import { + type LightningViewElement, + useCombinedRef, + useFocus, +} from '@plextv/react-lightning'; +import { forwardRef, type RefAttributes } from 'react'; +import type { + ButtonProps as RNButtonProps, + StyleProp, + ViewStyle, +} from 'react-native'; import { Pressable } from './Pressable'; import { Text } from './Text'; -export type ButtonProps = RNButtonProps & RefAttributes; - -export const Button = ({ title, color, ...props }: ButtonProps) => { - // TODO: height should come from text size - // TODO: Move to a stylesheet object (not doing this yet because vite caches things outside of the component function) - const style: ViewStyle = { - height: 40, - display: 'flex' as const, - justifyContent: 'center' as const, - alignItems: 'center' as const, - borderRadius: 4, +export type ButtonProps = RNButtonProps & + RefAttributes & { + style?: + | StyleProp + | ((props: { pressed: boolean }) => StyleProp); }; - const { ref, focused } = useFocus(); +export const Button = forwardRef( + ({ title, color, style, ...props }, forwardedRef) => { + // TODO: height should come from text size + // TODO: Move to a stylesheet object (not doing this yet because vite caches things outside of the component function) + const baseStyles: ViewStyle = { + height: 40, + display: 'flex' as const, + justifyContent: 'center' as const, + alignItems: 'center' as const, + borderRadius: 4, + }; + + const { ref, focused } = useFocus(); + const combinedRef = useCombinedRef(forwardedRef, ref); - if (color && focused) { - style.backgroundColor = color; - } else { - style.backgroundColor = 'transparent'; - } + if (color && focused) { + baseStyles.backgroundColor = color; + } else { + baseStyles.backgroundColor = 'transparent'; + } - return ( - [ - style, - { borderWidth: focused ? 4 : 0 }, - { opacity: pressed ? 0.6 : 1 }, - ]} - > - {title} - - ); -}; + return ( + [ + baseStyles, + typeof style === 'function' ? style({ pressed }) : style, + { borderWidth: focused ? 4 : 0 }, + { opacity: pressed ? 0.6 : 1 }, + ]} + > + {title} + + ); + }, +); Button.displayName = 'Button'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26b92236..3ebb6caf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,6 +205,9 @@ importers: '@plextv/react-lightning-plugin-flexbox-lite': specifier: workspace:* version: link:../../packages/plugin-flexbox-lite + '@plextv/react-lightning-plugin-reanimated': + specifier: workspace:* + version: link:../../packages/plugin-reanimated '@plextv/react-native-lightning': specifier: workspace:* version: link:../../packages/react-native-lightning