From 90adfdf5e058806bde82396cad871d15db55e3c4 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 6 Sep 2024 15:42:43 +0200 Subject: [PATCH] [Perf] Animated Counter: Avoid redundant rerenders --- src/components/common/AnimatedCounter.tsx | 118 ++++++++++++++-------- 1 file changed, 75 insertions(+), 43 deletions(-) diff --git a/src/components/common/AnimatedCounter.tsx b/src/components/common/AnimatedCounter.tsx index 107418e4c..cb558c8b0 100644 --- a/src/components/common/AnimatedCounter.tsx +++ b/src/components/common/AnimatedCounter.tsx @@ -1,72 +1,104 @@ import type { FC } from '../../lib/teact/teact'; -import React, { - useEffect, useMemo, useRef, -} from '../../lib/teact/teact'; +import React, { memo, useEffect, useMemo } from '../../lib/teact/teact'; import { getGlobal } from '../../global'; import { selectCanAnimateInterface } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; +import { throttleWithTickEnd } from '../../util/schedulers'; -import useFlag from '../../hooks/useFlag'; -import useOldLang from '../../hooks/useOldLang'; +import useForceUpdate from '../../hooks/useForceUpdate'; +import useLang from '../../hooks/useLang'; +import usePrevious from '../../hooks/usePrevious'; import styles from './AnimatedCounter.module.scss'; type OwnProps = { text: string; className?: string; + isDisabled?: boolean; }; +const ANIMATION_TIME = 200; +const MAX_SIMULTANEOUS_ANIMATIONS = 10; + +let scheduledAnimationsCounter = 0; + +const resetCounterOnTickEnd = throttleWithTickEnd(() => { + scheduledAnimationsCounter = 0; +}); + const AnimatedCounter: FC = ({ text, className, + isDisabled, }) => { - const lang = useOldLang(); + const { isRtl } = useLang(); - const prevTextRef = useRef(); - const [isAnimating, markAnimating, unmarkAnimating] = useFlag(false); + const prevText = usePrevious(text); + const forceUpdate = useForceUpdate(); - const shouldAnimate = selectCanAnimateInterface(getGlobal()); + const shouldAnimate = scheduleAnimation( + !isDisabled && selectCanAnimateInterface(getGlobal()) && prevText !== undefined && prevText !== text, + ); - const textElement = useMemo(() => { - if (!shouldAnimate) { - return text; - } - if (!isAnimating) { - return prevTextRef.current || text; - } - - const prevText = prevTextRef.current; - - const elements = []; - for (let i = 0; i < text.length; i++) { - if (prevText && text[i] !== prevText[i]) { - elements.push( -
-
{text[i]}
-
{prevText[i]}
-
{text[i]}
-
, - ); - } else { - elements.push({text[i]}); - } - } - - prevTextRef.current = text; - - return elements; - }, [shouldAnimate, isAnimating, text]); + const characters = useMemo(() => { + return shouldAnimate ? renderAnimatedCharacters(text, prevText) : text; + }, [shouldAnimate, prevText, text]); useEffect(() => { - markAnimating(); - }, [text]); + if (!shouldAnimate) return undefined; + + const timeoutId = window.setTimeout(() => { + forceUpdate(); + }, ANIMATION_TIME); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [shouldAnimate, text]); return ( - - {textElement} + + {characters} ); }; -export default AnimatedCounter; +export default memo(AnimatedCounter); + +function scheduleAnimation(condition: boolean) { + if (!condition || scheduledAnimationsCounter >= MAX_SIMULTANEOUS_ANIMATIONS) return false; + + if (scheduledAnimationsCounter === 0) { + resetCounterOnTickEnd(); + } + + scheduledAnimationsCounter++; + + return true; +} + +function renderAnimatedCharacters(text: string, prevText?: string) { + const elements: React.ReactNode[] = []; + const textLength = text.length; + const prevTextLength = prevText?.length ?? 0; + + for (let i = 0; i <= textLength; i++) { + const charIndex = textLength - i; + const prevTextCharIndex = prevTextLength - i; + + if (prevText && prevTextCharIndex >= 0 && text[charIndex] !== prevText[prevTextCharIndex]) { + elements.unshift( +
+
{text[charIndex] ?? ''}
+
{prevText[prevTextCharIndex]}
+
{text[charIndex] ?? ''}
+
, + ); + } else { + elements.unshift({text[charIndex] ?? ''}); + } + } + + return elements; +}