Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 122 additions & 67 deletions packages/vkui/src/components/Tappable/Ripple.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement>;
onPointerUp: React.PointerEventHandler<HTMLSpanElement>;
onPointerCancel: React.PointerEventHandler<HTMLSpanElement>;
onWaveAnimationEnd: React.AnimationEventHandler<HTMLSpanElement>;
} => {
const [clicks, setClicks] = React.useState<Wave[]>([]);

/**
* Коллекция нажатий и таймеров задержки появления волны.
*/
const pointerDelayTimers = React.useRef<Map<number, ReturnType<typeof setTimeout>>>(null);
if (pointerDelayTimers.current === null) {
pointerDelayTimers.current = new Map();
}
const pointerDelayTimerRef = React.useRef<ReturnType<typeof setTimeout>>(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<WaveState>('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<HTMLSpanElement> = (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<HTMLSpanElement> = (e) => {
const timer = pointerDelayTimers.current!.get(e.pointerId);
clearTimeout(timer);
pointerDelayTimers.current!.delete(e.pointerId);
const onPointerCancel: React.PointerEventHandler<HTMLSpanElement> = () => {
clearTimeout(pointerDelayTimerRef.current);
pointerDownRef.current = false;
checkEndFadeIn();
};

// WARNING: не использовать для рендеринга
const reallyNeedRipple = (!hasPointerLib || hasPointerContext === false) && needRipple;
const onPointerUp: React.PointerEventHandler<HTMLSpanElement> = () => {
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<HTMLSpanElement>;
}

export const Ripple = ({ needRipple = true, clicks }: RippleProps): React.ReactNode => {
const stylesState: Record<WaveState, string | undefined> = {
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 (
<span aria-hidden className={classNames(styles.stateLayer, needRipple && styles.ripple)}>
{clicks.map((wave) => (
<span key={wave.id} className={styles.wave} style={{ top: wave.y, left: wave.x }} />
))}
<span
className={classNames(styles.wave, stylesState[wave.state])}
style={style}
onAnimationEnd={onWaveAnimationEnd}
/>
</span>
);
};
41 changes: 29 additions & 12 deletions packages/vkui/src/components/Tappable/Tappable.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
11 changes: 7 additions & 4 deletions packages/vkui/src/components/Tappable/Tappable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -73,6 +73,7 @@ export const Tappable = ({
children,
hoverMode = DEFAULT_STATE_MODE,
activeMode = DEFAULT_STATE_MODE,
onPointerUp,
onPointerDown,
onPointerCancel,
...restProps
Expand All @@ -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,
});
Expand All @@ -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)}
Expand All @@ -109,7 +112,7 @@ export const Tappable = ({
>
{children}
{isClickable && (hoverMode === 'background' || activeMode === 'background') && (
<Ripple needRipple={needRipple} clicks={clicks} />
<Ripple needRipple={needRipple} wave={wave} onWaveAnimationEnd={onWaveAnimationEnd} />
)}
</Clickable>
);
Expand Down
2 changes: 1 addition & 1 deletion packages/vkui/src/components/Tappable/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function hoverClass(hoverMode: StateModeLiteral = DEFAULT_STATE_MODE): st
}

const stylesActivated: Record<string, string> = {
background: styles.activatedBackground,
background: '',
opacity: styles.activatedOpacity,
none: '',
};
Expand Down
20 changes: 10 additions & 10 deletions packages/vkui/src/lib/offset.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading