Skip to content

Commit dee71bb

Browse files
committed
fix: rewrite ripple
1 parent abd3006 commit dee71bb

5 files changed

Lines changed: 168 additions & 98 deletions

File tree

Lines changed: 122 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,171 @@
1+
'use client';
2+
13
import * as React from 'react';
2-
import { classNames, hasMouse as hasPointerLib, noop } from '@vkontakte/vkjs';
3-
import { usePlatform } from '../../hooks/usePlatform';
4+
import { classNames, noop } from '@vkontakte/vkjs';
45
import { getOffsetRect } from '../../lib/offset';
6+
import { type CSSCustomProperties } from '../../types';
57
import styles from './Tappable.module.css';
68

79
/* eslint-disable jsdoc/require-jsdoc */
810

9-
/**
10-
* Возможно нужен Ripple эффект. Данный хук нужен для отказа
11-
* от двойного ререндера.
12-
*/
13-
export const useMaybeNeedRipple = (
14-
activeMode: string,
15-
hasPointer: boolean | undefined,
16-
): boolean => {
17-
const platform = usePlatform();
18-
19-
return platform === 'android' && !hasPointer && activeMode === 'background';
20-
};
11+
type WaveState = 'inactive' | 'fadeIn' | 'fadeOut';
2112

2213
interface Wave {
2314
x: number;
2415
y: number;
25-
id: number;
26-
pointerId: number;
16+
rippleSize: number;
17+
rippleScale: number;
18+
state: WaveState;
2719
}
2820

29-
const DELAY = 70;
30-
const WAVE_LIVE = 225;
21+
const TOUCH_DELAY = 150;
22+
23+
const INITIAL_ORIGIN_SCALE = 0.2;
24+
const SOFT_EDGE_MINIMUM_SIZE = 75;
25+
const SOFT_EDGE_CONTAINER_RATIO = 0.35;
26+
const PADDING = 10;
27+
28+
function calcRippleSize(width: number, height: number): number {
29+
return Math.floor(Math.max(width, height) * INITIAL_ORIGIN_SCALE);
30+
}
31+
32+
function calcRippleScale(initialRippleSize: number, width: number, height: number) {
33+
const maxDim = Math.max(width, height);
34+
const softEdgeSize = Math.max(SOFT_EDGE_CONTAINER_RATIO * maxDim, SOFT_EDGE_MINIMUM_SIZE);
35+
36+
const diagonal = Math.sqrt(width ** 2 + height ** 2);
37+
const maxRadius = diagonal * 2 + PADDING;
38+
39+
return (maxRadius + softEdgeSize) / initialRippleSize;
40+
}
3141

3242
/**
3343
* Хук для создания Ripple эффектов.
3444
*/
3545
export const useRipple = (
3646
needRipple: boolean,
37-
hasPointerContext: boolean | undefined,
3847
): {
39-
clicks: Wave[];
48+
wave: Wave;
4049
onPointerDown: React.PointerEventHandler<HTMLSpanElement>;
50+
onPointerUp: React.PointerEventHandler<HTMLSpanElement>;
4151
onPointerCancel: React.PointerEventHandler<HTMLSpanElement>;
52+
onWaveAnimationEnd: React.AnimationEventHandler<HTMLSpanElement>;
4253
} => {
43-
const [clicks, setClicks] = React.useState<Wave[]>([]);
44-
45-
/**
46-
* Коллекция нажатий и таймеров задержки появления волны.
47-
*/
48-
const pointerDelayTimers = React.useRef<Map<number, ReturnType<typeof setTimeout>>>(null);
49-
if (pointerDelayTimers.current === null) {
50-
pointerDelayTimers.current = new Map();
51-
}
54+
const pointerDelayTimerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined);
5255

