diff --git a/packages/vkui/src/components/Tappable/Ripple.tsx b/packages/vkui/src/components/Tappable/Ripple.tsx index 36e3704541f..8ccbef418c7 100644 --- a/packages/vkui/src/components/Tappable/Ripple.tsx +++ b/packages/vkui/src/components/Tappable/Ripple.tsx @@ -1,116 +1,171 @@ +'use client'; + import * as React from 'react'; -import { classNames, hasMouse as hasPointerLib, noop } from '@vkontakte/vkjs'; -import { usePlatform } from '../../hooks/usePlatform'; +import { classNames, noop } from '@vkontakte/vkjs'; import { getOffsetRect } from '../../lib/offset'; +import { type CSSCustomProperties } from '../../types'; import styles from './Tappable.module.css'; /* eslint-disable jsdoc/require-jsdoc */ -/** - * Возможно нужен Ripple эффект. Данный хук нужен для отказа - * от двойного ререндера. - */ -export const useMaybeNeedRipple = ( - activeMode: string, - hasPointer: boolean | undefined, -): boolean => { - const platform = usePlatform(); - - return platform === 'android' && !hasPointer && activeMode === 'background'; -}; +type WaveState = 'inactive' | 'fadeIn' | 'fadeOut'; interface Wave { x: number; y: number; - id: number; - pointerId: number; + rippleSize: number; + rippleScale: number; + state: WaveState; } -const DELAY = 70; -const WAVE_LIVE = 225; +const TOUCH_DELAY = 150; + +const INITIAL_ORIGIN_SCALE = 0.2; +const SOFT_EDGE_MINIMUM_SIZE = 75; +const SOFT_EDGE_CONTAINER_RATIO = 0.35; +const PADDING = 10; + +function calcRippleSize(width: number, height: number): number { + return Math.floor(Math.max(width, height) * INITIAL_ORIGIN_SCALE); +} + +function calcRippleScale(initialRippleSize: number, width: number, height: number) { + const maxDim = Math.max(width, height); + const softEdgeSize = Math.max(SOFT_EDGE_CONTAINER_RATIO * maxDim, SOFT_EDGE_MINIMUM_SIZE); + + const diagonal = Math.sqrt(width ** 2 + height ** 2); + const maxRadius = diagonal * 2 + PADDING; + + return (maxRadius + softEdgeSize) / initialRippleSize; +} /** * Хук для создания Ripple эффектов. */ export const useRipple = ( needRipple: boolean, - hasPointerContext: boolean | undefined, ): { - clicks: Wave[]; + wave: Wave; onPointerDown: React.PointerEventHandler; + onPointerUp: React.PointerEventHandler; onPointerCancel: React.PointerEventHandler; + onWaveAnimationEnd: React.AnimationEventHandler; } => { - const [clicks, setClicks] = React.useState([]); - - /** - * Коллекция нажатий и таймеров задержки появления волны. - */ - const pointerDelayTimers = React.useRef>>(null); - if (pointerDelayTimers.current === null) { - pointerDelayTimers.current = new Map(); - } + const pointerDelayTimerRef = React.useRef>(undefined); - React.useEffect( - function setClearClicksTimeout() { - const clicksTimeoutId = clicks.length > 0 ? setTimeout(() => setClicks([]), WAVE_LIVE) : null; - return function cancelClearClicksTimeout() { - if (clicksTimeoutId) { - clearTimeout(clicksTimeoutId); - } - }; - }, - [clicks], - ); + const pointerDownRef = React.useRef(false); + const fadeInAnimationInProgressRef = React.useRef(false); - function addClick(x: number, y: number, pointerId: number) { - const dateNow = Date.now(); - const filteredClicks = clicks.filter((click) => click.id + WAVE_LIVE > dateNow); + const [state, setState] = React.useState('inactive'); - setClicks([...filteredClicks, { x, y, id: dateNow, pointerId }]); - pointerDelayTimers.current!.delete(pointerId); + const [x, setX] = React.useState(0); + const [y, setY] = React.useState(0); + const [rippleSize, setRippleSize] = React.useState(0); + const [rippleScale, setRippleScale] = React.useState(0); + + const checkEndFadeIn = () => { + if (state !== 'fadeIn' || pointerDownRef.current || fadeInAnimationInProgressRef.current) { + return; + } + + setState('fadeOut'); + }; + + const onAnimationEnd = () => { + switch (state) { + case 'fadeIn': + fadeInAnimationInProgressRef.current = false; + checkEndFadeIn(); + break; + case 'fadeOut': + setState('inactive'); + break; + } + }; + + function addClick(x: number, y: number, rippleSize: number, rippleScale: number) { + setState('fadeIn'); + fadeInAnimationInProgressRef.current = true; + setX(x); + setY(y); + setRippleSize(rippleSize); + setRippleScale(rippleScale); } - /** - * Добавляем волну с задержкой. Задержка необходима при отмене волны. - */ const onPointerDown: React.PointerEventHandler = (e) => { - const { top, left } = getOffsetRect(e.currentTarget); + setState('inactive'); + const { top, left, width, height } = getOffsetRect(e.currentTarget); + + const rippleSize = calcRippleSize(width, height); + const rippleScale = calcRippleScale(rippleSize, width, height); + const x = e.clientX - (left ?? 0); const y = e.clientY - (top ?? 0); - pointerDelayTimers.current!.set( - e.pointerId, - setTimeout(() => addClick(x, y, e.pointerId), DELAY), - ); + const delay = e.pointerType === 'touch' ? TOUCH_DELAY : 0; + + pointerDelayTimerRef.current = setTimeout(() => addClick(x, y, rippleSize, rippleScale), delay); + pointerDownRef.current = true; }; - const onPointerCancel: React.PointerEventHandler = (e) => { - const timer = pointerDelayTimers.current!.get(e.pointerId); - clearTimeout(timer); - pointerDelayTimers.current!.delete(e.pointerId); + const onPointerCancel: React.PointerEventHandler = () => { + clearTimeout(pointerDelayTimerRef.current); + pointerDownRef.current = false; + checkEndFadeIn(); }; - // WARNING: не использовать для рендеринга - const reallyNeedRipple = (!hasPointerLib || hasPointerContext === false) && needRipple; + const onPointerUp: React.PointerEventHandler = () => { + pointerDownRef.current = false; + checkEndFadeIn(); + }; return { - clicks, - onPointerDown: reallyNeedRipple ? onPointerDown : noop, - onPointerCancel: reallyNeedRipple ? onPointerCancel : noop, + wave: { + x, + y, + rippleSize, + rippleScale, + state, + }, + onWaveAnimationEnd: onAnimationEnd, + onPointerDown: needRipple ? onPointerDown : noop, + onPointerUp: needRipple ? onPointerUp : noop, + onPointerCancel: needRipple ? onPointerCancel : noop, }; }; export interface RippleProps { needRipple: boolean; - clicks: Wave[]; + wave: Wave; + onWaveAnimationEnd: React.AnimationEventHandler; } -export const Ripple = ({ needRipple = true, clicks }: RippleProps): React.ReactNode => { +const stylesState: Record = { + inactive: undefined, + fadeIn: styles.waveFadeIn, + fadeOut: styles.waveFadeOut, +}; + +export const Ripple = ({ + needRipple = true, + wave, + onWaveAnimationEnd, +}: RippleProps): React.ReactNode => { + const style: React.CSSProperties & CSSCustomProperties = { + 'top': wave.y - wave.rippleSize / 2, + 'left': wave.x - wave.rippleSize / 2, + 'width': wave.rippleSize, + 'height': wave.rippleSize, + '--vkui_internal--Tappable-scale': wave.rippleScale.toString(), + }; + return ( - {clicks.map((wave) => ( - - ))} + ); }; diff --git a/packages/vkui/src/components/Tappable/Tappable.module.css b/packages/vkui/src/components/Tappable/Tappable.module.css index 5aceaf3780b..d88b5706d6c 100644 --- a/packages/vkui/src/components/Tappable/Tappable.module.css +++ b/packages/vkui/src/components/Tappable/Tappable.module.css @@ -83,35 +83,52 @@ https://github.com/VKCOM/VKUI/pull/3641 } .wave { + --vkui_internal--Tappable-scale: 8; + position: absolute; - inset-block-start: 0; - inset-inline-start: 0; - inline-size: 24px; - block-size: 24px; - margin-block: -12px 0; - margin-inline: -12px 0; content: ''; - background: var(--vkui--color_transparent--active); - border-radius: 50%; + background: radial-gradient( + closest-side, + var(--vkui--color_transparent--active) max(calc(100% - 70px), 65%), + transparent 100% + ); opacity: 0; - animation: animationWave 0.3s var(--vkui--animation_easing_platform); + transform-origin: center center; +} + +.waveFadeIn { + animation: animationWaveFadeIn 450ms cubic-bezier(0.2, 0, 0, 1) forwards; +} + +.waveFadeOut { + animation: animationWaveFadeOut 375ms linear; } /** * Animations */ -@keyframes animationWave { +@keyframes animationWaveFadeIn { 0% { opacity: 1; transform: scale(1); } - 30% { + 100% { + opacity: 1; + transform: scale(var(--vkui_internal--Tappable-scale)); + } +} + +/** + * Animations + */ +@keyframes animationWaveFadeOut { + 0% { opacity: 1; + transform: scale(var(--vkui_internal--Tappable-scale)); } 100% { opacity: 0; - transform: scale(8); } } diff --git a/packages/vkui/src/components/Tappable/Tappable.tsx b/packages/vkui/src/components/Tappable/Tappable.tsx index 9ae1a637f83..b2da49f3f9a 100644 --- a/packages/vkui/src/components/Tappable/Tappable.tsx +++ b/packages/vkui/src/components/Tappable/Tappable.tsx @@ -5,7 +5,7 @@ import { useAdaptivity } from '../../hooks/useAdaptivity'; import { type SizeTypeValues, ViewWidth, type ViewWidthType } from '../../lib/adaptivity'; import { mergeCalls } from '../../lib/mergeCalls'; import { checkClickable, Clickable, type ClickableProps } from '../Clickable/Clickable'; -import { Ripple, useMaybeNeedRipple, useRipple } from './Ripple'; +import { Ripple, useRipple } from './Ripple'; import { activeClass, DEFAULT_STATE_MODE, hoverClass, type StateProps } from './state'; import styles from './Tappable.module.css'; @@ -73,6 +73,7 @@ export const Tappable = ({ children, hoverMode = DEFAULT_STATE_MODE, activeMode = DEFAULT_STATE_MODE, + onPointerUp, onPointerDown, onPointerCancel, ...restProps @@ -81,10 +82,11 @@ export const Tappable = ({ const { sizeX: legacySizeX, viewWidth = 'none', hasPointer } = useAdaptivity(); - const needRipple = useMaybeNeedRipple(activeMode, hasPointer); - const { clicks, ...rippleEvents } = useRipple(needRipple, hasPointer); + const needRipple = activeMode === 'background'; + const { wave, onWaveAnimationEnd, ...rippleEvents } = useRipple(needRipple); const handlers = mergeCalls(rippleEvents, { + onPointerUp, onPointerDown, onPointerCancel, }); @@ -100,6 +102,7 @@ export const Tappable = ({ getViewWidthClassName(viewWidth, legacySizeX), borderRadiusMode === 'inherit' && styles.borderRadiusInherit, hasPointerClassName(hasPointer), + activeMode === 'background' && restProps.activated && styles.activatedBackground, )} hoverClassName={hoverClass(hoverMode)} activeClassName={activeClass(activeMode)} @@ -109,7 +112,7 @@ export const Tappable = ({ > {children} {isClickable && (hoverMode === 'background' || activeMode === 'background') && ( - + )} ); diff --git a/packages/vkui/src/components/Tappable/state.tsx b/packages/vkui/src/components/Tappable/state.tsx index ac1ffd23183..b372efc47b4 100644 --- a/packages/vkui/src/components/Tappable/state.tsx +++ b/packages/vkui/src/components/Tappable/state.tsx @@ -37,7 +37,7 @@ export function hoverClass(hoverMode: StateModeLiteral = DEFAULT_STATE_MODE): st } const stylesActivated: Record = { - background: styles.activatedBackground, + background: '', opacity: styles.activatedOpacity, none: '', }; diff --git a/packages/vkui/src/lib/offset.ts b/packages/vkui/src/lib/offset.ts index 3682a7a8584..4bfb7e23da3 100644 --- a/packages/vkui/src/lib/offset.ts +++ b/packages/vkui/src/lib/offset.ts @@ -1,17 +1,17 @@ export interface OffsetRectInterface { - top: number | undefined; - left: number | undefined; - width: number | undefined; - height: number | undefined; + top: number; + left: number; + width: number; + height: number; } -export function getOffsetRect(elem: HTMLElement | null): OffsetRectInterface { - const box = elem?.getBoundingClientRect(); +export function getOffsetRect(elem: HTMLElement): OffsetRectInterface { + const box = elem.getBoundingClientRect(); return { - top: box?.top, - left: box?.left, - width: elem?.offsetWidth, - height: elem?.offsetHeight, + top: box.top, + left: box.left, + width: elem.offsetWidth, + height: elem.offsetHeight, }; }