Skip to content

Commit

Permalink
feat(View): increase swipe back zone
Browse files Browse the repository at this point in the history
Увеличил зону срабатывания свайпа назад, тем самым приблизив
поведение к нативным приложениям. В частности, как в
приложении ВКонтакте на iOS.
  • Loading branch information
inomdzhon committed Sep 1, 2023
1 parent 1e6d1c4 commit 1562253
Show file tree
Hide file tree
Showing 8 changed files with 316 additions and 95 deletions.
1 change: 1 addition & 0 deletions packages/vkui/src/components/BaseGallery/BaseGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export const BaseGallery = ({
};

const onStart = (e: TouchEvent) => {
e.originalEvent.stopPropagation();
onDragStart?.(e);
setShiftState((prevState) => ({ ...prevState, animation: false }));
};
Expand Down
21 changes: 21 additions & 0 deletions packages/vkui/src/components/View/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,27 @@ const ProfilePanelContent = ({ onSettingsClick }) => {
<Group>
<CellButton onClick={onSettingsClick}>Настройки</CellButton>
</Group>
<Group header={<Header>Gallery</Header>} description="Блокирует свайпбэк">
<Gallery slideWidth="90%" bullets="dark">
<div style={{ backgroundColor: 'var(--vkui--color_background_negative)' }} />
<img src="https://placebear.com/1024/640" style={{ display: 'block' }} />
<div style={{ backgroundColor: 'var(--vkui--color_background_accent)' }} />
</Gallery>
</Group>
<Group
header={<Header>HorizontalScroll</Header>}
description="Свайпбэк срабатывает если мы в начале"
>
<HorizontalScroll>
<div style={{ display: 'flex' }}>
{getRandomUsers(15).map((user) => (
<HorizontalCell key={user.id} size="s" header={user.first_name}>
<Avatar size={56} src={user.photo_100} />
</HorizontalCell>
))}
</div>
</HorizontalScroll>
</Group>
</React.Fragment>
);
};
Expand Down
159 changes: 125 additions & 34 deletions packages/vkui/src/components/View/View.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import * as React from 'react';
import { type ComponentType, Fragment, type ReactNode } from 'react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { Platform } from '../../lib/platform';
import { getRandomUsers } from '../../testing/mock';
import { baselineComponent, mockScrollContext, mountTest } from '../../testing/utils';
import { HasChildren } from '../../types';
import { Avatar } from '../Avatar/Avatar';
import { ConfigProvider } from '../ConfigProvider/ConfigProvider';
import { Gallery } from '../Gallery/Gallery';
import { HorizontalCell } from '../HorizontalCell/HorizontalCell';
import { HorizontalScroll } from '../HorizontalScroll/HorizontalScroll';
import { useNavDirection } from '../NavTransitionDirectionContext/NavTransitionDirectionContext';
import { Panel } from '../Panel/Panel';
import { scrollsCache, View, type ViewProps } from './View';
import { scrollsCache, SWIPE_BACK_THRESHOLD, View, type ViewProps } from './View';

// Basically the same as Root.test.tsx

