From b911f8072c1e602bdc52c15f6f32543c2e15322c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Wed, 26 Jun 2024 13:02:15 +0200 Subject: [PATCH] [Web LA] Custom `Keyframe` animations (#6135) ## Summary This PR adds support for custom layout animations created via `Keyframe` on web. > [!IMPORTANT] > This PR replaces #5277 ## Test plan Tested on example app and the following code snippet:
Test code ```jsx import { StyleSheet, View, Text, Pressable } from 'react-native'; import React, { useState } from 'react'; import Animated, { Easing, Keyframe } from 'react-native-reanimated'; const entering = new Keyframe({ 0: { transform: [ { translateX: -500 }, { translateY: -300 }, { scale: 1.25 }, { skewY: '25deg' }, ], opacity: 0.3, easing: Easing.cubic, }, 70: { transform: [{ translateX: 250 }, { translateY: 100 }, { scale: 1.25 }], opacity: 0.7, }, }); const exiting = new Keyframe({ 0: { easing: Easing.exp, }, 100: { transform: [ { translateX: 700 }, { translateY: 250 }, { scale: 0.3 }, { rotate: '225deg' }, ], opacity: 0, }, }); export default function EmptyExample() { const [show, setShow] = useState(true); return ( {show && ( )} setShow((prev) => !prev)} style={styles.button}> Click me! ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', justifyContent: 'center', }, box: { width: 250, height: 250, backgroundColor: '#782aeb', }, button: { width: 100, height: 50, backgroundColor: '#57b495', display: 'flex', alignItems: 'center', justifyContent: 'space-around', borderRadius: 15, position: 'absolute', top: 10, left: 10, }, }); ```
--- .../LayoutAnimations/OlympicAnimation.tsx | 8 +++ .../src/layoutReanimation/web/Easing.web.ts | 15 ++++ .../layoutReanimation/web/animationParser.ts | 32 ++++++++- .../web/animationsManager.ts | 57 ++++++++++++---- .../layoutReanimation/web/componentStyle.ts | 22 +++--- .../layoutReanimation/web/componentUtils.ts | 68 ++++++++++++++----- .../src/layoutReanimation/web/config.ts | 18 ++--- .../layoutReanimation/web/createAnimation.ts | 34 ++++++++-- .../src/layoutReanimation/web/domUtils.ts | 20 ++++-- 9 files changed, 208 insertions(+), 66 deletions(-) create mode 100644 packages/react-native-reanimated/src/layoutReanimation/web/Easing.web.ts diff --git a/apps/common-app/src/examples/LayoutAnimations/OlympicAnimation.tsx b/apps/common-app/src/examples/LayoutAnimations/OlympicAnimation.tsx index c12bac67e0f..27a3081dda3 100644 --- a/apps/common-app/src/examples/LayoutAnimations/OlympicAnimation.tsx +++ b/apps/common-app/src/examples/LayoutAnimations/OlympicAnimation.tsx @@ -72,6 +72,9 @@ export default function OlympicAnimation() { 60: { transform: [{ translateX: -13 }, { translateY: 0 }], }, + to: { + transform: [{ translateX: -13 }, { translateY: 0 }], + }, }).duration(3000); const blueRingExitAnimation = new Keyframe({ from: { @@ -104,6 +107,11 @@ export default function OlympicAnimation() { transform: [{ translateX: 1100 }, { translateY: 1100 }, { scale: 20 }], easing: Easing.quad, }, + to: { + opacity: 0, + transform: [{ translateX: 1100 }, { translateY: 1100 }, { scale: 20 }], + easing: Easing.quad, + }, }).duration(3000); const yellowRingExitAnimation = new Keyframe({ from: { diff --git a/packages/react-native-reanimated/src/layoutReanimation/web/Easing.web.ts b/packages/react-native-reanimated/src/layoutReanimation/web/Easing.web.ts new file mode 100644 index 00000000000..ab6d931663f --- /dev/null +++ b/packages/react-native-reanimated/src/layoutReanimation/web/Easing.web.ts @@ -0,0 +1,15 @@ +'use strict'; + +// Those are the easings that can be implemented using Bezier curves. +// Others should be done as CSS animations +export const WebEasings = { + linear: [0, 0, 1, 1], + ease: [0.42, 0, 1, 1], + quad: [0.11, 0, 0.5, 0], + cubic: [0.32, 0, 0.67, 0], + sin: [0.12, 0, 0.39, 0], + circle: [0.55, 0, 1, 0.45], + exp: [0.7, 0, 0.84, 0], +}; + +export type WebEasingsNames = keyof typeof WebEasings; diff --git a/packages/react-native-reanimated/src/layoutReanimation/web/animationParser.ts b/packages/react-native-reanimated/src/layoutReanimation/web/animationParser.ts index 42595b4fd07..ec04bc78d44 100644 --- a/packages/react-native-reanimated/src/layoutReanimation/web/animationParser.ts +++ b/packages/react-native-reanimated/src/layoutReanimation/web/animationParser.ts @@ -1,5 +1,8 @@ 'use strict'; +import { WebEasings } from './Easing.web'; +import type { WebEasingsNames } from './Easing.web'; + export interface ReanimatedWebTransformProperties { translateX?: string; translateY?: string; @@ -14,7 +17,7 @@ export interface ReanimatedWebTransformProperties { skewX?: string; } -interface AnimationStyle { +export interface AnimationStyle { opacity?: number; transform?: ReanimatedWebTransformProperties[]; } @@ -39,9 +42,34 @@ export function convertAnimationObjectToKeyframes( let keyframe = `@keyframes ${animationObject.name} { `; for (const [timestamp, style] of Object.entries(animationObject.style)) { - keyframe += `${timestamp}% { `; + const step = + timestamp === 'from' ? 0 : timestamp === 'to' ? 100 : timestamp; + + keyframe += `${step}% { `; for (const [property, values] of Object.entries(style)) { + if (property === 'easing') { + const easingName = ( + values.name in WebEasings ? values.name : 'linear' + ) as WebEasingsNames; + + keyframe += `animation-timing-function: cubic-bezier(${WebEasings[ + easingName + ].toString()});`; + + continue; + } + + if (property === 'originX') { + keyframe += `left: ${values}px; `; + continue; + } + + if (property === 'originY') { + keyframe += `top: ${values}px; `; + continue; + } + if (property !== 'transform') { keyframe += `${property}: ${values}; `; continue; diff --git a/packages/react-native-reanimated/src/layoutReanimation/web/animationsManager.ts b/packages/react-native-reanimated/src/layoutReanimation/web/animationsManager.ts index 6b1ff2e3041..72753c3c74d 100644 --- a/packages/react-native-reanimated/src/layoutReanimation/web/animationsManager.ts +++ b/packages/react-native-reanimated/src/layoutReanimation/web/animationsManager.ts @@ -1,20 +1,28 @@ 'use strict'; -import type { AnimationConfig, AnimationNames, CustomConfig } from './config'; +import type { + AnimationConfig, + AnimationNames, + CustomConfig, + KeyframeDefinitions, +} from './config'; import { Animations } from './config'; import type { AnimatedComponentProps, LayoutAnimationStaticContext, } from '../../createAnimatedComponent/commonTypes'; import { LayoutAnimationType } from '../animationBuilder/commonTypes'; +import { createCustomKeyFrameAnimation } from './createAnimation'; import { getProcessedConfig, handleExitingAnimation, handleLayoutTransition, + maybeModifyStyleForKeyframe, setElementAnimation, } from './componentUtils'; import { areDOMRectsEqual } from './domUtils'; import type { TransitionData } from './animationParser'; +import { Keyframe } from '../animationBuilder'; import { makeElementVisible } from './componentStyle'; function chooseConfig>( @@ -35,11 +43,11 @@ function chooseConfig>( function checkUndefinedAnimationFail( initialAnimationName: string, - isLayoutTransition: boolean + needsCustomization: boolean ) { // This prevents crashes if we try to set animations that are not defined. - // We don't care about layout transitions since they're created dynamically - if (initialAnimationName in Animations || isLayoutTransition) { + // We don't care about layout transitions or custom keyframes since they're created dynamically + if (initialAnimationName in Animations || needsCustomization) { return false; } @@ -86,7 +94,7 @@ function chooseAction( ) { switch (animationType) { case LayoutAnimationType.ENTERING: - setElementAnimation(element, animationConfig); + setElementAnimation(element, animationConfig, true); break; case LayoutAnimationType.LAYOUT: transitionData.reversed = animationConfig.reversed; @@ -111,25 +119,48 @@ function tryGetAnimationConfig>( typeof config.constructor; const isLayoutTransition = animationType === LayoutAnimationType.LAYOUT; - const animationName = - typeof config === 'function' - ? config.presetName - : (config.constructor as ConstructorWithStaticContext).presetName; + const isCustomKeyframe = config instanceof Keyframe; + + let animationName; + + if (isCustomKeyframe) { + animationName = createCustomKeyFrameAnimation( + (config as CustomConfig).definitions as KeyframeDefinitions + ); + } else if (typeof config === 'function') { + animationName = config.presetName; + } else { + animationName = (config.constructor as ConstructorWithStaticContext) + .presetName; + } const shouldFail = checkUndefinedAnimationFail( animationName, - isLayoutTransition + isLayoutTransition || isCustomKeyframe ); if (shouldFail) { return null; } + if (isCustomKeyframe) { + const keyframeTimestamps = Object.keys( + (config as CustomConfig).definitions as KeyframeDefinitions + ); + + if ( + !(keyframeTimestamps.includes('100') || keyframeTimestamps.includes('to')) + ) { + console.warn( + `[Reanimated] Neither '100' nor 'to' was specified in Keyframe definition. This may result in wrong final position of your component. One possible solution is to duplicate last timestamp in definition as '100' (or 'to')` + ); + } + } + const animationConfig = getProcessedConfig( animationName, animationType, - config as CustomConfig, - animationName as AnimationNames + config as CustomConfig ); return animationConfig; @@ -145,6 +176,8 @@ export function startWebLayoutAnimation< ) { const animationConfig = tryGetAnimationConfig(props, animationType); + maybeModifyStyleForKeyframe(element, props.entering as CustomConfig); + if ((animationConfig?.animationName as AnimationNames) in Animations) { maybeReportOverwrittenProperties( Animations[animationConfig?.animationName as AnimationNames].style, diff --git a/packages/react-native-reanimated/src/layoutReanimation/web/componentStyle.ts b/packages/react-native-reanimated/src/layoutReanimation/web/componentStyle.ts index ec192f6cae7..f2f82c3d70e 100644 --- a/packages/react-native-reanimated/src/layoutReanimation/web/componentStyle.ts +++ b/packages/react-native-reanimated/src/layoutReanimation/web/componentStyle.ts @@ -66,19 +66,19 @@ function fixElementPosition( } } -export function setDummyPosition( - dummy: HTMLElement, +export function setElementPosition( + element: HTMLElement, snapshot: ReanimatedSnapshot ) { - dummy.style.transform = ''; - dummy.style.position = 'absolute'; - dummy.style.top = `${snapshot.top}px`; - dummy.style.left = `${snapshot.left}px`; - dummy.style.width = `${snapshot.width}px`; - dummy.style.height = `${snapshot.height}px`; - dummy.style.margin = '0px'; // tmpElement has absolute position, so margin is not necessary + element.style.transform = ''; + element.style.position = 'absolute'; + element.style.top = `${snapshot.top}px`; + element.style.left = `${snapshot.left}px`; + element.style.width = `${snapshot.width}px`; + element.style.height = `${snapshot.height}px`; + element.style.margin = '0px'; // tmpElement has absolute position, so margin is not necessary - if (dummy.parentElement) { - fixElementPosition(dummy, dummy.parentElement, snapshot); + if (element.parentElement) { + fixElementPosition(element, element.parentElement, snapshot); } } diff --git a/packages/react-native-reanimated/src/layoutReanimation/web/componentUtils.ts b/packages/react-native-reanimated/src/layoutReanimation/web/componentUtils.ts index d8085301a3e..417f7aa10be 100644 --- a/packages/react-native-reanimated/src/layoutReanimation/web/componentUtils.ts +++ b/packages/react-native-reanimated/src/layoutReanimation/web/componentUtils.ts @@ -1,13 +1,15 @@ 'use strict'; -import { Animations, TransitionType, WebEasings } from './config'; +import { Animations, TransitionType } from './config'; import type { AnimationCallback, AnimationConfig, AnimationNames, CustomConfig, - WebEasingsNames, + KeyframeDefinitions, } from './config'; +import { WebEasings } from './Easing.web'; +import type { WebEasingsNames } from './Easing.web'; import type { TransitionData } from './animationParser'; import { TransitionGenerator } from './createAnimation'; import { scheduleAnimationCleanup } from './domUtils'; @@ -17,7 +19,8 @@ import { ReduceMotion } from '../../commonTypes'; import { isReducedMotion } from '../../PlatformChecker'; import { LayoutAnimationType } from '../animationBuilder/commonTypes'; import type { ReanimatedSnapshot, ScrollOffsets } from './componentStyle'; -import { setDummyPosition, snapshots } from './componentStyle'; +import { setElementPosition, snapshots } from './componentStyle'; +import { Keyframe } from '../animationBuilder'; function getEasingFromConfig(config: CustomConfig): string { const easingName = @@ -63,12 +66,15 @@ export function getReducedMotionFromConfig(config: CustomConfig) { function getDurationFromConfig( config: CustomConfig, - isLayoutTransition: boolean, - animationName: AnimationNames + animationName: string ): number { - const defaultDuration = isLayoutTransition - ? 0.3 - : Animations[animationName].duration; + // Duration in keyframe has to be in seconds. However, when using `.duration()` modifier we pass it in miliseconds. + // If `duration` was specified in config, we have to divide it by `1000`, otherwise we return value that is already in seconds. + + const defaultDuration = + animationName in Animations + ? Animations[animationName as AnimationNames].duration + : 0.3; return config.durationV !== undefined ? config.durationV / 1000 @@ -86,17 +92,12 @@ function getReversedFromConfig(config: CustomConfig) { export function getProcessedConfig( animationName: string, animationType: LayoutAnimationType, - config: CustomConfig, - initialAnimationName: AnimationNames + config: CustomConfig ): AnimationConfig { return { animationName, animationType, - duration: getDurationFromConfig( - config, - animationType === LayoutAnimationType.LAYOUT, - initialAnimationName - ), + duration: getDurationFromConfig(config, animationName), delay: getDelayFromConfig(config), easing: getEasingFromConfig(config), callback: getCallbackFromConfig(config), @@ -104,6 +105,28 @@ export function getProcessedConfig( }; } +export function maybeModifyStyleForKeyframe( + element: HTMLElement, + config: CustomConfig +) { + if (!(config instanceof Keyframe)) { + return; + } + + // We need to set `animationFillMode` to `forwards`, otherwise component will go back to its position. + // This will result in wrong snapshot + element.style.animationFillMode = 'forwards'; + + for (const timestampRules of Object.values( + config.definitions as KeyframeDefinitions + )) { + if ('originX' in timestampRules || 'originY' in timestampRules) { + element.style.position = 'absolute'; + return; + } + } +} + export function saveSnapshot(element: HTMLElement) { const rect = element.getBoundingClientRect(); @@ -120,7 +143,8 @@ export function saveSnapshot(element: HTMLElement) { export function setElementAnimation( element: HTMLElement, - animationConfig: AnimationConfig + animationConfig: AnimationConfig, + shouldSavePosition = false ) { const { animationName, duration, delay, easing } = animationConfig; @@ -130,6 +154,10 @@ export function setElementAnimation( element.style.animationTimingFunction = easing; element.onanimationend = () => { + if (shouldSavePosition) { + saveSnapshot(element); + } + animationConfig.callback?.(true); element.removeEventListener('animationcancel', animationCancelHandler); }; @@ -152,7 +180,11 @@ export function setElementAnimation( }; if (!(animationName in Animations)) { - scheduleAnimationCleanup(animationName, duration + delay); + scheduleAnimationCleanup(animationName, duration + delay, () => { + if (shouldSavePosition) { + setElementPosition(element, snapshots.get(element)!); + } + }); } } @@ -261,7 +293,7 @@ export function handleExitingAnimation( snapshots.set(dummy, snapshot); - setDummyPosition(dummy, snapshot); + setElementPosition(dummy, snapshot); const originalOnAnimationEnd = dummy.onanimationend; diff --git a/packages/react-native-reanimated/src/layoutReanimation/web/config.ts b/packages/react-native-reanimated/src/layoutReanimation/web/config.ts index 47bf53cea33..5b63b9a9e14 100644 --- a/packages/react-native-reanimated/src/layoutReanimation/web/config.ts +++ b/packages/react-native-reanimated/src/layoutReanimation/web/config.ts @@ -37,10 +37,12 @@ import { } from './animation/Stretch.web'; import { ZoomIn, ZoomInData, ZoomOut, ZoomOutData } from './animation/Zoom.web'; -import type { AnimationData } from './animationParser'; +import type { AnimationData, AnimationStyle } from './animationParser'; export type AnimationCallback = ((finished: boolean) => void) | null; +export type KeyframeDefinitions = Record; + export interface AnimationConfig { animationName: string; animationType: LayoutAnimationType; @@ -59,6 +61,7 @@ export interface CustomConfig { reduceMotionV?: ReduceMotion; callbackV?: AnimationCallback; reversed?: boolean; + definitions?: KeyframeDefinitions; } export enum TransitionType { @@ -112,18 +115,5 @@ export const Animations = { ...RollOut, }; -// Those are the easings that can be implemented using Bezier curves. -// Others should be done as CSS animations -export const WebEasings = { - linear: [0, 0, 1, 1], - ease: [0.42, 0, 1, 1], - quad: [0.11, 0, 0.5, 0], - cubic: [0.32, 0, 0.67, 0], - sin: [0.12, 0, 0.39, 0], - circle: [0.55, 0, 1, 0.45], - exp: [0.7, 0, 0.84, 0], -}; - export type AnimationNames = keyof typeof Animations; export type LayoutTransitionsNames = keyof typeof AnimationsData; -export type WebEasingsNames = keyof typeof WebEasings; diff --git a/packages/react-native-reanimated/src/layoutReanimation/web/createAnimation.ts b/packages/react-native-reanimated/src/layoutReanimation/web/createAnimation.ts index 0354d118d6c..b082108e10f 100644 --- a/packages/react-native-reanimated/src/layoutReanimation/web/createAnimation.ts +++ b/packages/react-native-reanimated/src/layoutReanimation/web/createAnimation.ts @@ -1,8 +1,10 @@ 'use strict'; import { TransitionType } from './config'; +import type { KeyframeDefinitions } from './config'; import { convertAnimationObjectToKeyframes } from './animationParser'; import type { + AnimationData, ReanimatedWebTransformProperties, TransitionData, } from './animationParser'; @@ -13,14 +15,14 @@ import { FadingTransition } from './transition/Fading.web'; import { JumpingTransition } from './transition/Jumping.web'; import { insertWebAnimation } from './domUtils'; +type TransformType = NonNullable; + // Translate values are passed as numbers. However, if `translate` property receives number, it will not automatically // convert it to `px`. Therefore if we want to keep transform we have to add 'px' suffix to each of translate values // that are present inside transform. // // eslint-disable-next-line @typescript-eslint/no-unused-vars -function addPxToTranslate( - transform: NonNullable -) { +function addPxToTranslate(transform: TransformType) { type RNTransformProp = (typeof transform)[number]; // @ts-ignore `existingTransform` cannot be string because in that case @@ -28,7 +30,7 @@ function addPxToTranslate( const newTransform = transform.map((transformProp: RNTransformProp) => { const newTransformProp: ReanimatedWebTransformProperties = {}; for (const [key, value] of Object.entries(transformProp)) { - if (key.includes('translate')) { + if (key.includes('translate') && typeof value === 'number') { // @ts-ignore After many trials we decided to ignore this error - it says that we cannot use 'key' to index this object. // Sadly it doesn't go away after using cast `key as keyof TransformProperties`. newTransformProp[key] = `${value}px`; @@ -43,6 +45,30 @@ function addPxToTranslate( return newTransform; } +export function createCustomKeyFrameAnimation( + keyframeDefinitions: KeyframeDefinitions +) { + for (const value of Object.values(keyframeDefinitions)) { + if (value.transform) { + value.transform = addPxToTranslate(value.transform as TransformType); + } + } + + const animationData: AnimationData = { + name: '', + style: keyframeDefinitions, + duration: -1, + }; + + animationData.name = generateNextCustomKeyframeName(); + + const parsedKeyframe = convertAnimationObjectToKeyframes(animationData); + + insertWebAnimation(animationData.name, parsedKeyframe); + + return animationData.name; +} + let customKeyframeCounter = 0; function generateNextCustomKeyframeName() { diff --git a/packages/react-native-reanimated/src/layoutReanimation/web/domUtils.ts b/packages/react-native-reanimated/src/layoutReanimation/web/domUtils.ts index 7e918f50830..423f4068012 100644 --- a/packages/react-native-reanimated/src/layoutReanimation/web/domUtils.ts +++ b/packages/react-native-reanimated/src/layoutReanimation/web/domUtils.ts @@ -2,7 +2,7 @@ import type { ReanimatedHTMLElement } from '../../js-reanimated'; import { isWindowAvailable } from '../../PlatformChecker'; -import { setDummyPosition, snapshots } from './componentStyle'; +import { setElementPosition, snapshots } from './componentStyle'; import { Animations } from './config'; import type { AnimationNames } from './config'; @@ -85,7 +85,10 @@ export function insertWebAnimation(animationName: string, keyframe: string) { } } -function removeWebAnimation(animationName: string) { +function removeWebAnimation( + animationName: string, + animationRemoveCallback: () => void +) { // Without this check SSR crashes because document is undefined (NextExample on CI) if (!isWindowAvailable()) { return; @@ -101,7 +104,10 @@ function removeWebAnimation(animationName: string) { throw new Error('[Reanimated] Failed to obtain animation index.'); } + animationRemoveCallback(); + styleTag.sheet?.deleteRule(currentAnimationIndex); + animationNameList.splice(currentAnimationIndex, 1); animationNameToIndex.delete(animationName); @@ -123,7 +129,8 @@ const minimumFrames = 10; export function scheduleAnimationCleanup( animationName: string, - animationDuration: number + animationDuration: number, + animationRemoveCallback: () => void ) { // If duration is very short, we want to keep remove delay to at least 10 frames // In our case it is exactly 160/1099 s, which is approximately 0.15s @@ -132,7 +139,10 @@ export function scheduleAnimationCleanup( animationDuration + frameDurationMs * minimumFrames ); - setTimeout(() => removeWebAnimation(animationName), timeoutValue); + setTimeout( + () => removeWebAnimation(animationName, animationRemoveCallback), + timeoutValue + ); } function reattachElementToAncestor(child: ReanimatedHTMLElement, parent: Node) { @@ -147,7 +157,7 @@ function reattachElementToAncestor(child: ReanimatedHTMLElement, parent: Node) { child.removedAfterAnimation = true; parent.appendChild(child); - setDummyPosition(child, childSnapshot); + setElementPosition(child, childSnapshot); const originalOnAnimationEnd = child.onanimationend;