53-
React.useEffect(
54-
function setClearClicksTimeout() {
55-
const clicksTimeoutId = clicks.length > 0 ? setTimeout(() => setClicks([]), WAVE_LIVE) : null;
56-
return function cancelClearClicksTimeout() {
57-
if (clicksTimeoutId) {
58-
clearTimeout(clicksTimeoutId);
59-
}
60-
};
61-
},
62-
[clicks],
63-
);
56+
const pointerDownRef = React.useRef(false);
57+
const fadeInAnimationInProgressRef = React.useRef(false);
6458

65-
function addClick(x: number, y: number, pointerId: number) {
66-
const dateNow = Date.now();
67-
const filteredClicks = clicks.filter((click) => click.id + WAVE_LIVE > dateNow);
59+
const [state, setState] = React.useState<WaveState>('inactive');
6860

69-
setClicks([...filteredClicks, { x, y, id: dateNow, pointerId }]);
70-
pointerDelayTimers.current!.delete(pointerId);
61+
const [x, setX] = React.useState(0);
62+
const [y, setY] = React.useState(0);
63+
const [rippleSize, setRippleSize] = React.useState(0);
64+
const [rippleScale, setRippleScale] = React.useState(0);
65+
66+
const checkEndFadeIn = () => {
67+
if (state !== 'fadeIn' || pointerDownRef.current || fadeInAnimationInProgressRef.current) {
68+
return;
69+
}
70+
71+
setState('fadeOut');
72+
};
73+
74+
const onAnimationEnd = () => {
75+
switch (state) {
76+
case 'fadeIn':
77+
fadeInAnimationInProgressRef.current = false;
78+
checkEndFadeIn();
79+
break;
80+
case 'fadeOut':
81+
setState('inactive');
82+
break;
83+
}
84+
};
85+
86+
function addClick(x: number, y: number, rippleSize: number, rippleScale: number) {
87+
setState('fadeIn');
88+
fadeInAnimationInProgressRef.current = true;
89+
setX(x);
90+
setY(y);
91+
setRippleSize(rippleSize);
92+
setRippleScale(rippleScale);
7193
}
7294

73-
/**
74-
* Добавляем волну с задержкой. Задержка необходима при отмене волны.
75-
*/
7695
const onPointerDown: React.PointerEventHandler<HTMLSpanElement> = (e) => {
77-
const { top, left } = getOffsetRect(e.currentTarget);
96+
setState('inactive');
97+
const { top, left, width, height } = getOffsetRect(e.currentTarget);
98+
99+
const rippleSize = calcRippleSize(width, height);
100+
const rippleScale = calcRippleScale(rippleSize, width, height);
101+
78102
const x = e.clientX - (left ?? 0);
79103
const y = e.clientY - (top ?? 0);
80104

81-
pointerDelayTimers.current!.set(
82-
e.pointerId,
83-
setTimeout(() => addClick(x, y, e.pointerId), DELAY),
84-
);
105+
const delay = e.pointerType === 'touch' ? TOUCH_DELAY : 0;
106+
107+
pointerDelayTimerRef.current = setTimeout(() => addClick(x, y, rippleSize, rippleScale), delay);
108+
pointerDownRef.current = true;
85109
};
86110

87-
const onPointerCancel: React.PointerEventHandler<HTMLSpanElement> = (e) => {
88-
const timer = pointerDelayTimers.current!.get(e.pointerId);
89-
clearTimeout(timer);
90-
pointerDelayTimers.current!.delete(e.pointerId);
111+
const onPointerCancel: React.PointerEventHandler<HTMLSpanElement> = () => {
112+
clearTimeout(pointerDelayTimerRef.current);
113+
pointerDownRef.current = false;
114+
checkEndFadeIn();
91115
};
92116

93-
// WARNING: не использовать для рендеринга
94-
const reallyNeedRipple = (!hasPointerLib || hasPointerContext === false) && needRipple;
117+
const onPointerUp: React.PointerEventHandler<HTMLSpanElement> = () => {
118+
pointerDownRef.current = false;
119+
checkEndFadeIn();
120+
};
95121

96122
return {
97-
clicks,
98-
onPointerDown: reallyNeedRipple ? onPointerDown : noop,
99-
onPointerCancel: reallyNeedRipple ? onPointerCancel : noop,
123+
wave: {
124+
x,
125+
y,
126+
rippleSize,
127+
rippleScale,
128+
state,
129+
},
130+
onWaveAnimationEnd: onAnimationEnd,
131+
onPointerDown: needRipple ? onPointerDown : noop,
132+
onPointerUp: needRipple ? onPointerUp : noop,
133+
onPointerCancel: needRipple ? onPointerCancel : noop,
100134
};
101135
};
102136

103137
export interface RippleProps {
104138
needRipple: boolean;
105-
clicks: Wave[];
139+
wave: Wave;
140+
onWaveAnimationEnd: React.AnimationEventHandler<HTMLSpanElement>;
106141
}
107142

108-
export const Ripple = ({ needRipple = true, clicks }: RippleProps): React.ReactNode => {
143+
const stylesState: Record<WaveState, string | undefined> = {
144+
inactive: undefined,
145+
fadeIn: styles.waveFadeIn,
146+
fadeOut: styles.waveFadeOut,
147+
};
148+
149+
export const Ripple = ({
150+
needRipple = true,
151+
wave,
152+
onWaveAnimationEnd,
153+
}: RippleProps): React.ReactNode => {
154+
const style: React.CSSProperties & CSSCustomProperties = {
155+
'top': wave.y - wave.rippleSize / 2,
156+
'left': wave.x - wave.rippleSize / 2,
157+
'width': wave.rippleSize,
158+
'height': wave.rippleSize,
159+
'--vkui_internal--Tappable-scale': wave.rippleScale.toString(),
160+
};
161+
109162
return (
110163
<span aria-hidden className={classNames(styles.stateLayer, needRipple && styles.ripple)}>
111-
{clicks.map((wave) => (
112-
<span key={wave.id} className={styles.wave} style={{ top: wave.y, left: wave.x }} />
113-
))}
164+
<span
165+
className={classNames(styles.wave, stylesState[wave.state])}
166+
style={style}
167+
onAnimationEnd={onWaveAnimationEnd}
168+
/>
114169
</span>
115170
);
116171
};

packages/vkui/src/components/Tappable/Tappable.module.css

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,6 @@ https://github.com/VKCOM/VKUI/pull/3641
6565
background-color: var(--vkui--color_transparent--hover);
6666
}
6767

68-
.activatedBackground > .stateLayer {
69-
background-color: var(--vkui--color_transparent--active);
70-
}
71-
7268
.hoveredOpacity,
7369
.activatedOpacity {
7470
transition: opacity 0.15s ease-out;
@@ -83,35 +79,52 @@ https://github.com/VKCOM/VKUI/pull/3641
8379
}
8480

8581
.wave {
82+
--vkui_internal--Tappable-scale: 8;
83+
8684
position: absolute;
87-
inset-block-start: 0;
88-
inset-inline-start: 0;
89-
inline-size: 24px;
90-
block-size: 24px;
91-
margin-block: -12px 0;
92-
margin-inline: -12px 0;
9385
content: '';
94-
background: var(--vkui--color_transparent--active);
95-
border-radius: 50%;
86+
background: radial-gradient(
87+
closest-side,
88+
var(--vkui--color_transparent--active) max(calc(100% - 70px), 65%),
89+
transparent 100%
90+
);
9691
opacity: 0;
97-
animation: animationWave 0.3s var(--vkui--animation_easing_platform);
92+
transform-origin: center center;
93+
}
94+
95+
.waveFadeIn {
96+
animation: animationWaveFadeIn 450ms cubic-bezier(0.2, 0, 0, 1) forwards;
97+
}
98+
99+
.waveFadeOut {
100+
animation: animationWaveFadeOut 375ms linear;
98101
}
99102

100103
/**
101104
* Animations
102105
*/
103-
@keyframes animationWave {
106+
@keyframes animationWaveFadeIn {
104107
0% {
105108
opacity: 1;
106109
transform: scale(1);
107110
}
108111

109-
30% {
112+
100% {
113+
opacity: 1;
114+
transform: scale(var(--vkui_internal--Tappable-scale));
115+
}
116+
}
117+
118+
/**
119+
* Animations
120+
*/
121+
@keyframes animationWaveFadeOut {
122+
0% {
110123
opacity: 1;
124+
transform: scale(var(--vkui_internal--Tappable-scale));
111125
}
112126

113127
100% {
114128
opacity: 0;
115-
transform: scale(8);
116129
}
117130
}

packages/vkui/src/components/Tappable/Tappable.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useAdaptivity } from '../../hooks/useAdaptivity';
55
import { type SizeTypeValues, ViewWidth, type ViewWidthType } from '../../lib/adaptivity';
66
import { mergeCalls } from '../../lib/mergeCalls';
77
import { checkClickable, Clickable, type ClickableProps } from '../Clickable/Clickable';
8-
import { Ripple, useMaybeNeedRipple, useRipple } from './Ripple';
8+
import { Ripple, useRipple } from './Ripple';
99
import { activeClass, DEFAULT_STATE_MODE, hoverClass, type StateProps } from './state';
1010
import styles from './Tappable.module.css';
1111

@@ -73,6 +73,7 @@ export const Tappable = ({
7373
children,
7474
hoverMode = DEFAULT_STATE_MODE,
7575
activeMode = DEFAULT_STATE_MODE,
76+
onPointerUp,
7677
onPointerDown,
7778
onPointerCancel,
7879
...restProps
@@ -81,10 +82,11 @@ export const Tappable = ({
8182

8283
const { sizeX: legacySizeX, viewWidth = 'none', hasPointer } = useAdaptivity();
8384

84-
const needRipple = useMaybeNeedRipple(activeMode, hasPointer);
85-
const { clicks, ...rippleEvents } = useRipple(needRipple, hasPointer);
85+
const needRipple = activeMode === 'background';
86+
const { wave, onWaveAnimationEnd, ...rippleEvents } = useRipple(needRipple);
8687

8788
const handlers = mergeCalls(rippleEvents, {
89+
onPointerUp,
8890
onPointerDown,
8991
onPointerCancel,
9092
});
@@ -109,7 +111,7 @@ export const Tappable = ({
109111
>
110112
{children}
111113
{isClickable && (hoverMode === 'background' || activeMode === 'background') && (
112-
<Ripple needRipple={needRipple} clicks={clicks} />
114+
<Ripple needRipple={needRipple} wave={wave} onWaveAnimationEnd={onWaveAnimationEnd} />
113115
)}
114116
</Clickable>
115117
);

packages/vkui/src/components/Tappable/state.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function hoverClass(hoverMode: StateModeLiteral = DEFAULT_STATE_MODE): st
3737
}
3838

3939
const stylesActivated: Record<string, string> = {
40-
background: styles.activatedBackground,
40+
background: '',
4141
opacity: styles.activatedOpacity,
4242
none: '',
4343
};

packages/vkui/src/lib/offset.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
export interface OffsetRectInterface {
2-
top: number | undefined;
3-
left: number | undefined;
4-
width: number | undefined;
5-
height: number | undefined;
2+
top: number;
3+
left: number;
4+
width: number;
5+
height: number;
66
}
77

8-
export function getOffsetRect(elem: HTMLElement | null): OffsetRectInterface {
9-
const box = elem?.getBoundingClientRect();
8+
export function getOffsetRect(elem: HTMLElement): OffsetRectInterface {
9+
const box = elem.getBoundingClientRect();
1010

1111
return {
12-
top: box?.top,
13-
left: box?.left,
14-
width: elem?.offsetWidth,
15-
height: elem?.offsetHeight,
12+
top: box.top,
13+
left: box.left,
14+
width: elem.offsetWidth,
15+
height: elem.offsetHeight,
1616
};
1717
}

0 commit comments

Comments
 (0)