Skip to content

Commit 4c764d8

Browse files
authored
fix: Touch event should work on list (#52)
* fix: Scroll * smooth it * touch move scroll bar * coverage
1 parent 5c898cb commit 4c764d8

File tree

4 files changed

+219
-12
lines changed

4 files changed

+219
-12
lines changed

src/List.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import { useRef } from 'react';
2+
import { useRef, useState } from 'react';
33
import classNames from 'classnames';
44
import Filler from './Filler';
55
import ScrollBar from './ScrollBar';
@@ -10,6 +10,7 @@ import useInRange from './hooks/useInRange';
1010
import useScrollTo from './hooks/useScrollTo';
1111
import useDiffItem from './hooks/useDiffItem';
1212
import useFrameWheel from './hooks/useFrameWheel';
13+
import useMobileTouchMove from './hooks/useMobileTouchMove';
1314

1415
const EMPTY_DATA = [];
1516

@@ -70,8 +71,8 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
7071
const inVirtual =
7172
virtual !== false && height && itemHeight && data && itemHeight * data.length > height;
7273

73-
const [scrollTop, setScrollTop] = React.useState(0);
74-
const [scrollMoving, setScrollMoving] = React.useState(false);
74+
const [scrollTop, setScrollTop] = useState(0);
75+
const [scrollMoving, setScrollMoving] = useState(false);
7576

7677
const mergedClassName = classNames(prefixCls, className);
7778
const mergedData = data || EMPTY_DATA;
@@ -215,6 +216,11 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
215216
});
216217
});
217218

