react-native-reanimated
Version:
More powerful alternative to Animated library for React Native.
265 lines (209 loc) • 7.68 kB
text/typescript
import { ReanimatedError } from '../../errors';
import { logger } from '../../logger';
import { isWindowAvailable } from '../../PlatformChecker';
import type { ReanimatedHTMLElement } from '../../ReanimatedModule/js-reanimated';
import { setElementPosition, snapshots } from './componentStyle';
import type { AnimationNames } from './config';
import { Animations } from './config';
const PREDEFINED_WEB_ANIMATIONS_ID = 'ReanimatedPredefinedWebAnimationsStyle';
const CUSTOM_WEB_ANIMATIONS_ID = 'ReanimatedCustomWebAnimationsStyle';
// Since we cannot remove keyframe from DOM by its name, we have to store its id
const animationNameToIndex = new Map<string, number>();
const animationNameList: string[] = [];
let isObserverSet = false;
/**
* Creates `HTMLStyleElement`, inserts it into DOM and then inserts CSS rules
* into the stylesheet. If style element already exists, nothing happens.
*/
export function configureWebLayoutAnimations() {
if (
!isWindowAvailable() || // Without this check SSR crashes because document is undefined (NextExample on CI)
document.getElementById(PREDEFINED_WEB_ANIMATIONS_ID) !== null
) {
return;
}
const predefinedAnimationsStyleTag = document.createElement('style');
predefinedAnimationsStyleTag.id = PREDEFINED_WEB_ANIMATIONS_ID;
predefinedAnimationsStyleTag.onload = () => {
if (!predefinedAnimationsStyleTag.sheet) {
logger.error('Failed to create layout animations stylesheet.');
return;
}
for (const animationName in Animations) {
predefinedAnimationsStyleTag.sheet.insertRule(
Animations[animationName as AnimationNames].style
);
}
};
const customAnimationsStyleTag = document.createElement('style');
customAnimationsStyleTag.id = CUSTOM_WEB_ANIMATIONS_ID;
document.head.appendChild(predefinedAnimationsStyleTag);
document.head.appendChild(customAnimationsStyleTag);
}
export function insertWebAnimation(animationName: string, keyframe: string) {
// Without this check SSR crashes because document is undefined (NextExample on CI)
if (!isWindowAvailable()) {
return;
}
const styleTag = document.getElementById(
CUSTOM_WEB_ANIMATIONS_ID
) as HTMLStyleElement;
if (!styleTag.sheet) {
logger.error('Failed to create layout animations stylesheet.');
return;
}
styleTag.sheet.insertRule(keyframe, 0);
animationNameList.unshift(animationName);
animationNameToIndex.set(animationName, 0);
for (let i = 1; i < animationNameList.length; ++i) {
const nextAnimationName = animationNameList[i];
const nextAnimationIndex = animationNameToIndex.get(nextAnimationName);
if (nextAnimationIndex === undefined) {
throw new ReanimatedError('Failed to obtain animation index.');
}
animationNameToIndex.set(animationNameList[i], nextAnimationIndex + 1);
}
}
function removeWebAnimation(
animationName: string,
animationRemoveCallback: () => void
) {
// Without this check SSR crashes because document is undefined (NextExample on CI)
if (!isWindowAvailable()) {
return;
}
const styleTag = document.getElementById(
CUSTOM_WEB_ANIMATIONS_ID
) as HTMLStyleElement;
const currentAnimationIndex = animationNameToIndex.get(animationName);
if (currentAnimationIndex === undefined) {
throw new ReanimatedError('Failed to obtain animation index.');
}
animationRemoveCallback();
styleTag.sheet?.deleteRule(currentAnimationIndex);
animationNameList.splice(currentAnimationIndex, 1);
animationNameToIndex.delete(animationName);
for (let i = currentAnimationIndex; i < animationNameList.length; ++i) {
const nextAnimationName = animationNameList[i];
const nextAnimationIndex = animationNameToIndex.get(nextAnimationName);
if (nextAnimationIndex === undefined) {
throw new ReanimatedError('Failed to obtain animation index.');
}
animationNameToIndex.set(animationNameList[i], nextAnimationIndex - 1);
}
}
const timeoutScale = 1.25; // We use this value to enlarge timeout duration. It can prove useful if animation lags.
const frameDurationMs = 16; // Just an approximation.
const minimumFrames = 10;
export function scheduleAnimationCleanup(
animationName: string,
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
const timeoutValue = Math.max(
animationDuration * timeoutScale * 1000,
animationDuration + frameDurationMs * minimumFrames
);
setTimeout(
() => removeWebAnimation(animationName, animationRemoveCallback),
timeoutValue
);
}
function reattachElementToAncestor(child: ReanimatedHTMLElement, parent: Node) {
const childSnapshot = snapshots.get(child);
if (!childSnapshot) {
logger.error('Failed to obtain snapshot.');
return;
}
// We use that so we don't end up in infinite loop
child.removedAfterAnimation = true;
parent.appendChild(child);
setElementPosition(child, childSnapshot);
const originalOnAnimationEnd = child.onanimationend;
child.onanimationend = function (event: AnimationEvent) {
parent.removeChild(child);
// Given that this function overrides onAnimationEnd, it won't be null
originalOnAnimationEnd?.call(this, event);
};
}
function findDescendantWithExitingAnimation(
node: ReanimatedHTMLElement,
root: Node
) {
// Node could be something else than HTMLElement, for example TextNode (treated as plain text, not as HTML object),
// therefore it won't have children prop and calling Array.from(node.children) will cause error.
if (!(node instanceof HTMLElement)) {
return;
}
if (node.reanimatedDummy && node.removedAfterAnimation === undefined) {
reattachElementToAncestor(node, root);
}
const children = Array.from(node.children);
for (let i = 0; i < children.length; ++i) {
findDescendantWithExitingAnimation(
children[i] as ReanimatedHTMLElement,
root
);
}
}
type FiberNodeKey = `__reactFiber${string}`;
interface FiberNode {
memoizedProps?: {
navigation?: unknown;
};
child?: FiberNode;
}
type WithFiberNode = {
[key: FiberNodeKey]: FiberNode;
};
type MaybeWithFiberNode = Partial<WithFiberNode>;
function checkIfScreenWasChanged(
mutationTarget: ReanimatedHTMLElement & MaybeWithFiberNode
) {
let reactFiberKey: FiberNodeKey = '__reactFiber';
for (const key of Object.keys(mutationTarget)) {
if (key.startsWith('__reactFiber')) {
reactFiberKey = key as FiberNodeKey;
break;
}
}
return (
mutationTarget[reactFiberKey]?.child?.memoizedProps?.navigation !==
undefined
);
}
export function addHTMLMutationObserver() {
if (isObserverSet || !isWindowAvailable()) {
return;
}
isObserverSet = true;
const observer = new MutationObserver((mutationsList) => {
const rootMutation = mutationsList[mutationsList.length - 1];
if (
checkIfScreenWasChanged(
rootMutation.target as ReanimatedHTMLElement & MaybeWithFiberNode
)
) {
return;
}
for (let i = 0; i < rootMutation.removedNodes.length; ++i) {
findDescendantWithExitingAnimation(
rootMutation.removedNodes[i] as ReanimatedHTMLElement,
rootMutation.target
);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
export function areDOMRectsEqual(r1: DOMRect, r2: DOMRect) {
// There are 4 more fields, but checking these should suffice
return (
r1.x === r2.x &&
r1.y === r2.y &&
r1.width === r2.width &&
r1.height === r2.height
);
}
;