From 11e938fbdc5cd35b3989ad3ec8315fa30f4e1047 Mon Sep 17 00:00:00 2001 From: Victoria Zhizhonkova Date: Mon, 2 Oct 2023 21:07:53 +0700 Subject: [PATCH] feat(Carousel): add Gallery with loop (#5744) * feat(Carousel): add Gallery with loop --- .../BaseGallery/CarouselBase/CarouselBase.tsx | 381 ++++++++++++++++++ .../BaseGallery/CarouselBase/constants.ts | 20 + .../BaseGallery/CarouselBase/helpers.ts | 136 +++++++ .../BaseGallery/CarouselBase/hooks.ts | 37 ++ .../BaseGallery/CarouselBase/types.ts | 56 +++ .../vkui/src/components/Gallery/Gallery.tsx | 16 +- .../vkui/src/components/Gallery/Readme.md | 220 +++++----- packages/vkui/src/lib/animate.ts | 22 +- packages/vkui/src/lib/fx.ts | 11 + 9 files changed, 794 insertions(+), 105 deletions(-) create mode 100644 packages/vkui/src/components/BaseGallery/CarouselBase/CarouselBase.tsx create mode 100644 packages/vkui/src/components/BaseGallery/CarouselBase/constants.ts create mode 100644 packages/vkui/src/components/BaseGallery/CarouselBase/helpers.ts create mode 100644 packages/vkui/src/components/BaseGallery/CarouselBase/hooks.ts create mode 100644 packages/vkui/src/components/BaseGallery/CarouselBase/types.ts diff --git a/packages/vkui/src/components/BaseGallery/CarouselBase/CarouselBase.tsx b/packages/vkui/src/components/BaseGallery/CarouselBase/CarouselBase.tsx new file mode 100644 index 0000000000..33d09a949e --- /dev/null +++ b/packages/vkui/src/components/BaseGallery/CarouselBase/CarouselBase.tsx @@ -0,0 +1,381 @@ +import * as React from 'react'; +import { classNames } from '@vkontakte/vkjs'; +import { useAdaptivityHasPointer } from '../../../hooks/useAdaptivityHasPointer'; +import { useExternRef } from '../../../hooks/useExternRef'; +import { useGlobalEventListener } from '../../../hooks/useGlobalEventListener'; +import { useDOM } from '../../../lib/dom'; +import { useIsomorphicLayoutEffect } from '../../../lib/useIsomorphicLayoutEffect'; +import { warnOnce } from '../../../lib/warnOnce'; +import { RootComponent } from '../../RootComponent/RootComponent'; +import { ScrollArrow } from '../../ScrollArrow/ScrollArrow'; +import { Touch, TouchEvent } from '../../Touch/Touch'; +import { BaseGalleryProps, GallerySlidesState } from '../types'; +import { ANIMATION_DURATION, CONTROL_ELEMENTS_STATE, SLIDES_MANAGER_STATE } from './constants'; +import { calculateIndent, getLoopPoints, getTargetIndex } from './helpers'; +import { useSlideAnimation } from './hooks'; +import { ControlElementsState, SlidesManagerState } from './types'; +import styles from '../BaseGallery.module.css'; + +const stylesBullets = { + dark: styles['BaseGallery__bullets--dark'], + light: styles['BaseGallery__bullets--light'], +}; + +const warn = warnOnce('Gallery'); + +export const CarouselBase = ({ + bullets = false, + getRootRef, + children, + slideWidth = '100%', + slideIndex = 0, + isDraggable: isDraggableProp = true, + onDragStart, + onDragEnd, + onChange, + onPrevClick, + onNextClick, + align = 'left', + showArrows, + getRef, + arrowSize = 'l', + ...restProps +}: BaseGalleryProps) => { + const slidesStore = React.useRef>({}); + const slidesManager = React.useRef(SLIDES_MANAGER_STATE); + + const rootRef = useExternRef(getRootRef); + const viewportRef = useExternRef(getRef); + const layerRef = React.useRef(null); + const animationFrameRef = React.useRef | null>(null); + const shiftXCurrentRef = React.useRef(0); + const shiftXDeltaRef = React.useRef(0); + const initialized = React.useRef(false); + const { addToAnimationQueue, getAnimateFunction, startAnimation } = useSlideAnimation(); + + const [controlElementsState, setControlElementsState] = + React.useState(CONTROL_ELEMENTS_STATE); + + const { window } = useDOM(); + const hasPointer = useAdaptivityHasPointer(); + + const isCenterWithCustomWidth = slideWidth === 'custom' && align === 'center'; + + const transformCssStyles = (shiftX: number, animation = false) => { + slidesManager.current.loopPoints.forEach((loopPoint) => { + const { target, index } = loopPoint; + const slide = slidesStore.current[index]; + if (slide) { + slide.style.transform = `translate3d(${target(shiftX)}px, 0, 0)`; + } + }); + + if (layerRef.current) { + layerRef.current.style.transform = `translate3d(${shiftX}px, 0, 0)`; + layerRef.current.style.transition = animation + ? `transform ${ANIMATION_DURATION}ms cubic-bezier(.1, 0, .25, 1)` + : ''; + } + }; + + const requestTransform = (shiftX: number, animation = false) => { + const { snaps, contentSize, slides } = slidesManager.current; + + if (animationFrameRef.current !== null) { + cancelAnimationFrame(animationFrameRef.current); + } + animationFrameRef.current = requestAnimationFrame(() => { + if (shiftX > snaps[0]) { + shiftXCurrentRef.current = -contentSize + snaps[0]; + shiftX = shiftXCurrentRef.current + shiftXDeltaRef.current; + } + const lastPoint = slides[slides.length - 1].width + slides[slides.length - 1].coordX; + + if (shiftX <= -lastPoint) { + shiftXCurrentRef.current = Math.abs(shiftXDeltaRef.current) + snaps[0]; + } + transformCssStyles(shiftX, animation); + }); + }; + + const initializeSlides = () => { + if (!rootRef.current || !viewportRef.current) { + return; + } + let localSlides = + React.Children.map(children, (_item, i): GallerySlidesState => { + const elem = slidesStore.current[i] || { offsetLeft: 0, offsetWidth: 0 }; + return { coordX: elem.offsetLeft, width: elem.offsetWidth }; + }) || []; + + const containerWidth = rootRef.current.offsetWidth; + const viewportOffsetWidth = viewportRef.current.offsetWidth; + const layerWidth = localSlides.reduce((val, slide) => slide.width + val, 0); + + if (process.env.NODE_ENV === 'development') { + let remainingWidth = containerWidth; + let slideIndex = 0; + + while (remainingWidth > 0 && slideIndex < localSlides.length) { + remainingWidth -= localSlides[slideIndex].width; + slideIndex++; + } + if (remainingWidth <= 0 && slideIndex === localSlides.length) { + warn( + 'Ширины слайдов недостаточно для корректной работы свойства "looped". Пожалуйста, сделайте её больше."', + ); + } + } + if (align === 'center') { + const firstSlideShift = (containerWidth - localSlides[0].width) / 2; + localSlides = localSlides.map((item) => { + return { + width: item.width, + coordX: item.coordX - firstSlideShift, + }; + }); + } + + slidesManager.current = { + ...slidesManager.current, + viewportOffsetWidth, + slides: localSlides, + isFullyVisible: layerWidth <= containerWidth, + }; + + const snaps = localSlides.map((_, index) => + calculateIndent(index, slidesManager.current, isCenterWithCustomWidth), + ); + + let contentSize = -snaps[snaps.length - 1] + localSlides[localSlides.length - 1].width; + if (align === 'center') { + contentSize += snaps[0]; + } + + slidesManager.current.snaps = snaps; + slidesManager.current.contentSize = contentSize; + slidesManager.current.loopPoints = getLoopPoints(slidesManager.current, containerWidth); + + setControlElementsState({ + canSlideLeft: !slidesManager.current.isFullyVisible, + canSlideRight: !slidesManager.current.isFullyVisible, + isDraggable: isDraggableProp && !slidesManager.current.isFullyVisible, + }); + + shiftXCurrentRef.current = snaps[slideIndex]; + initialized.current = true; + + requestTransform(shiftXCurrentRef.current); + }; + + const onResize = () => { + if (initialized.current) { + initializeSlides(); + } + }; + + useGlobalEventListener(window, 'resize', onResize); + + useIsomorphicLayoutEffect( + function performSlideChange() { + if (!initialized.current) { + return; + } + const { snaps, slides } = slidesManager.current; + const indent = snaps[slideIndex]; + let startPoint = shiftXCurrentRef.current; + + /** + * Переключаемся с последнего элемента на первый + * Для корректной анимации мы прокручиваем последний слайд на всю длину (shiftX) "вперед" + * В конце анимации при отрисовке следующего кадра задаем всем слайдам начальные значения + */ + if (indent === snaps[0] && shiftXCurrentRef.current <= snaps[snaps.length - 1]) { + const distance = + Math.abs(snaps[snaps.length - 1]) + slides[slides.length - 1].width + startPoint; + + addToAnimationQueue( + getAnimateFunction((progress) => { + const shiftX = startPoint + progress * distance * -1; + + transformCssStyles(shiftX); + + if (shiftX <= snaps[snaps.length - 1] - slides[slides.length - 1].width) { + requestAnimationFrame(() => { + shiftXCurrentRef.current = indent; + transformCssStyles(snaps[0]); + }); + } + }), + ); + /** + * Переключаемся с первого слайда на последний + * Для корректной анимации сначала задаем первым видимым слайдам смещение + * В следующем кадре начинаем анимация прокрутки "назад" + */ + } else if (indent === snaps[snaps.length - 1] && shiftXCurrentRef.current === snaps[0]) { + startPoint = indent - slides[slides.length - 1].width; + + addToAnimationQueue(() => { + requestAnimationFrame(() => { + const shiftX = indent - slides[slides.length - 1].width; + transformCssStyles(shiftX); + + getAnimateFunction((progress) => { + transformCssStyles(startPoint + progress * slides[slides.length - 1].width); + })(); + }); + }); + /** + * Если не обработаны `corner`-кейсы выше, то просто проигрываем анимацию смещения + */ + } else { + addToAnimationQueue(() => { + const distance = Math.abs(indent - startPoint); + let direction = startPoint <= indent ? 1 : -1; + + getAnimateFunction((progress) => { + const shiftX = startPoint + progress * distance * direction; + transformCssStyles(shiftX); + })(); + }); + } + + startAnimation(); + + shiftXCurrentRef.current = indent; + }, + [slideIndex], + ); + + useIsomorphicLayoutEffect(() => { + initializeSlides(); + }, [children, align, slideWidth]); + + const slideLeft = (event: React.MouseEvent) => { + onChange?.( + (slideIndex - 1 + slidesManager.current.slides.length) % slidesManager.current.slides.length, + ); + onPrevClick?.(event); + }; + + const slideRight = (event: React.MouseEvent) => { + onChange?.((slideIndex + 1) % slidesManager.current.slides.length); + onNextClick?.(event); + }; + + const onStart = (e: TouchEvent) => { + e.originalEvent.stopPropagation(); + onDragStart?.(e); + shiftXCurrentRef.current = slidesManager.current.snaps[slideIndex]; + shiftXDeltaRef.current = 0; + }; + + const onMoveX = (e: TouchEvent) => { + if (isDraggableProp && !slidesManager.current.isFullyVisible) { + e.originalEvent.preventDefault(); + + if (e.isSlideX) { + if (shiftXDeltaRef.current !== e.shiftX) { + shiftXDeltaRef.current = e.shiftX; + requestTransform(shiftXCurrentRef.current + shiftXDeltaRef.current); + } + } + } + }; + + const onEnd = (e: TouchEvent) => { + let targetIndex = slideIndex; + if (e.isSlide) { + targetIndex = getTargetIndex( + slidesManager.current.slides, + slideIndex, + shiftXCurrentRef.current, + shiftXDeltaRef.current, + ); + } + onDragEnd?.(e, targetIndex); + + if (targetIndex !== slideIndex) { + shiftXCurrentRef.current = shiftXCurrentRef.current + shiftXDeltaRef.current; + onChange?.(targetIndex); + } else { + const initialShiftX = slidesManager.current.snaps[targetIndex]; + requestTransform(initialShiftX, true); + } + }; + + const setSlideRef = (slideRef: HTMLDivElement | null, slideIndex: number) => { + slidesStore.current[slideIndex] = slideRef; + }; + + const { canSlideLeft, canSlideRight, isDraggable } = controlElementsState; + + return ( + + +
+ {React.Children.map(children, (item: React.ReactNode, i: number) => ( +
setSlideRef(el, i)} + > + {item} +
+ ))} +
+
+ + {bullets && ( +
+ {React.Children.map(children, (_item: React.ReactNode, index: number) => ( +
+ ))} +
+ )} + + {showArrows && hasPointer && canSlideLeft && ( + + )} + {showArrows && hasPointer && canSlideRight && ( + + )} + + ); +}; diff --git a/packages/vkui/src/components/BaseGallery/CarouselBase/constants.ts b/packages/vkui/src/components/BaseGallery/CarouselBase/constants.ts new file mode 100644 index 0000000000..0cc18332aa --- /dev/null +++ b/packages/vkui/src/components/BaseGallery/CarouselBase/constants.ts @@ -0,0 +1,20 @@ +import { ControlElementsState, SlidesManagerState } from './types'; + +export const ANIMATION_DURATION = 240; + +export const SLIDE_THRESHOLD = 0.05; + +export const CONTROL_ELEMENTS_STATE: ControlElementsState = { + canSlideLeft: true, + canSlideRight: true, + isDraggable: true, +}; + +export const SLIDES_MANAGER_STATE: SlidesManagerState = { + viewportOffsetWidth: 0, + slides: [], + isFullyVisible: true, + loopPoints: [], + contentSize: 0, + snaps: [], +}; diff --git a/packages/vkui/src/components/BaseGallery/CarouselBase/helpers.ts b/packages/vkui/src/components/BaseGallery/CarouselBase/helpers.ts new file mode 100644 index 0000000000..11d6835ce1 --- /dev/null +++ b/packages/vkui/src/components/BaseGallery/CarouselBase/helpers.ts @@ -0,0 +1,136 @@ +import { GallerySlidesState } from '../types'; +import { SLIDE_THRESHOLD } from './constants'; +import { LoopPoint, SlidesManagerState } from './types'; + +/* + * Считает отступ слоя галереи + */ +export function calculateIndent( + targetIndex: number, + slidesManager: SlidesManagerState, + isCenterWithCustomWidth: boolean, +) { + if (slidesManager.isFullyVisible || !slidesManager.slides.length) { + return 0; + } + + const targetSlide = slidesManager.slides[targetIndex]; + + if (targetSlide) { + const { coordX, width } = targetSlide; + + if (isCenterWithCustomWidth) { + return slidesManager.viewportOffsetWidth / 2 - coordX - width / 2; + } + + return -1 * coordX; + } + + return 0; +} + +/** + * Вычисляем индексы слайдов, которые необходимо смещать + */ +export function getShiftedIndexes( + direction: 1 | -1, + slides: GallerySlidesState[], + availableWidth: number, +) { + let gap = availableWidth; + const shiftedSlideIndexes = []; + const startIndex = direction === 1 ? 0 : slides.length - 1; + const endIndex = direction === 1 ? slides.length - 1 : 0; + + for ( + let i = startIndex; + (direction === 1 ? i <= endIndex : i >= endIndex) && gap > 0; + i += direction + ) { + const slideWidth = slides[i].width; + + if (gap > 0) { + shiftedSlideIndexes.push(i); + } + gap -= slideWidth; + } + + return shiftedSlideIndexes; +} + +export function calculateLoopPoints( + indexes: number[], + edge: 'start' | 'end', + slidesManager: SlidesManagerState, + containerWidth: number, +): LoopPoint[] { + const { contentSize, slides, snaps } = slidesManager; + const isStartEdge = edge === 'start'; + const offset = isStartEdge ? -contentSize : contentSize; + + return indexes.map((index) => { + const initial = isStartEdge ? 0 : -contentSize; + const altered = isStartEdge ? contentSize : 0; + const loopPoint = isStartEdge + ? snaps[index] + containerWidth + offset + : snaps[index] - slides[index].width + offset - snaps[0]; + + return { + index, + target: (currentLocation) => { + return currentLocation >= loopPoint ? initial : altered; + }, + }; + }); +} + +/** + * Вычисляем "ключевые" точки, на которых должно происходить смещение слайдов + */ +export function getLoopPoints(slidesManager: SlidesManagerState, containerWidth: number) { + const { slides, snaps } = slidesManager; + const startShiftedIndexes = getShiftedIndexes(-1, slides, snaps[0]); + const endShiftedIndexes = getShiftedIndexes(1, slides, containerWidth - snaps[0]); + + return [ + ...calculateLoopPoints(endShiftedIndexes, 'start', slidesManager, containerWidth), + ...calculateLoopPoints(startShiftedIndexes, 'end', slidesManager, containerWidth), + ]; +} + +/* + * Получает индекс слайда, к которому будет осуществлен переход + */ +export function getTargetIndex( + slides: GallerySlidesState[], + slideIndex: number, + currentShiftX: number, + currentShiftXDelta: number, +) { + const shift = currentShiftX + currentShiftXDelta; + const direction = currentShiftXDelta < 0 ? 1 : -1; + + // Находим ближайшую границу слайда к текущему отступу + let targetIndex = slides.reduce((val: number, item: GallerySlidesState, index: number) => { + const previousValue = Math.abs(slides[val].coordX + shift); + const currentValue = Math.abs(item.coordX + shift); + + return previousValue < currentValue ? val : index; + }, slideIndex); + + if (targetIndex === slideIndex) { + let targetSlide = slideIndex + direction; + + if (targetSlide >= 0 && targetSlide < slides.length) { + if (Math.abs(currentShiftXDelta) > slides[targetSlide].width * SLIDE_THRESHOLD) { + return targetSlide; + } + return targetIndex; + } + return direction < 0 + ? (targetSlide + slides.length) % slides.length + : targetSlide % slides.length; + } + + return targetIndex; +} diff --git a/packages/vkui/src/components/BaseGallery/CarouselBase/hooks.ts b/packages/vkui/src/components/BaseGallery/CarouselBase/hooks.ts new file mode 100644 index 0000000000..a0c0fcc505 --- /dev/null +++ b/packages/vkui/src/components/BaseGallery/CarouselBase/hooks.ts @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { animate, DrawInterface } from '../../../lib/animate'; +import { cubicBezier } from '../../../lib/fx'; +import { ANIMATION_DURATION } from './constants'; + +const TIMING_FUNCTION = cubicBezier(0.8, 1); + +export function useSlideAnimation() { + const animationQueue = React.useRef([]); + + function getAnimateFunction(drawFunction: DrawInterface) { + return () => { + animate({ + duration: ANIMATION_DURATION, + timing: TIMING_FUNCTION, + animationQueue: animationQueue.current, + draw: drawFunction, + }); + }; + } + + function addToAnimationQueue(func: VoidFunction) { + animationQueue.current.push(func); + } + + function startAnimation() { + if (animationQueue.current.length === 1) { + animationQueue.current[0](); + } + } + + return { + getAnimateFunction, + addToAnimationQueue, + startAnimation, + }; +} diff --git a/packages/vkui/src/components/BaseGallery/CarouselBase/types.ts b/packages/vkui/src/components/BaseGallery/CarouselBase/types.ts new file mode 100644 index 0000000000..5842ccf0d3 --- /dev/null +++ b/packages/vkui/src/components/BaseGallery/CarouselBase/types.ts @@ -0,0 +1,56 @@ +import { GallerySlidesState } from '../types'; + +export interface LoopPoint { + /** + * Индекс слайда + */ + index: number; + /** + * Функция, которая по текущему сдвигу галереи определяет нужный сдвиг слайда + */ + target(this: void, location: number): void; +} + +export interface ControlElementsState { + /** + * Отвечает за отображение стрелки влево + */ + canSlideLeft: boolean; + /** + * Отвечает за отображение стрелки вправо + */ + canSlideRight: boolean; + /** + * Возможность листаться слайды drag'ом + */ + isDraggable: boolean; +} + +export interface SlidesManagerState { + /** + * Общая ширина всех слайдов + */ + contentSize: number; + /** + * Массив с пограничными точками слайдов, которые необходимо смещать, чтобы они всегда были в области видимости + * (пример: для последнего слайда это n-первых слайдов, необходимых для заполнения оставшейся ширины, + * или для первого слайда это n-последних слайдов при выравнивании по центру) + */ + loopPoints: LoopPoint[]; + /** + * Массив с правыми границами слайдов + */ + snaps: number[]; + /** + * Ширина видимой области слайдов + */ + viewportOffsetWidth: number; + /** + * Массив слайдов с координатой начала слайда и шириной + */ + slides: GallerySlidesState[]; + /** + * Все слайды видимы без скрола + */ + isFullyVisible: boolean; +} diff --git a/packages/vkui/src/components/Gallery/Gallery.tsx b/packages/vkui/src/components/Gallery/Gallery.tsx index e9d2a1229f..f1abcdbb82 100644 --- a/packages/vkui/src/components/Gallery/Gallery.tsx +++ b/packages/vkui/src/components/Gallery/Gallery.tsx @@ -1,12 +1,16 @@ import * as React from 'react'; import { clamp } from '../../helpers/math'; +import { useIsClient } from '../../hooks/useIsClient'; import { useTimeout } from '../../hooks/useTimeout'; import { BaseGallery } from '../BaseGallery/BaseGallery'; +import { CarouselBase } from '../BaseGallery/CarouselBase/CarouselBase'; import { BaseGalleryProps } from '../BaseGallery/types'; export interface GalleryProps extends BaseGalleryProps { initialSlideIndex?: number; timeout?: number; + // Отвечает за зацикливание слайдов + looped?: boolean; } /** @@ -18,6 +22,7 @@ export const Gallery = ({ timeout = 0, onChange, bullets, + looped, ...props }: GalleryProps) => { const [localSlideIndex, setSlideIndex] = React.useState(initialSlideIndex); @@ -29,6 +34,7 @@ export const Gallery = ({ [children], ); const childCount = slides.length; + const isClient = useIsClient(); const handleChange: GalleryProps['onChange'] = React.useCallback( (current: number) => { @@ -58,8 +64,14 @@ export const Gallery = ({ setSlideIndex(safeSlideIndex); }, [onChange, safeSlideIndex, slideIndex]); + if (!isClient) { + return null; + } + + const Component = looped ? CarouselBase : BaseGallery; + return ( - 0 && bullets} @@ -67,6 +79,6 @@ export const Gallery = ({ onChange={handleChange} > {slides} - + ); }; diff --git a/packages/vkui/src/components/Gallery/Readme.md b/packages/vkui/src/components/Gallery/Readme.md index 842dd160e3..2603cf3922 100644 --- a/packages/vkui/src/components/Gallery/Readme.md +++ b/packages/vkui/src/components/Gallery/Readme.md @@ -1,101 +1,127 @@ ```jsx -const [slideIndex, setSlideIndex] = useState(0); -const [isDraggable, setIsDraggable] = useState(true); -const [showArrows, setShowArrows] = useState(true); +const slideStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '72px', +}; - - - Gallery - Sticks right}> - -
- -
- - - Sticks left}> - -
-
-
- - - Centered}> - -
-
-
- - - Custom width}> - -
-
-
-
- - - Arrows}> - -
-
-
- - - Controlled}> - -
-
-
- +const Slide = ({ children, width, backgroundColor }) => ( +
{children}
+); - - setIsDraggable(e.target.checked)}> - isDraggable - - setShowArrows(e.target.checked)}> - showArrows - - - - - - - -; +const Example = () => { + const [slideIndex, setSlideIndex] = useState(0); + const [isDraggable, setIsDraggable] = useState(true); + const [showArrows, setShowArrows] = useState(true); + + return ( + + + Gallery + Sticks right}> + +
+ +
+ + + Sticks left}> + +
+
+
+ + + Centered}> + +
+
+
+ + + Custom width}> + +
+
+
+
+ + + Arrows}> + +
+
+
+ + + Controlled}> + +
+
+
+ + + + setIsDraggable(e.target.checked)}> + isDraggable + + setShowArrows(e.target.checked)}> + showArrows + + + + + + + With looped prop}> + + 1 + 2 + 3 + 4 + 5 + + + + + ); +}; + +; ``` diff --git a/packages/vkui/src/lib/animate.ts b/packages/vkui/src/lib/animate.ts index 8bf94c8aa9..4d8dcdc2d6 100644 --- a/packages/vkui/src/lib/animate.ts +++ b/packages/vkui/src/lib/animate.ts @@ -15,21 +15,26 @@ export interface AnimateArgumentsInterface { duration: number; timing: TimingInterface; draw: DrawInterface; + animationQueue: VoidFunction[]; } -export function animate({ duration, timing, draw }: AnimateArgumentsInterface): void { +export function animate({ + duration, + timing, + draw, + animationQueue = [], +}: AnimateArgumentsInterface): void { if (!canUseDOM) { return; } - const start = performance.now(); + let start: number; requestAnimationFrame(function animate(time: number): void { - let timeFraction = (time - start) / duration; - - if (timeFraction > 1) { - timeFraction = 1; + if (!start) { + start = time; } + let timeFraction = Math.min((time - start) / duration, 1); const progress = timing(timeFraction); @@ -37,6 +42,11 @@ export function animate({ duration, timing, draw }: AnimateArgumentsInterface): if (timeFraction < 1) { requestAnimationFrame(animate); + return; + } + animationQueue.shift(); + if (animationQueue.length > 0) { + animationQueue[0](); } }); } diff --git a/packages/vkui/src/lib/fx.ts b/packages/vkui/src/lib/fx.ts index 3ac554eb48..535197093b 100644 --- a/packages/vkui/src/lib/fx.ts +++ b/packages/vkui/src/lib/fx.ts @@ -5,3 +5,14 @@ export function easeInOutSine(x: number) { return 0.5 * (1 - Math.cos(Math.PI * x)); } + +export function cubicBezier(x1: number, x2: number) { + return function (progress: number) { + const t = progress; + const cx = 3 * x1; + const bx = 3 * (x2 - x1) - cx; + const ax = 1 - cx - bx; + const x = ax * Math.pow(t, 3) + bx * Math.pow(t, 2) + cx * t; + return x; + }; +}