UNPKG

react-native-reanimated

Version:

More powerful alternative to Animated library for React Native.

239 lines (204 loc) • 7.85 kB
'use strict'; import { convertPropertiesToArrays, type ConvertValuesToArrays, kebabizeCamelCase, maybeAddSuffix, } from '../../../common'; import type { ReanimatedHTMLElement } from '../../../ReanimatedModule/js-reanimated'; import type { CSSAnimationKeyframes, CSSAnimationSettings, ExistingCSSAnimationProperties, ICSSAnimationsManager, } from '../../types'; import { normalizeTimeUnit } from '../../utils'; import { processKeyframeDefinitions } from '../animationParser'; import { configureWebCSSAnimations, insertCSSAnimation, removeCSSAnimation, } from '../domUtils'; import { CSSKeyframesRuleImpl } from '../keyframes'; import { maybeAddSuffixes, parseTimingFunction } from '../utils'; const isCSSKeyframesRuleImpl = ( keyframes: ExistingCSSAnimationProperties['animationName'] ): keyframes is CSSKeyframesRuleImpl => typeof keyframes === 'object' && 'processedKeyframes' in keyframes; type ProcessedAnimation = { keyframesRule: CSSKeyframesRuleImpl; removable: boolean; creationTimestamp: number; elapsedTime?: number; }; type ProcessedSettings = ConvertValuesToArrays<CSSAnimationSettings>; export default class CSSAnimationsManager implements ICSSAnimationsManager { private readonly element: ReanimatedHTMLElement; // Keys are processed keyframes private attachedAnimations: Record<string, ProcessedAnimation> = {}; private unmountCleanupCalled = false; constructor(element: ReanimatedHTMLElement) { configureWebCSSAnimations(); this.element = element; } update(animationProperties: ExistingCSSAnimationProperties | null) { if (!animationProperties) { this.detach(); return; } const { animationName: definitions, ...animationSettings } = convertPropertiesToArrays(animationProperties); if (definitions.length === 0) { this.detach(); return; } const timestamp = Date.now(); const processedAnimations = definitions.map((definition) => { let processedAnimation: ProcessedAnimation; // If the CSSKeyframesRule instance was provided, we can just use it if (isCSSKeyframesRuleImpl(definition)) { processedAnimation = this.attachedAnimations[ definition.processedKeyframes ] ?? { keyframesRule: definition, removable: false, creationTimestamp: timestamp, }; } else { // If keyframes was defined as an object, the additional processing is needed const keyframes = definition as CSSAnimationKeyframes; const processedKeyframes = processKeyframeDefinitions(keyframes); // If the animation with the same keyframes was already attached, we can reuse it // Otherwise, we need to create a new CSSKeyframesRule object processedAnimation = this.attachedAnimations[processedKeyframes] ?? { keyframesRule: new CSSKeyframesRuleImpl( keyframes, processedKeyframes ), removable: true, creationTimestamp: timestamp, }; } if (this.unmountCleanupCalled) { // unmountCleanup is called not only when the component truly unmounts, but also // when display property is set to 'none' (e.g. during navigation between screens) // In such a case, we don't want to restart the animation after re-entering the // screen so we have to shift its delay by the time elapsed since the animation // was started for the first time. processedAnimation.elapsedTime = timestamp - processedAnimation.creationTimestamp; } return processedAnimation; }); this.unmountCleanupCalled = false; this.updateAttachedAnimations(processedAnimations); this.setElementAnimations(processedAnimations, animationSettings); } unmountCleanup(): void { if (!this.unmountCleanupCalled) { this.unmountCleanupCalled = true; // We use setTimeout to ensure that the animation is removed after the // component is unmounted (it puts the detach call at the end of the event loop) // We just remove the animation definition from the style sheet as there is no // need to clean up view props if it is removed from the DOM. setTimeout(() => { this.removeAnimationsFromStyleSheet( Object.values(this.attachedAnimations) ); }); } } private detach() { const attachedAnimations = Object.values(this.attachedAnimations); if (attachedAnimations.length === 0) { return; } this.element.style.animationDuration = ''; this.element.style.animationDelay = ''; this.element.style.animationDirection = ''; this.element.style.animationFillMode = ''; this.element.style.animationPlayState = ''; this.element.style.animationTimingFunction = ''; this.removeAnimationsFromStyleSheet(attachedAnimations); this.unmountCleanupCalled = false; this.attachedAnimations = {}; } private updateAttachedAnimations(processedAnimations: ProcessedAnimation[]) { const newAttachedAnimations: Record<string, ProcessedAnimation> = {}; processedAnimations.forEach((processedAnimation) => { const rule = processedAnimation.keyframesRule; if (rule.processedKeyframes) { // We always call insert as it will insert animation only if it doesn't exist insertCSSAnimation(rule.name, rule.processedKeyframes); } newAttachedAnimations[rule.processedKeyframes] = processedAnimation; }); Object.values(this.attachedAnimations).forEach( ({ keyframesRule: rule, removable }) => { if ( removable && rule.processedKeyframes && !newAttachedAnimations[rule.processedKeyframes] ) { removeCSSAnimation(rule.name); } } ); this.attachedAnimations = newAttachedAnimations; } private setElementAnimations( processedAnimations: ProcessedAnimation[], animationSettings: ProcessedSettings ) { this.element.style.animationName = processedAnimations .map(({ keyframesRule: { name } }) => name) .join(','); this.element.style.animationDuration = maybeAddSuffixes( animationSettings, 'animationDuration', 'ms' ).join(','); const animationDelays = animationSettings.animationDelay ?? []; this.element.style.animationDelay = processedAnimations .map(({ elapsedTime }, i) => { const providedDelay = animationDelays[i] ?? 0; return maybeAddSuffix( elapsedTime ? (normalizeTimeUnit(providedDelay) ?? 0) - elapsedTime : providedDelay, 'ms' ); }) .join(','); if (animationSettings.animationIterationCount) { this.element.style.animationIterationCount = animationSettings.animationIterationCount.join(','); } if (animationSettings.animationDirection) { this.element.style.animationDirection = animationSettings.animationDirection.map(kebabizeCamelCase).join(','); } if (animationSettings.animationFillMode) { this.element.style.animationFillMode = animationSettings.animationFillMode.join(','); } if (animationSettings.animationPlayState) { this.element.style.animationPlayState = animationSettings.animationPlayState.join(','); } if (animationSettings.animationTimingFunction) { this.element.style.animationTimingFunction = parseTimingFunction( animationSettings.animationTimingFunction ); } } private removeAnimationsFromStyleSheet(animations: ProcessedAnimation[]) { animations.forEach( ({ keyframesRule: { name, processedKeyframes }, removable }) => { if (removable && processedKeyframes) { removeCSSAnimation(name); } } ); } }