Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Web LA] Custom Keyframe animations #6135

Merged
merged 18 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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