Expand Down Expand Up @@ -105,18 +110,21 @@ describe('View', () => {
afterEach(() => nowMock && nowMock.mockClear());
it('cancels swipeBack on swipe left', () => {
const { view, ...events } = setupSwipeBack();
fireEvent.mouseDown(view, { clientX: 0, clientY: 100 });
fireEvent.mouseMove(view, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });
expect(events.onSwipeBackStart).toBeCalledTimes(1);
fireEvent.mouseUp(view, { clientX: 0, clientY: 100 });
act(() => jest.runAllTimers());
expect(events.onSwipeBack).not.toBeCalled();
expect(events.onSwipeBackCancel).toBeCalledTimes(1);
});
it('does swipeBack immediately on overscroll', () => {
const { view, ...events } = setupSwipeBack();
fireEvent.mouseMove(view, {
clientX: window.innerWidth + 1,
clientY: 100,
});
fireEvent.mouseDown(view, { clientX: 0, clientY: 100 });
fireEvent.mouseMove(view, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });
fireEvent.mouseMove(view, { clientX: window.innerWidth + 1, clientY: 100 });
fireEvent.mouseUp(view);
act(() => jest.runAllTimers());
expect(events.onSwipeBack).toBeCalledTimes(1);
expect(events.onSwipeBackCancel).not.toBeCalled();
});
Expand All @@ -129,14 +137,13 @@ describe('View', () => {
const { view } = setupSwipeBack({
childrenForPanel1: <PanelContent />,
childrenForPanel2: <PanelContent />,
shouldForceDetectXSwipe: false,
});

// only panel 2 visible by default with undefined direction
expect(screen.queryByText('Direction: undefined')).toBeTruthy();
expect(screen.queryByText('Direction: backwards')).toBeFalsy();
fireEvent.mouseDown(view, { clientX: 50, clientY: 100 });
fireEvent.mouseMove(view, { clientX: 40, clientY: 100 });
fireEvent.mouseDown(view, { clientX: 0, clientY: 100 });
fireEvent.mouseMove(view, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });

// both panels are visible and the content on panel 1 knows about backwards direction
expect(screen.queryByText('Direction: undefined')).toBeTruthy();
Expand All @@ -162,47 +169,65 @@ describe('View', () => {
childrenForPanel2: component,
initialProps: props,
});
fireEvent.mouseMove(screen.getByTestId('ex'), {
clientX: window.innerWidth + 1,
clientY: 100,
});
fireEvent.mouseUp(screen.getByTestId('ex'));
const elPreventSwipeBack = screen.getByTestId('ex');
fireEvent.mouseDown(elPreventSwipeBack, { clientX: 0, clientY: 100 });
fireEvent.mouseMove(elPreventSwipeBack, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });
fireEvent.mouseMove(elPreventSwipeBack, { clientX: window.innerWidth + 1, clientY: 100 });
fireEvent.mouseUp(elPreventSwipeBack);
act(() => jest.runAllTimers());
expect(events.onSwipeBack).not.toBeCalled();
});
});
it('does swipeBack after animation', () => {
const { view, ...events } = setupSwipeBack();
fireEvent.mouseMove(view, {
clientX: window.innerWidth / 2 + 1,
clientY: 100,
});
fireEvent.mouseDown(view, { clientX: 0, clientY: 100 });
fireEvent.mouseMove(view, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });
fireEvent.mouseMove(view, { clientX: window.innerWidth / 2 + 1, clientY: 100 });
// speed to 0
nowMock.mockImplementation(() => Infinity);
fireEvent.mouseUp(view);
expect(events.onSwipeBack).not.toBeCalled();
expect(events.onSwipeBackCancel).not.toBeCalled();
act(() => {
jest.runAllTimers();
act(() => jest.runAllTimers());
expect(events.onSwipeBack).toBeCalledTimes(1);
});
it('should swipe back by start touch anywhere', () => {
const { view, ...events } = setupSwipeBack();

const fromQuarterScreenX = window.innerWidth / 4;
fireEvent.mouseDown(view, { clientX: fromQuarterScreenX, clientY: 100 });
fireEvent.mouseMove(view, {
clientX: fromQuarterScreenX + SWIPE_BACK_THRESHOLD,
clientY: 100,
});

const shiftXWithOffset = fromQuarterScreenX * 2; // event.shiftX начинается с 0, для правильного высчитывания swipeBackShift, смещаем это значение
fireEvent.mouseMove(view, { clientX: fromQuarterScreenX + shiftXWithOffset, clientY: 100 });
expect(events.onSwipeBackStart).toBeCalledTimes(1);
fireEvent.mouseUp(view);
act(() => jest.runAllTimers());
expect(events.onSwipeBack).toBeCalledTimes(1);
expect(events.onSwipeBackCancel).not.toBeCalled();
});
it('fails weak swipeBack', () => {
const { view, ...events } = setupSwipeBack();
fireEvent.mouseDown(view, { clientX: 0, clientY: 100 });
// fireEvent.mouseMove(view, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });
fireEvent.mouseMove(view, {
clientX: window.innerWidth / 2 - 1,
clientY: 100,
});
// speed to 0
nowMock.mockImplementation(() => Infinity);
fireEvent.mouseUp(view);
act(() => {
jest.runAllTimers();
});
act(() => jest.runAllTimers());
expect(events.onSwipeBack).not.toBeCalled();
expect(events.onSwipeBackCancel).toBeCalledTimes(1);
});
it('recovers after swipeBack', () => {
const { view, rerender, SwipeBack, ...events } = setupSwipeBack();
fireEvent.mouseDown(view, { clientX: 0, clientY: 100 });
fireEvent.mouseMove(view, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });
fireEvent.mouseMove(view, {
clientX: window.innerWidth + 1,
clientY: 100,
Expand All @@ -220,6 +245,8 @@ describe('View', () => {
scrollsCache['scroll']['p1'] = 22;
const [MockScroll, scrollTo] = mockScrollContext(() => y);
const { view, rerender, SwipeBack } = setupSwipeBack({ Wrapper: MockScroll });
fireEvent.mouseDown(view, { clientX: 0, clientY: 100 });
fireEvent.mouseMove(view, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });
fireEvent.mouseMove(view, {
clientX: window.innerWidth + 1,
clientY: 100,
Expand All @@ -228,6 +255,79 @@ describe('View', () => {
rerender(<SwipeBack activePanel="p1" history={['p1']} />);
expect(scrollTo).toBeCalledWith(0, 22);
});

it('should prevent swipe back if horizontal scrollable elements are scrolled', () => {
const { rerender, SwipeBack, getByTestId, ...events } = setupSwipeBack({
childrenForPanel2: (
<HorizontalScroll data-testid="horizontal-scroll">
<div style={{ width: '800px', height: '50px', display: 'flex' }}>
{getRandomUsers(20).map((user, index) => (
<HorizontalCell
key={user.id}
size="s"
header={user.first_name}
data-testid={`horizontal-cell-${index}`}
>
<Avatar size={56} src={user.photo_100} />
</HorizontalCell>
))}
</div>
</HorizontalScroll>
),
});

const elWithScroll = getByTestId('horizontal-scroll').lastElementChild as HTMLElement;
elWithScroll.style.overflowX = 'auto';
elWithScroll.scrollLeft = 100;

const elMiddleHorizontalCell = getByTestId('horizontal-cell-10');

fireEvent.mouseDown(elMiddleHorizontalCell, { clientX: 0, clientY: 100 });
fireEvent.mouseMove(elMiddleHorizontalCell, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });
fireEvent.mouseMove(elMiddleHorizontalCell, { clientX: window.innerWidth / 2, clientY: 100 });
fireEvent.mouseUp(elMiddleHorizontalCell);
expect(events.onSwipeBackStart).not.toBeCalled();
act(() => jest.runAllTimers());
expect(events.onSwipeBack).not.toBeCalled();
expect(events.onSwipeBackCancel).not.toBeCalled();

elWithScroll.scrollLeft = 0;

fireEvent.mouseDown(elMiddleHorizontalCell, { clientX: 0, clientY: 100 });
fireEvent.mouseMove(elMiddleHorizontalCell, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });
fireEvent.mouseMove(elMiddleHorizontalCell, { clientX: window.innerWidth / 2, clientY: 100 });
fireEvent.mouseUp(elMiddleHorizontalCell);
expect(events.onSwipeBackStart).toBeCalledTimes(1);
act(() => jest.runAllTimers());
expect(events.onSwipeBack).toBeCalledTimes(1);
expect(events.onSwipeBackCancel).not.toBeCalled();
});

it('should prevent swipe back if drag Gallery', () => {
const { rerender, SwipeBack, getByTestId, ...events } = setupSwipeBack({
childrenForPanel2: (
<Gallery slideWidth="90%" align="center">
<div
data-testid="slide-1"
style={{ backgroundColor: 'var(--vkui--color_background_negative)' }}
/>
<div style={{ backgroundColor: 'var(--vkui--color_background_positive)' }} />
<div style={{ backgroundColor: 'var(--vkui--color_background_accent)' }} />
</Gallery>
),
});

const elSlide1 = getByTestId('slide-1');

fireEvent.mouseDown(elSlide1, { clientX: 0, clientY: 100 });
fireEvent.mouseMove(elSlide1, { clientX: SWIPE_BACK_THRESHOLD, clientY: 100 });
fireEvent.mouseMove(elSlide1, { clientX: window.innerWidth / 2, clientY: 100 });
fireEvent.mouseUp(elSlide1);
expect(events.onSwipeBackStart).not.toBeCalled();
act(() => jest.runAllTimers());
expect(events.onSwipeBack).not.toBeCalled();
expect(events.onSwipeBackCancel).not.toBeCalled();
});
});

describe('scroll control', () => {
Expand Down Expand Up @@ -292,13 +392,11 @@ function setupSwipeBack({
childrenForPanel1 = null,
childrenForPanel2 = null,
initialProps = {},
shouldForceDetectXSwipe = true,
}: {
Wrapper?: ComponentType<HasChildren>;
childrenForPanel1?: any;
childrenForPanel2?: any;
initialProps?: Partial<ViewProps>;
shouldForceDetectXSwipe?: boolean;
} = {}) {
const events = {
onSwipeBack: jest.fn(),
Expand All @@ -310,6 +408,7 @@ function setupSwipeBack({
<Wrapper>
<ConfigProvider platform={Platform.IOS} isWebView>
<View
data-testid="view"
id="scroll"
activePanel="p2"
history={['p1', 'p2']}
Expand All @@ -324,14 +423,6 @@ function setupSwipeBack({
</Wrapper>
);
const component = render(<SwipeBack />);
act(() => {
jest.runAllTimers();
});
const view = component.container.firstElementChild as Element;
// force detect x-swipe
if (shouldForceDetectXSwipe) {
fireEvent.mouseDown(view, { clientX: 50, clientY: 100 });
fireEvent.mouseMove(view, { clientX: 40, clientY: 100 });
}
return { view, ...events, rerender: component.rerender, SwipeBack };
act(() => jest.runAllTimers());
return { view: component.getByTestId('view'), ...events, ...component, SwipeBack };
}
24 changes: 14 additions & 10 deletions packages/vkui/src/components/View/View.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import { NavTransitionProvider } from '../NavTransitionContext/NavTransitionCont
import { NavTransitionDirectionProvider } from '../NavTransitionDirectionContext/NavTransitionDirectionContext';
import { useSplitCol } from '../SplitCol/SplitColContext';
import { Touch, TouchEvent } from '../Touch/Touch';
import { swipeBackExcluded } from './utils';
import { hasHorizontalScrollableElementWithScrolledToLeft, swipeBackExcluded } from './utils';
import styles from './View.module.css';

const SWIPE_BACK_AREA = 70;
export const SWIPE_BACK_THRESHOLD = 20;

enum SwipeBackResults {
fail = 1,
Expand Down Expand Up @@ -259,22 +259,27 @@ export const View = ({
return;
}

const swipeBackShouldStart = event.shiftX >= SWIPE_BACK_THRESHOLD;

if (!configProvider?.isWebView) {
if (
(event.startX <= SWIPE_BACK_AREA || event.startX >= window!.innerWidth - SWIPE_BACK_AREA) &&
!browserSwipe
) {
if (swipeBackShouldStart && !browserSwipe) {
setBrowserSwipe(true);
}

return;
}

if (!onSwipeBack || (animated && event.startX <= SWIPE_BACK_AREA)) {
if (!onSwipeBack || (animated && swipeBackShouldStart)) {
return;
}

if (!swipingBack && event.startX <= SWIPE_BACK_AREA && history && history.length > 1) {
if (!swipingBack && swipeBackShouldStart && history && history.length > 1) {
if (
hasHorizontalScrollableElementWithScrolledToLeft(event.originalEvent.target as HTMLElement)
) {
setSwipeBackPrevented(true);
return;
}
// Начался свайп назад
if (onSwipeBackStart) {
const payload = onSwipeBackStart(activePanel);
Expand Down Expand Up @@ -317,7 +322,7 @@ export const View = ({
onSwipeBackCancel();
} else if (swipeBackShift >= (window!.innerWidth ?? 0)) {
onSwipeBackSuccess();
} else if (speed > 250 || swipeBackStartX + swipeBackShift > window!.innerWidth / 2) {
} else if (speed > 250 || swipeBackShift >= window!.innerWidth / 2) {
setSwipeBackResult(SwipeBackResults.success);
} else {
setSwipeBackResult(SwipeBackResults.fail);
Expand All @@ -331,7 +336,6 @@ export const View = ({
onSwipeBackCancel,
onSwipeBackSuccess,
swipeBackShift,
swipeBackStartX,
swipingBack,
swipeBackPrevented,
window,
Expand Down
Loading

0 comments on commit 1562253

Please sign in to comment.