Skip to content

Commit

Permalink
[Web LA] Custom Keyframe animations (#6135)
Browse files Browse the repository at this point in the history
## 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:

<details>
<summary> Test code </summary>

```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 (
    <View style={styles.container}>
      {show && (
        <Animated.View
          entering={entering.duration(1000)}
          exiting={exiting}
          style={styles.box}
        />
      )}

      <Pressable onPress={() => setShow((prev) => !prev)} style={styles.button}>
        <Text style={{ color: 'white' }}> Click me! </Text>
      </Pressable>
    </View>
  );
}

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,
  },
});

```

</details>
  • Loading branch information
m-bert committed Jun 26, 2024
1 parent 7aea7e5 commit b911f80
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
'use strict';

import { WebEasings } from './Easing.web';
import type { WebEasingsNames } from './Easing.web';

export interface ReanimatedWebTransformProperties {
translateX?: string;
translateY?: string;
Expand All @@ -14,7 +17,7 @@ export interface ReanimatedWebTransformProperties {
skewX?: string;
}

interface AnimationStyle {
export interface AnimationStyle {
opacity?: number;
transform?: ReanimatedWebTransformProperties[];
}
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ComponentProps extends Record<string, unknown>>(
Expand All @@ -35,11 +43,11 @@ function chooseConfig<ComponentProps extends Record<string, unknown>>(

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;
}

Expand Down Expand Up @@ -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;
Expand All @@ -111,25 +119,48 @@ function tryGetAnimationConfig<ComponentProps extends Record<string, unknown>>(
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;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 =
Expand Down Expand Up @@ -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
Expand All @@ -86,24 +92,41 @@ 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),
reversed: getReversedFromConfig(config),
};
}

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();

Expand All @@ -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;

Expand All @@ -130,6 +154,10 @@ export function setElementAnimation(
element.style.animationTimingFunction = easing;

element.onanimationend = () => {
if (shouldSavePosition) {
saveSnapshot(element);
}

animationConfig.callback?.(true);
element.removeEventListener('animationcancel', animationCancelHandler);
};
Expand All @@ -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)!);
}
});
}
}

Expand Down Expand Up @@ -261,7 +293,7 @@ export function handleExitingAnimation(

snapshots.set(dummy, snapshot);

setDummyPosition(dummy, snapshot);
setElementPosition(dummy, snapshot);

const originalOnAnimationEnd = dummy.onanimationend;

Expand Down
Loading

0 comments on commit b911f80

Please sign in to comment.