diff --git a/packages/vkui/src/components/Skeleton/Skeleton.module.css b/packages/vkui/src/components/Skeleton/Skeleton.module.css index 5cae8a66bc..05280c0a8a 100644 --- a/packages/vkui/src/components/Skeleton/Skeleton.module.css +++ b/packages/vkui/src/components/Skeleton/Skeleton.module.css @@ -1,18 +1,8 @@ -@keyframes skeleton { - from { - transform: translateX(0); - } - - to { - transform: translateX(var(--vkui_internal--skeleton_width)); - } -} - .Skeleton { - --vkui_internal--skeleton_width: 300px; --vkui_internal--skeleton_color_from: var(--vkui--color_skeleton_from); --vkui_internal--skeleton_color_to: var(--vkui--color_skeleton_to); --vkui_internal--skeleton_animation_duration: 1.5s; + --vkui_internal--skeleton_gradient_left: 0; display: inline-flex; position: relative; @@ -21,26 +11,7 @@ line-height: 1; border-radius: 6px; overflow: hidden; -} - -.Skeleton::before { - content: ''; - position: absolute; - inset: 0; - inset-inline-start: calc(-1 * var(--vkui_internal--skeleton_width)); - animation-duration: var(--vkui_internal--skeleton_animation_duration); - animation-iteration-count: infinite; - animation-name: skeleton; - background-attachment: fixed; - animation-timing-function: var(--vkui--animation_easing_platform); - background-size: var(--vkui_internal--skeleton_width) 100%; background-color: var(--vkui_internal--skeleton_color_from); - background-image: linear-gradient( - to right, - var(--vkui_internal--skeleton_color_from) 0, - var(--vkui_internal--skeleton_color_to) 50%, - var(--vkui_internal--skeleton_color_from) 75% - ); } /* Если скелетон находится внутри другого скелетона он меняет цвет */ @@ -54,7 +25,34 @@ --vkui_internal--skeleton_color_to: var(--vkui--color_skeleton_to); } -.Skeleton--disableAnimation { +.Skeleton::before { + position: absolute; + inset-inline-start: var(--vkui_internal--skeleton_gradient_left); + inset-block-start: 0; + content: ' '; + inline-size: 100vw; + block-size: 100%; + background-image: linear-gradient( + 90deg, + var(--vkui_internal--skeleton_color_from), + var(--vkui_internal--skeleton_color_to), + var(--vkui_internal--skeleton_color_from) + ); + transform: translateX(-100vw); + animation-name: animation-skeleton; + animation-direction: normal /*rtl:reverse*/; + animation-duration: 1.5s; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; +} + +@keyframes animation-skeleton { + 100% { + transform: translateX(100vw); + } +} + +.Skeleton--disableAnimation::before { /** * Safari тратит время не пересчет анимации даже если элемент скрыт * Для повышения производительности анимацию необходимо выключить @@ -64,7 +62,7 @@ } @media (--reduce-motion) { - .Skeleton { + .Skeleton::before { animation-name: none; background-image: none; } diff --git a/packages/vkui/src/components/Skeleton/Skeleton.tsx b/packages/vkui/src/components/Skeleton/Skeleton.tsx index 13099e5a0d..0f2935d45b 100644 --- a/packages/vkui/src/components/Skeleton/Skeleton.tsx +++ b/packages/vkui/src/components/Skeleton/Skeleton.tsx @@ -1,9 +1,89 @@ import * as React from 'react'; import { classNames } from '@vkontakte/vkjs'; +import { millisecondsInSecond } from 'date-fns/constants'; +import { useExternRef } from '../../hooks/useExternRef'; +import { useGlobalEventListener } from '../../hooks/useGlobalEventListener'; +import { usePrevious } from '../../hooks/usePrevious'; +import { useDOM } from '../../lib/dom'; import type { CSSCustomProperties, HTMLAttributesWithRootRef } from '../../types'; import { RootComponent } from '../RootComponent/RootComponent'; import styles from './Skeleton.module.css'; +const CUSTOM_PROPERTY_GRADIENT_LEFT = '--vkui_internal--skeleton_gradient_left'; + +/** + * Синхронизирует анимацию скелетонов с помощью временных отрезков + * + * ## visibilitychange + * + * В синхронизацию не заложен механизм перехода на оптимизации браузеров при + * переходе на другую вкладку, поскольку нет уверенности в реальности таких + * кейсов со скелетонами. Если такой кейс принесут, необходимо обработать + * событие `visibilitychange` используя функцию `syncAnimation` + * + * https://developer.chrome.com/blog/page-lifecycle-api/ + * + * @param duration длительность анимации в секундах + */ +function useSkeletonSyncAnimation(disableAnimation: boolean, duration = 1.5) { + const [isAnimationStarted, setIsAnimationStarted] = React.useState(false); + const timer = React.useRef | undefined>(undefined); + + const syncAnimation = React.useCallback(() => { + clearTimeout(timer.current); + setIsAnimationStarted(false); + + const durationInMilliseconds = duration * millisecondsInSecond; + const delay = durationInMilliseconds - (performance.now() % durationInMilliseconds); + + timer.current = setTimeout(() => setIsAnimationStarted(true), delay); + + return () => clearTimeout(timer.current); + }, [duration]); + + React.useEffect(() => { + if (disableAnimation) { + setIsAnimationStarted(false); + return; + } + + if (isAnimationStarted) { + return; + } + + return syncAnimation(); + }, [disableAnimation, isAnimationStarted, syncAnimation]); + + return isAnimationStarted; +} + +/** + * Вычисляет позицию скелетона + */ +function useSkeletonPosition(rootRef: React.MutableRefObject) { + const { document, window } = useDOM(); + const [skeletonGradientLeft, setSkeletonGradientLeft] = React.useState('0'); + const prevSkeletonGradientLeft = usePrevious(skeletonGradientLeft); + + const updatePosition = React.useCallback(() => { + const el = rootRef.current; + if (!el || !document) { + return; + } + + const value = -(el.getBoundingClientRect().left - document.body.getBoundingClientRect().left); + const gradientValue = value === 0 ? '0' : `${value}px`; + if (prevSkeletonGradientLeft !== gradientValue) { + setSkeletonGradientLeft(gradientValue); + } + }, [document, prevSkeletonGradientLeft, rootRef]); + + React.useEffect(updatePosition, [updatePosition]); + useGlobalEventListener(window, 'resize', updatePosition); + + return skeletonGradientLeft; +} + export interface SkeletonProps extends HTMLAttributesWithRootRef, Pick< @@ -61,11 +141,17 @@ export const Skeleton = ({ children, colorFrom, colorTo, - noAnimation, + noAnimation = false, duration, margin, + getRootRef, ...restProps }: SkeletonProps): React.ReactNode => { + const rootRef = useExternRef(getRootRef); + + const disableAnimation = !useSkeletonSyncAnimation(noAnimation, duration); + const skeletonGradientLeft = useSkeletonPosition(rootRef); + const skeletonStyle: React.CSSProperties & CSSCustomProperties = { width, height, @@ -75,6 +161,7 @@ export const Skeleton = ({ maxInlineSize, borderRadius, margin, + [CUSTOM_PROPERTY_GRADIENT_LEFT]: skeletonGradientLeft, }; if (colorFrom) { @@ -91,10 +178,11 @@ export const Skeleton = ({ return (