219+
// Mobile touch move
220+
useMobileTouchMove(componentRef, deltaY => {
221+
onRawWheel({ preventDefault() {}, deltaY } as WheelEvent);
222+
});
223+
218224
React.useEffect(() => {
219225
componentRef.current.addEventListener('wheel', onRawWheel);
220226
componentRef.current.addEventListener('DOMMouseScroll', onFireFoxScroll as any);

src/ScrollBar.tsx

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,17 @@ interface ScrollBarState {
2222
visible: boolean;
2323
}
2424

25+
function getPageY(e: React.MouseEvent | MouseEvent | TouchEvent) {
26+
return 'touches' in e ? e.touches[0].pageY : e.pageY;
27+
}
28+
2529
export default class ScrollBar extends React.Component<ScrollBarProps, ScrollBarState> {
2630
moveRaf: number = null;
2731

32+
scrollbarRef = React.createRef<HTMLDivElement>();
33+
34+
thumbRef = React.createRef<HTMLDivElement>();
35+
2836
visibleTimeout: NodeJS.Timeout = null;
2937

3038
state: ScrollBarState = {
@@ -34,6 +42,11 @@ export default class ScrollBar extends React.Component<ScrollBarProps, ScrollBar
3442
visible: false,
3543
};
3644

45+
componentDidMount() {
46+
this.scrollbarRef.current.addEventListener('touchstart', this.onScrollbarTouchStart);
47+
this.thumbRef.current.addEventListener('touchstart', this.onMouseDown);
48+
}
49+
3750
componentDidUpdate(prevProps: ScrollBarProps) {
3851
if (prevProps.scrollTop !== this.props.scrollTop) {
3952
this.delayHidden();
@@ -54,28 +67,43 @@ export default class ScrollBar extends React.Component<ScrollBarProps, ScrollBar
5467
}, 2000);
5568
};
5669

70+
onScrollbarTouchStart = (e: TouchEvent) => {
71+
e.preventDefault();
72+
};
73+
74+
onContainerMouseDown: React.MouseEventHandler = e => {
75+
e.stopPropagation();
76+
e.preventDefault();
77+
};
78+
79+
// ======================= Clean =======================
5780
patchEvents = () => {
5881
window.addEventListener('mousemove', this.onMouseMove);
5982
window.addEventListener('mouseup', this.onMouseUp);
83+
84+
this.thumbRef.current.addEventListener('touchmove', this.onMouseMove);
85+
this.thumbRef.current.addEventListener('touchend', this.onMouseUp);
6086
};
6187

6288
removeEvents = () => {
6389
window.removeEventListener('mousemove', this.onMouseMove);
6490
window.removeEventListener('mouseup', this.onMouseUp);
65-
raf.cancel(this.moveRaf);
66-
};
6791

68-
onContainerMouseDown: React.MouseEventHandler = e => {
69-
e.stopPropagation();
70-
e.preventDefault();
92+
this.scrollbarRef.current.removeEventListener('touchstart', this.onScrollbarTouchStart);
93+
this.thumbRef.current.removeEventListener('touchstart', this.onMouseDown);
94+
this.thumbRef.current.removeEventListener('touchmove', this.onMouseMove);
95+
this.thumbRef.current.removeEventListener('touchend', this.onMouseUp);
96+
97+
raf.cancel(this.moveRaf);
7198
};
7299

73-
onMouseDown: React.MouseEventHandler = e => {
100+
// ======================= Thumb =======================
101+
onMouseDown = (e: React.MouseEvent | TouchEvent) => {
74102
const { onStartMove } = this.props;
75103

76104
this.setState({
77105
dragging: true,
78-
pageY: e.pageY,
106+
pageY: getPageY(e),
79107
startTop: this.getTop(),
80108
});
81109

@@ -85,14 +113,14 @@ export default class ScrollBar extends React.Component<ScrollBarProps, ScrollBar
85113
e.preventDefault();
86114
};
87115

88-
onMouseMove = (e: MouseEvent) => {
116+
onMouseMove = (e: MouseEvent | TouchEvent) => {
89117
const { dragging, pageY, startTop } = this.state;
90118
const { onScroll } = this.props;
91119

92120
raf.cancel(this.moveRaf);
93121

94122
if (dragging) {
95-
const offsetY = e.pageY - pageY;
123+
const offsetY = getPageY(e) - pageY;
96124
const newTop = startTop + offsetY;
97125

98126
const enableScrollRange = this.getEnableScrollRange();
@@ -114,6 +142,7 @@ export default class ScrollBar extends React.Component<ScrollBarProps, ScrollBar
114142
this.removeEvents();
115143
};
116144

145+
// ===================== Calculate =====================
117146
getSpinHeight = () => {
118147
const { height, count } = this.props;
119148
let baseHeight = (height / count) * 10;
@@ -149,6 +178,7 @@ export default class ScrollBar extends React.Component<ScrollBarProps, ScrollBar
149178

150179
return (
151180
<div
181+
ref={this.scrollbarRef}
152182
className={`${prefixCls}-scrollbar`}
153183
style={{
154184
width: 8,
@@ -162,6 +192,7 @@ export default class ScrollBar extends React.Component<ScrollBarProps, ScrollBar
162192
onMouseMove={this.delayHidden}
163193
>
164194
<div
195+
ref={this.thumbRef}
165196
className={classNames(`${prefixCls}-scrollbar-thumb`, {
166197
[`${prefixCls}-scrollbar-thumb-moving`]: dragging,
167198
})}

src/hooks/useMobileTouchMove.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import * as React from 'react';
2+
import { useRef } from 'react';
3+
4+
const SMOOTH_PTG = 14 / 15;
5+
6+
export default function useMobileTouchMove(
7+
listRef: React.RefObject<HTMLDivElement>,
8+
callback: (offsetY: number) => void,
9+
) {
10+
const touchedRef = useRef(false);
11+
const touchYRef = useRef(0);
12+
13+
const elementRef = useRef<HTMLElement>(null);
14+
15+
// Smooth scroll
16+
const intervalRef = useRef(null);
17+
18+
let cleanUpEvents: () => void;
19+
20+
const onTouchMove = (e: TouchEvent) => {
21+
if (touchedRef.current) {
22+
const currentY = Math.ceil(e.touches[0].pageY);
23+
let offsetY = touchYRef.current - currentY;
24+
console.log('>>>', offsetY);
25+
touchYRef.current = currentY;
26+
27+
callback(offsetY);
28+
29+
// Smooth interval
30+
clearInterval(intervalRef.current);
31+
intervalRef.current = setInterval(() => {
32+
offsetY *= SMOOTH_PTG;
33+
callback(offsetY);
34+
35+
if (Math.abs(offsetY) <= 0.1) {
36+
clearInterval(intervalRef.current);
37+
}
38+
}, 16);
39+
}
40+
};
41+
42+
const onTouchEnd = () => {
43+
touchedRef.current = false;
44+
45+
cleanUpEvents();
46+
};
47+
48+
const onTouchStart = (e: TouchEvent) => {
49+
cleanUpEvents();
50+
51+
if (e.touches.length === 1 && !touchedRef.current) {
52+
touchedRef.current = true;
53+
touchYRef.current = Math.ceil(e.touches[0].pageY);
54+
e.preventDefault();
55+
56+
elementRef.current = e.target as HTMLElement;
57+
elementRef.current.addEventListener('touchmove', onTouchMove);
58+
elementRef.current.addEventListener('touchend', onTouchEnd);
59+
}
60+
};
61+
62+
cleanUpEvents = () => {
63+
if (elementRef.current) {
64+
elementRef.current.removeEventListener('touchmove', onTouchMove);
65+
elementRef.current.removeEventListener('touchend', onTouchEnd);
66+
}
67+
};
68+
69+
React.useEffect(() => {
70+
listRef.current.addEventListener('touchstart', onTouchStart);
71+
72+
return () => {
73+
listRef.current.removeEventListener('touchstart', onTouchStart);
74+
cleanUpEvents();
75+
clearInterval(intervalRef.current);
76+
};
77+
}, []);
78+
}

tests/touch.test.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React from 'react';
2+
import { mount } from 'enzyme';
3+
import { spyElementPrototypes } from './utils/domHook';
4+
import List from '../src';
5+
6+
function genData(count) {
7+
return new Array(count).fill(null).map((_, index) => ({ id: String(index) }));
8+
}
9+
10+
describe('List.Touch', () => {
11+
let mockElement;
12+
13+
beforeAll(() => {
14+
mockElement = spyElementPrototypes(HTMLElement, {
15+
offsetHeight: {
16+
get: () => 20,
17+
},
18+
clientHeight: {
19+
get: () => 100,
20+
},
21+
});
22+
});
23+
24+
afterAll(() => {
25+
mockElement.mockRestore();
26+
});
27+
28+
beforeEach(() => {
29+
jest.useFakeTimers();
30+
});
31+
32+
afterEach(() => {
33+
jest.useRealTimers();
34+
});
35+
36+
function genList(props) {
37+
let node = (
38+
<List component="ul" itemKey="id" {...props}>
39+
{({ id }) => <li>{id}</li>}
40+
</List>
41+
);
42+
43+
if (props.ref) {
44+
node = <div>{node}</div>;
45+
}
46+
47+
return mount(node);
48+
}
49+
50+
it('touch content', () => {
51+
const listRef = React.createRef();
52+
const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100), ref: listRef });
53+
54+
function getElement() {
55+
return wrapper.find('.rc-virtual-list-holder').instance();
56+
}
57+
58+
// start
59+
const touchEvent = new Event('touchstart');
60+
touchEvent.touches = [{ pageY: 100 }];
61+
getElement().dispatchEvent(touchEvent);
62+
63+
// move
64+
const moveEvent = new Event('touchmove');
65+
moveEvent.touches = [{ pageY: 90 }];
66+
getElement().dispatchEvent(moveEvent);
67+
68+
// end
69+
const endEvent = new Event('touchend');
70+
getElement().dispatchEvent(endEvent);
71+
72+
// smooth
73+
jest.runAllTimers();
74+
expect(wrapper.find('ul').instance().scrollTop > 10).toBeTruthy();
75+
76+
wrapper.unmount();
77+
});
78+
79+
it('should container preventDefault', () => {
80+
const preventDefault = jest.fn();
81+
const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) });
82+
83+
const touchEvent = new Event('touchstart');
84+
touchEvent.preventDefault = preventDefault;
85+
wrapper
86+
.find('.rc-virtual-list-scrollbar')
87+
.instance()
88+
.dispatchEvent(touchEvent);
89+
90+
expect(preventDefault).toHaveBeenCalled();
91+
});
92+
});

0 commit comments

Comments
 (0)