Skip to content

Commit abdf0ff

Browse files
authored
refactor: Use customize scrollbar (#45)
* mock scroll * use my scrollbar * customize scroll bar * clean up * sync top * fix test * scrollbar coverage * coverage * skip invalidate top
1 parent f166395 commit abdf0ff

File tree

7 files changed

+286
-35
lines changed

7 files changed

+286
-35
lines changed

examples/animate.less

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
.motion {
2-
transition: all 5s;
2+
transition: all .3s;
33
}
44

55
.item {

examples/basic.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ const Demo = () => {
146146

147147
{!destroy && (
148148
<List
149+
id="list"
149150
ref={listRef}
150151
data={data}
151152
height={200}

src/List.tsx

Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
22
import { useRef } from 'react';
33
import classNames from 'classnames';
44
import Filler from './Filler';
5+
import ScrollBar from './ScrollBar';
56
import { RenderFunc, SharedConfig, GetKey } from './interface';
67
import useChildren from './hooks/useChildren';
78
import useHeights from './hooks/useHeights';
@@ -12,7 +13,7 @@ import useFrameWheel from './hooks/useFrameWheel';
1213

1314
const EMPTY_DATA = [];
1415

15-
const ScrollStyle = {
16+
const ScrollStyle: React.CSSProperties = {
1617
overflowY: 'auto',
1718
overflowAnchor: 'none',
1819
};
@@ -50,7 +51,7 @@ export interface ListProps<T> extends React.HTMLAttributes<any> {
5051

5152
export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
5253
const {
53-
prefixCls,
54+
prefixCls = 'rc-virtual-list',
5455
className,
5556
height,
5657
itemHeight,
@@ -89,6 +90,21 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
8990
getKey,
9091
};
9192

93+
// ================================ Scroll ================================
94+
function syncScrollTop(newTop: number | ((prev: number) => number)) {
95+
setScrollTop(origin => {
96+
let value: number;
97+
if (typeof newTop === 'function') {
98+
value = newTop(origin);
99+
} else {
100+
value = newTop;
101+
}
102+
103+
componentRef.current.scrollTop = value;
104+
return value;
105+
});
106+
}
107+
92108
// ================================ Legacy ================================
93109
// Put ref here since the range is generate by follow
94110
const rangeRef = useRef({ start: 0, end: mergedData.length });
@@ -142,7 +158,8 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
142158
itemTop = currentItemBottom;
143159
}
144160

145-
// Fallback to normal if not match
161+
// Fallback to normal if not match. This code should never reach
162+
/* istanbul ignore next */
146163
if (startIndex === undefined) {
147164
startIndex = 0;
148165
startOffset = 0;
@@ -171,20 +188,25 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
171188
// ================================ Scroll ================================
172189
// Since this added in global,should use ref to keep update
173190
const onRawWheel = useFrameWheel(inVirtual, offsetY => {
174-
setScrollTop(top => {
191+
syncScrollTop(top => {
175192
const newTop = keepInRange(top + offsetY);
176-
177-
componentRef.current.scrollTop = newTop;
178193
return newTop;
179194
});
180195
});
181196

182-
// Additional handle the scroll which not trigger by wheel
183-
function onRawScroll(event: React.UIEvent) {
184-
const newScrollTop = (event.target as HTMLDivElement).scrollTop;
197+
function onScrollBar(newScrollTop: number) {
185198
const newTop = keepInRange(newScrollTop);
186199
if (newTop !== scrollTop) {
187-
setScrollTop(newTop);
200+
syncScrollTop(newTop);
201+
}
202+
}
203+
204+
// This code may only trigger in test case.
205+
// But we still need a sync if some special escape
206+
function onFallbackScroll(e: React.UIEvent) {
207+
const { scrollTop: newScrollTop } = e.currentTarget;
208+
if (newScrollTop !== scrollTop) {
209+
syncScrollTop(newScrollTop);
188210
}
189211
}
190212

@@ -203,6 +225,7 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
203225
itemHeight,
204226
getKey,
205227
collectHeight,
228+
syncScrollTop,
206229
);
207230

208231
React.useImperativeHandle(ref, () => ({
@@ -212,25 +235,46 @@ export function RawList<T>(props: ListProps<T>, ref: React.Ref<ListRef>) {
212235
// ================================ Render ================================
213236
const listChildren = useChildren(mergedData, start, end, setInstanceRef, children, sharedConfig);
214237

238+
let componentStyle: React.CSSProperties = null;
239+
if (height) {
240+
componentStyle = { [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle };
241+
componentStyle.overflowY = 'hidden';
242+
}
243+
215244
return (
216-
<Component
217-
style={
218-
height ? { ...style, [fullHeight ? 'height' : 'maxHeight']: height, ...ScrollStyle } : style
219-
}
245+
<div
246+
style={{
247+
...style,
248+
position: 'relative',
249+
}}
220250
className={mergedClassName}
221251
{...restProps}
222-
ref={componentRef}
223-
onScroll={onRawScroll}
224252
>
225-
<Filler
226-
prefixCls={prefixCls}
227-
height={scrollHeight}
228-
offset={offset}
229-
onInnerResize={collectHeight}
253+
<Component
254+
className={`${prefixCls}-holder`}
255+
style={componentStyle}
256+
ref={componentRef}
257+
onScroll={onFallbackScroll}
230258
>
231-
{listChildren}
232-
</Filler>
233-
</Component>
259+
<Filler
260+
prefixCls={prefixCls}
261+
height={scrollHeight}
262+
offset={offset}
263+
onInnerResize={collectHeight}
264+
>
265+
{listChildren}
266+
</Filler>
267+
</Component>
268+
269+
<ScrollBar
270+
prefixCls={prefixCls}
271+
scrollTop={scrollTop}
272+
height={height}
273+
scrollHeight={scrollHeight}
274+
count={mergedData.length}
275+
onScroll={onScrollBar}
276+
/>
277+
</div>
234278
);
235279
}
236280

src/ScrollBar.tsx

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import * as React from 'react';
2+
import classNames from 'classnames';
3+
import raf from 'rc-util/lib/raf';
4+
5+
const MIN_SIZE = 20;
6+
7+
export interface ScrollBarProps {
8+
prefixCls: string;
9+
scrollTop: number;
10+
scrollHeight: number;
11+
height: number;
12+
count: number;
13+
onScroll: (scrollTop: number) => void;
14+
}
15+
16+
interface ScrollBarState {
17+
dragging: boolean;
18+
pageY: number;
19+
startTop: number;
20+
visible: boolean;
21+
}
22+
23+
export default class ScrollBar extends React.Component<ScrollBarProps, ScrollBarState> {
24+
moveRaf: number = null;
25+
26+
visibleTimeout: NodeJS.Timeout = null;
27+
28+
state: ScrollBarState = {
29+
dragging: false,
30+
pageY: null,
31+
startTop: null,
32+
visible: false,
33+
};
34+
35+
componentDidUpdate(prevProps: ScrollBarProps) {
36+
if (prevProps.scrollTop !== this.props.scrollTop) {
37+
this.delayHidden();
38+
}
39+
}
40+
41+
componentWillUnmount() {
42+
this.removeEvents();
43+
clearTimeout(this.visibleTimeout);
44+
}
45+
46+
delayHidden = () => {
47+
clearTimeout(this.visibleTimeout);
48+
49+
this.setState({ visible: true });
50+
this.visibleTimeout = setTimeout(() => {
51+
this.setState({ visible: false });
52+
}, 2000);
53+
};
54+
55+
patchEvents = () => {
56+
window.addEventListener('mousemove', this.onMouseMove);
57+
window.addEventListener('mouseup', this.onMouseUp);
58+
};
59+
60+
removeEvents = () => {
61+
window.removeEventListener('mousemove', this.onMouseMove);
62+
window.removeEventListener('mouseup', this.onMouseUp);
63+
raf.cancel(this.moveRaf);
64+
};
65+
66+
onMouseDown: React.MouseEventHandler = e => {
67+
this.setState({
68+
dragging: true,
69+
pageY: e.pageY,
70+
startTop: this.getTop(),
71+
});
72+
73+
this.patchEvents();
74+
e.stopPropagation();
75+
};
76+
77+
onMouseMove = (e: MouseEvent) => {
78+
const { dragging, pageY, startTop } = this.state;
79+
const { onScroll } = this.props;
80+
81+
raf.cancel(this.moveRaf);
82+
83+
if (dragging) {
84+
const offsetY = e.pageY - pageY;
85+
const newTop = startTop + offsetY;
86+
87+
const enableScrollRange = this.getEnableScrollRange();
88+
const enableHeightRange = this.getEnableHeightRange();
89+
90+
const ptg = newTop / enableHeightRange;
91+
const newScrollTop = Math.ceil(ptg * enableScrollRange);
92+
this.moveRaf = raf(() => {
93+
onScroll(newScrollTop);
94+
});
95+
}
96+
};
97+
98+
onMouseUp = () => {
99+
this.setState({ dragging: false });
100+
this.removeEvents();
101+
};
102+
103+
getSpinHeight = () => {
104+
const { height, count } = this.props;
105+
let baseHeight = (height / count) * 10;
106+
baseHeight = Math.max(baseHeight, MIN_SIZE);
107+
baseHeight = Math.min(baseHeight, height / 2);
108+
return Math.floor(baseHeight);
109+
};
110+
111+
getEnableScrollRange = () => {
112+
const { scrollHeight, height } = this.props;
113+
return scrollHeight - height;
114+
};
115+
116+
getEnableHeightRange = () => {
117+
const { height } = this.props;
118+
const spinHeight = this.getSpinHeight();
119+
return height - spinHeight;
120+
};
121+
122+
getTop = () => {
123+
const { scrollTop } = this.props;
124+
const enableScrollRange = this.getEnableScrollRange();
125+
const enableHeightRange = this.getEnableHeightRange();
126+
const ptg = scrollTop / enableScrollRange;
127+
return ptg * enableHeightRange;
128+
};
129+
130+
render() {
131+
const { visible, dragging } = this.state;
132+
const { prefixCls } = this.props;
133+
const spinHeight = this.getSpinHeight();
134+
const top = this.getTop();
135+
136+
return (
137+
<div
138+
className={`${prefixCls}-scrollbar`}
139+
style={{
140+
width: 8,
141+
top: 0,
142+
bottom: 0,
143+
right: 0,
144+
position: 'absolute',
145+
display: visible ? null : 'none',
146+
}}
147+
onMouseMove={this.delayHidden}
148+
>
149+
<div
150+
className={classNames(`${prefixCls}-scrollbar-thumb`, {
151+
[`${prefixCls}-scrollbar-thumb-moving`]: dragging,
152+
})}
153+
style={{
154+
width: '100%',
155+
height: spinHeight,
156+
top,
157+
left: 0,
158+
position: 'absolute',
159+
background: 'rgba(0, 0, 0, 0.5)',
160+
borderRadius: 99,
161+
cursor: 'pointer',
162+
userSelect: 'none',
163+
}}
164+
onMouseDown={this.onMouseDown}
165+
/>
166+
</div>
167+
);
168+
}
169+
}

src/hooks/useScrollTo.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ export default function useScrollTo<T>(
1212
itemHeight: number,
1313
getKey: GetKey<T>,
1414
collectHeight: () => void,
15+
syncScrollTop: (newTop: number) => void,
1516
): ScrollTo {
1617
const scrollRef = React.useRef<number>();
1718

1819
return arg => {
1920
raf.cancel(scrollRef.current);
2021

2122
if (typeof arg === 'number') {
22-
containerRef.current.scrollTop = arg;
23+
syncScrollTop(arg);
2324
} else if (arg && typeof arg === 'object') {
2425
let index: number;
2526
const { align } = arg;
@@ -80,7 +81,7 @@ export default function useScrollTo<T>(
8081
}
8182

8283
if (targetTop !== null && targetTop !== containerRef.current.scrollTop) {
83-
containerRef.current.scrollTop = targetTop;
84+
syncScrollTop(targetTop);
8485
}
8586

8687
// We will retry since element may not sync height as it described

tests/list.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ describe('List.Basic', () => {
7979
mockElement.mockRestore();
8080
});
8181

82-
it('scroll', () => {
82+
it('scroll it', () => {
8383
// scroll to top
8484
scrollTop = 0;
8585
const wrapper = genList({ itemHeight: 20, height: 100, data: genData(100) });

0 commit comments

Comments
 (0)