Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(PullToRefresh): refactor global touchmove listener #6540

Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default story;

type Story = StoryObj<PullToRefreshProps>;

const initUsers = getRandomUsers(10);
const initUsers = getRandomUsers(20);

export const Example: Story = {
render: function Render() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,6 @@ describe(PullToRefresh, () => {
const hasDefaultStart = fireEvent.mouseDown(screen.getByTestId('xxx'), { clientY: 0 });
expect(hasDefaultStart).toBe(toHaveDefault);
};
it('prevents during refresh', () => {
renderRefresher();
firePull(screen.getByTestId('xxx'));
expectEvents(false);
});
it('releases after refresh', () => {
const { setFetching } = renderRefresher();
firePull(screen.getByTestId('xxx'));
Expand Down Expand Up @@ -153,7 +148,7 @@ describe(PullToRefresh, () => {
it('disables native pull-to-refresh while pulling', async () => {
const component = render(
<ConfigProvider platform="ios">
<PullToRefresh onRefresh={noop} data-testid="xxx" />
<PullToRefresh onRefresh={noop} isFetching={false} data-testid="xxx" />
</ConfigProvider>,
{ baseElement: document.documentElement },
);
Expand All @@ -162,12 +157,27 @@ describe(PullToRefresh, () => {

// класс присутствует пока пуллим
firePull(component.getByTestId('xxx'), { end: false });
act(jest.runAllTimers);
expect(document.querySelector('.vkui--disable-overscroll-behavior')).toBeTruthy();

// класс удаляется когда отпускаем
component.rerender(
<ConfigProvider platform="ios">
<PullToRefresh onRefresh={noop} isFetching data-testid="xxx" />
</ConfigProvider>,
);

fireEvent.mouseUp(component.getByTestId('xxx'), { clientY: 500 });
act(jest.runAllTimers);
expect(document.querySelector('.vkui--disable-overscroll-behavior')).toBeTruthy();

// пока идёт обновление, класс не удаляется, чтобы не вызывалось нативное поведение
firePull(component.getByTestId('xxx'), { end: true });
expect(document.querySelector('.vkui--disable-overscroll-behavior')).toBeTruthy();

component.rerender(
<ConfigProvider platform="ios">
<PullToRefresh onRefresh={noop} isFetching={false} data-testid="xxx" />
</ConfigProvider>,
);

expect(document.querySelector('.vkui--disable-overscroll-behavior')).toBeFalsy();
});
});
136 changes: 64 additions & 72 deletions packages/vkui/src/components/PullToRefresh/PullToRefresh.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,30 @@
import * as React from 'react';
import { classNames } from '@vkontakte/vkjs';
import { clamp } from '../../helpers/math';
import { useGlobalEventListener } from '../../hooks/useGlobalEventListener';
import { usePlatform } from '../../hooks/usePlatform';
import { usePrevious } from '../../hooks/usePrevious';
import { useTimeout } from '../../hooks/useTimeout';
import { DOMProps, useDOM } from '../../lib/dom';
import { coordY, VKUITouchEvent } from '../../lib/touch';
import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect';
import { AnyFunction, HasChildren } from '../../types';
import { ScrollContextInterface, useScroll } from '../AppRoot/ScrollContext';
import { FixedLayout } from '../FixedLayout/FixedLayout';
import { Touch, TouchEvent, TouchProps } from '../Touch/Touch';
import { Touch, TouchEvent as TouchEventInternal, TouchProps } from '../Touch/Touch';
import TouchRootContext from '../Touch/TouchContext';
import { PullToRefreshSpinner } from './PullToRefreshSpinner';
import styles from './PullToRefresh.module.css';

function cancelEvent(event: any) {
const WAIT_FETCHING_TIMEOUT_MS = 1000;

function cancelEvent(event: TouchEventInternal) {
/* istanbul ignore if: неясно в какой ситуации `event` из `Touch` может быть не определён */
if (!event) {
return false;
}
while (event.originalEvent) {
event = event.originalEvent;
}
if (event.preventDefault && event.cancelable) {
event.preventDefault();
if ('preventDefault' in event.originalEvent && event.originalEvent.cancelable) {
event.originalEvent.preventDefault();
}
if (event.stopPropagation) {
event.stopPropagation();
if ('stopPropagation' in event.originalEvent) {
event.originalEvent.stopPropagation();
}
return false;
}
Expand All @@ -45,11 +42,6 @@ export interface PullToRefreshProps extends DOMProps, TouchProps, HasChildren {
scroll?: ScrollContextInterface;
}

const TOUCH_MOVE_EVENT_PARAMS = {
cancelable: true,
passive: false,
};

/**
* @see https://vkcom.github.io/VKUI/#/PullToRefresh
*/
Expand All @@ -62,7 +54,7 @@ export const PullToRefresh = ({
}: PullToRefreshProps) => {
const platform = usePlatform();
const scroll = useScroll();
const { document } = useDOM();
const { window, document } = useDOM();
const prevIsFetching = usePrevious(isFetching);

const initParams = React.useMemo(
Expand Down Expand Up @@ -102,10 +94,7 @@ export const PullToRefresh = ({
}
}, [touchDown, resetRefreshingState]);

const { set: setWaitFetchingTimeout, clear: clearWaitFetchingTimeout } = useTimeout(
onRefreshingFinish,
1000,
);
const waitFetchingTimeoutId = React.useRef<NodeJS.Timeout>();

useIsomorphicLayoutEffect(() => {
if (prevIsFetching !== undefined && prevIsFetching && !isFetching) {
Expand All @@ -115,21 +104,22 @@ export const PullToRefresh = ({

useIsomorphicLayoutEffect(() => {
if (prevIsFetching !== undefined && !prevIsFetching && isFetching) {
clearWaitFetchingTimeout();
clearTimeout(waitFetchingTimeoutId.current);
}
}, [isFetching, prevIsFetching, clearWaitFetchingTimeout]);
}, [isFetching, prevIsFetching]);

const runRefreshing = React.useCallback(() => {
if (!refreshing && onRefresh) {
// cleanup if the consumer does not start fetching in 1s
setWaitFetchingTimeout();
clearTimeout(waitFetchingTimeoutId.current);
waitFetchingTimeoutId.current = setTimeout(onRefreshingFinish, WAIT_FETCHING_TIMEOUT_MS);

setRefreshing(true);
setSpinnerY((prevSpinnerY) => (platform === 'ios' ? prevSpinnerY : initParams.refreshing));

onRefresh();
}
}, [refreshing, onRefresh, setWaitFetchingTimeout, platform, initParams.refreshing]);
}, [refreshing, onRefresh, onRefreshingFinish, platform, initParams.refreshing]);

useIsomorphicLayoutEffect(() => {
if (prevTouchDown !== undefined && prevTouchDown && !touchDown) {
Expand All @@ -138,6 +128,7 @@ export const PullToRefresh = ({
} else if (refreshing && !isFetching) {
// only iOS can start refresh before gesture end
resetRefreshingState();
/* istanbul ignore if: TODO написать тест */
BlackySoul marked this conversation as resolved.
Show resolved Hide resolved
} else {
// refreshing && isFetching: refresh in progress
// OR !refreshing && !canRefresh: pull was not strong enough
Expand All @@ -158,56 +149,63 @@ export const PullToRefresh = ({
runRefreshing,
]);

const startYRef = React.useRef(0);

const onTouchStart = (e: TouchEvent) => {
if (refreshing) {
cancelEvent(e);
}
setTouchDown(true);
startYRef.current = e.startY;
useIsomorphicLayoutEffect(
function toggleBodyOverscrollBehavior() {
/* istanbul ignore if: невозможный кейс, т.к. в SSR эффекты не вызываются. Проверка на будущее, если вдруг эффект будет вызываться. */
if (!window || !document) {
return;
}

if (document) {
// eslint-disable-next-line no-restricted-properties
document.documentElement.classList.add('vkui--disable-overscroll-behavior');
}
};
/**
* ⚠️ В частности, необходимо для iOS 15. Начиная с этой версии в Safari добавили
* pull-to-refresh. CSS св-во `overflow-behavior` появился только с iOS 16.
*
* Во вторую очередь, полезна блокированием скролла, чтобы пользователь дождался обновления
* данных.
*/
/* istanbul ignore next: в jest не протестировать */
const handleWindowTouchMoveForPreventIOSViewportBounce = (event: TouchEvent) => {
event.preventDefault();
event.stopPropagation();
};

if (watching || refreshing) {
// eslint-disable-next-line no-restricted-properties
document.documentElement.classList.add('vkui--disable-overscroll-behavior');
/* istanbul ignore next: в jest не протестировать */
window.addEventListener('touchmove', handleWindowTouchMoveForPreventIOSViewportBounce, {
passive: false,
});
}

const shouldPreventTouchMove = (event: VKUITouchEvent) => {
if (watching || refreshing) {
return true;
}
return () => {
// eslint-disable-next-line no-restricted-properties
document.documentElement.classList.remove('vkui--disable-overscroll-behavior');
/* istanbul ignore next: в jest не протестировать */
window.removeEventListener('touchmove', handleWindowTouchMoveForPreventIOSViewportBounce);
};
inomdzhon marked this conversation as resolved.
Show resolved Hide resolved
},
[window, document, watching, refreshing],
);

/* Нам нужно запретить touchmove у документа как только стало понятно, что
* начинается pull.
* состояния watching и refreshing устанавливаются слишком поздно и браузер
* может успеть начать нативный pull to refresh.
*
* Этот код является запасным вариантом, на случай, если css свойство
* overscroll-behavior не поддерживается
* */
const shiftY = coordY(event) - startYRef.current;
const pageYOffset = scroll?.getScroll().y;
const isRefreshGestureStarted = pageYOffset === 0 && shiftY > 0 && touchDown;
return isRefreshGestureStarted;
};
const startYRef = React.useRef(0);

const onWindowTouchMove = (event: VKUITouchEvent) => {
if (shouldPreventTouchMove(event)) {
event.preventDefault();
event.stopPropagation();
const onTouchStart = (event: TouchEventInternal) => {
if (refreshing) {
cancelEvent(event);
return;
}
setTouchDown(true);
startYRef.current = event.startY;
};

useGlobalEventListener(document, 'touchmove', onWindowTouchMove, TOUCH_MOVE_EVENT_PARAMS);

const onTouchMove = (e: TouchEvent) => {
const { isY, shiftY } = e;
const onTouchMove = (event: TouchEventInternal) => {
const { isY, shiftY } = event;
const { start, max } = initParams;
const pageYOffset = scroll?.getScroll().y;

if (watching && touchDown) {
cancelEvent(e);
cancelEvent(event);

const { positionMultiplier, maxY } = initParams;

Expand All @@ -225,7 +223,7 @@ export const PullToRefresh = ({
runRefreshing();
}
} else if (isY && pageYOffset === 0 && shiftY > 0 && !refreshing && touchDown) {
cancelEvent(e);
cancelEvent(event);

touchY.current = shiftY;
setWatching(true);
Expand All @@ -237,12 +235,6 @@ export const PullToRefresh = ({
const onTouchEnd = () => {
setWatching(false);
setTouchDown(false);

// восстанавливаем overscroll behavior
if (document) {
// eslint-disable-next-line no-restricted-properties
document.documentElement.classList.remove('vkui--disable-overscroll-behavior');
}
};

const spinnerTransform = `translate3d(0, ${spinnerY}px, 0)`;
Expand Down
1 change: 1 addition & 0 deletions packages/vkui/src/styles/common.css
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,6 @@
/* отключаем нативный pull-to-refresh при взаимодействии с компонентом
* PullToRefresh или при открывании модалки */
.vkui--disable-overscroll-behavior {
/* Safari >= 16 */
overscroll-behavior-y: none;
}
Loading