Skip to content

Commit 299fa2a

Browse files
jaluikhchlqcrazylxr
authored
feat(useInfiniteScroll): support scroll to top (#2565)
* feat(useInfiniteScroll): support scroll to top * test: add useInfiniteScroll test case * refactor: 重构代码 --------- Co-authored-by: huangcheng <[email protected]> Co-authored-by: lxr <[email protected]> Co-authored-by: 潇见 <[email protected]>
1 parent f58f34d commit 299fa2a

File tree

6 files changed

+255
-49
lines changed

6 files changed

+255
-49
lines changed

packages/hooks/src/useInfiniteScroll/__tests__/index.test.ts

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ let count = 0;
88
export async function mockRequest() {
99
await sleep(1000);
1010
if (count >= 1) {
11-
return { list: [] };
11+
return { list: [4, 5, 6] };
1212
}
1313
count++;
1414
return {
@@ -19,6 +19,14 @@ export async function mockRequest() {
1919

2020
const targetEl = document.createElement('div');
2121

22+
// set target property
23+
function setTargetInfo(key: 'scrollTop', value) {
24+
Object.defineProperty(targetEl, key, {
25+
value,
26+
configurable: true,
27+
});
28+
}
29+
2230
const setup = <T extends Data>(service: Service<T>, options?: InfiniteScrollOptions<T>) =>
2331
renderHook(() => useInfiniteScroll(service, options));
2432

@@ -93,27 +101,90 @@ describe('useInfiniteScroll', () => {
93101
jest.advanceTimersByTime(1000);
94102
});
95103
expect(result.current.loading).toBe(false);
104+
const scrollHeightSpy = jest
105+
.spyOn(targetEl, 'scrollHeight', 'get')
106+
.mockImplementation(() => 150);
107+
const clientHeightSpy = jest
108+
.spyOn(targetEl, 'clientHeight', 'get')
109+
.mockImplementation(() => 300);
110+
setTargetInfo('scrollTop', 100);
111+
act(() => {
112+
events['scroll']();
113+
});
114+
expect(result.current.loadingMore).toBe(true);
115+
await act(async () => {
116+
jest.advanceTimersByTime(1000);
117+
});
118+
expect(result.current.loadingMore).toBe(false);
96119

97-
// mock scroll
98-
Object.defineProperties(targetEl, {
99-
clientHeight: {
100-
value: 150,
101-
},
102-
scrollHeight: {
103-
value: 300,
104-
},
105-
scrollTop: {
106-
value: 100,
120+
// not work when no more
121+
expect(result.current.noMore).toBe(true);
122+
act(() => {
123+
events['scroll']();
124+
});
125+
expect(result.current.loadingMore).toBe(false);
126+
// get list by order
127+
expect(result.current.data?.list).toMatchObject([1, 2, 3, 4, 5, 6]);
128+
129+
mockAddEventListener.mockRestore();
130+
scrollHeightSpy.mockRestore();
131+
clientHeightSpy.mockRestore();
132+
});
133+
134+
it('should auto load when scroll to top', async () => {
135+
const events = {};
136+
const mockAddEventListener = jest
137+
.spyOn(targetEl, 'addEventListener')
138+
.mockImplementation((eventName, callback) => {
139+
events[eventName] = callback;
140+
});
141+
// Mock scrollTo using Object.defineProperty
142+
Object.defineProperty(targetEl, 'scrollTo', {
143+
value: (x: number, y: number) => {
144+
setTargetInfo('scrollTop', y);
107145
},
146+
writable: true,
147+
});
148+
149+
const { result } = setup(mockRequest, {
150+
target: targetEl,
151+
direction: 'top',
152+
isNoMore: (d) => d?.nextId === undefined,
153+
});
154+
// not work when loading
155+
expect(result.current.loading).toBe(true);
156+
events['scroll']();
157+
await act(async () => {
158+
jest.advanceTimersByTime(1000);
159+
});
160+
expect(result.current.loading).toBe(false);
161+
162+
// mock first scroll
163+
const scrollHeightSpy = jest
164+
.spyOn(targetEl, 'scrollHeight', 'get')
165+
.mockImplementation(() => 150);
166+
const clientHeightSpy = jest
167+
.spyOn(targetEl, 'clientHeight', 'get')
168+
.mockImplementation(() => 500);
169+
setTargetInfo('scrollTop', 300);
170+
171+
act(() => {
172+
events['scroll']();
108173
});
174+
// mock scroll upward
175+
setTargetInfo('scrollTop', 50);
176+
109177
act(() => {
110178
events['scroll']();
111179
});
180+
112181
expect(result.current.loadingMore).toBe(true);
113182
await act(async () => {
114183
jest.advanceTimersByTime(1000);
115184
});
116185
expect(result.current.loadingMore).toBe(false);
186+
//reverse order
187+
expect(result.current.data?.list).toMatchObject([4, 5, 6, 1, 2, 3]);
117188

118189
// not work when no more
119190
expect(result.current.noMore).toBe(true);
@@ -123,6 +194,8 @@ describe('useInfiniteScroll', () => {
123194
expect(result.current.loadingMore).toBe(false);
124195

125196
mockAddEventListener.mockRestore();
197+
scrollHeightSpy.mockRestore();
198+
clientHeightSpy.mockRestore();
126199
});
127200

128201
it('reload should be work', async () => {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import React, { useRef } from 'react';
2+
import { useInfiniteScroll } from 'ahooks';
3+
4+
interface Result {
5+
list: string[];
6+
nextId: string | undefined;
7+
}
8+
9+
const resultData = [
10+
'15',
11+
'14',
12+
'13',
13+
'12',
14+
'11',
15+
'10',
16+
'9',
17+
'8',
18+
'7',
19+
'6',
20+
'5',
21+
'4',
22+
'3',
23+
'2',
24+
'1',
25+
'0',
26+
];
27+
28+
function getLoadMoreList(nextId: string | undefined, limit: number): Promise<Result> {
29+
let start = 0;
30+
if (nextId) {
31+
start = resultData.findIndex((i) => i === nextId);
32+
}
33+
const end = start + limit;
34+
const list = resultData.slice(start, end).reverse();
35+
const nId = resultData.length >= end ? resultData[end] : undefined;
36+
return new Promise((resolve) => {
37+
setTimeout(() => {
38+
resolve({
39+
list,
40+
nextId: nId,
41+
});
42+
}, 1000);
43+
});
44+
}
45+
46+
export default () => {
47+
const ref = useRef<HTMLDivElement>(null);
48+
const isFirstIn = useRef(true);
49+
50+
const { data, loading, loadMore, loadingMore, noMore } = useInfiniteScroll(
51+
(d) => getLoadMoreList(d?.nextId, 5),
52+
{
53+
target: ref,
54+
direction: 'top',
55+
threshold: 0,
56+
isNoMore: (d) => d?.nextId === undefined,
57+
onSuccess() {
58+
if (isFirstIn.current) {
59+
isFirstIn.current = false;
60+
setTimeout(() => {
61+
const el = ref.current;
62+
if (el) {
63+
el.scrollTo(0, 999999);
64+
}
65+
});
66+
}
67+
},
68+
},
69+
);
70+
71+
return (
72+
<div ref={ref} style={{ height: 150, overflow: 'auto', border: '1px solid', padding: 12 }}>
73+
{loading ? (
74+
<p>loading</p>
75+
) : (
76+
<div>
77+
<div style={{ marginBottom: 10 }}>
78+
{!noMore && (
79+
<button type="button" onClick={loadMore} disabled={loadingMore}>
80+
{loadingMore ? 'Loading more...' : 'Click to load more'}
81+
</button>
82+
)}
83+
84+
{noMore && <span>No more data</span>}
85+
</div>
86+
{data?.list?.map((item) => (
87+
<div key={item} style={{ padding: 12, border: '1px solid #f5f5f5' }}>
88+
item-{item}
89+
</div>
90+
))}
91+
</div>
92+
)}
93+
</div>
94+
);
95+
};

packages/hooks/src/useInfiniteScroll/index.en-US.md

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,14 @@ In the infinite scrolling scenario, the most common case is to automatically loa
3636

3737
- `options.target` specifies the parent element, The parent element needs to set a fixed height and support internal scrolling
3838
- `options.isNoMore` determines if there is no more data
39+
- `options.direction` determines the direction of scrolling, the default is `bottom`
3940

41+
the scroll to bottom demo
4042
<code src="./demo/scroll.tsx" />
4143

44+
the scroll to top demo
45+
<code src="./demo/scrollTop.tsx" />
46+
4247
## Data reset
4348

4449
The data can be reset by `reload`. The following example shows that after the `filter` changes, the data is reset to the first page.
@@ -111,14 +116,15 @@ const {
111116

112117
### Options
113118

114-
| Property | Description | Type | Default |
115-
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------- |
116-
| target | specifies the parent element. If it exists, it will trigger the `loadMore` when scrolling to the bottom. Needs to work with `isNoMore` to know when there is no more data to load. **when target is document, it is defined as the entire viewport** | `() => Element` \| `Element` \| `MutableRefObject<Element>` | - |
117-
| isNoMore | determines if there is no more data, the input parameter is the latest merged `data` | `(data?: TData) => boolean` | - |
118-
| threshold | The pixel threshold to the bottom for the scrolling to load | `number` | `100` |
119-
| reloadDeps | When the content of the array changes, `reload` will be triggered | `any[]` | - |
120-
| manual | <ul><li> The default is `false`. That is, the service is automatically executed during initialization. </li><li>If set to `true`, you need to manually call `run` or `runAsync` to trigger execution </li></ul> | `boolean` | `false` |
121-
| onBefore | Triggered before service execution | `() => void` | - |
122-
| onSuccess | Triggered when service resolve | `(data: TData) => void` | - |
123-
| onError | Triggered when service reject | `(e: Error) => void` | - |
124-
| onFinally | Triggered when service execution is complete | `(data?: TData, e?: Error) => void` | - |
119+
| Property | Description | Type | Default |
120+
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- |
121+
| target | specifies the parent element. If it exists, it will trigger the `loadMore` when scrolling to the bottom. Needs to work with `isNoMore` to know when there is no more data to load. **when target is document, it is defined as the entire viewport** | `() => Element` \| `Element` \| `MutableRefObject<Element>` | - |
122+
| isNoMore | determines if there is no more data, the input parameter is the latest merged `data` | `(data?: TData) => boolean` | - |
123+
| threshold | The pixel threshold to the bottom for the scrolling to load | `number` | `100` |
124+
| direction | The direction of the scrolling | `bottom` \|`top` | `bottom` |
125+
| reloadDeps | When the content of the array changes, `reload` will be triggered | `any[]` | - |
126+
| manual | <ul><li> The default is `false`. That is, the service is automatically executed during initialization. </li><li>If set to `true`, you need to manually call `run` or `runAsync` to trigger execution </li></ul> | `boolean` | `false` |
127+
| onBefore | Triggered before service execution | `() => void` | - |
128+
| onSuccess | Triggered when service resolve | `(data: TData) => void` | - |
129+
| onError | Triggered when service reject | `(e: Error) => void` | - |
130+
| onFinally | Triggered when service execution is complete | `(data?: TData, e?: Error) => void` | - |

packages/hooks/src/useInfiniteScroll/index.tsx

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useState } from 'react';
1+
import { useMemo, useRef, useState } from 'react';
22
import useEventListener from '../useEventListener';
33
import useMemoizedFn from '../useMemoizedFn';
44
import useRequest from '../useRequest';
@@ -15,6 +15,7 @@ const useInfiniteScroll = <TData extends Data>(
1515
target,
1616
isNoMore,
1717
threshold = 100,
18+
direction = 'bottom',
1819
reloadDeps = [],
1920
manual,
2021
onBefore,
@@ -25,6 +26,11 @@ const useInfiniteScroll = <TData extends Data>(
2526

2627
const [finalData, setFinalData] = useState<TData>();
2728
const [loadingMore, setLoadingMore] = useState(false);
29+
const isScrollToTop = direction === 'top';
30+
// lastScrollTop is used to determine whether the scroll direction is up or down
31+
const lastScrollTop = useRef<number>();
32+
// scrollBottom is used to record the distance from the bottom of the scroll bar
33+
const scrollBottom = useRef<number>(0);
2834

2935
const noMore = useMemo(() => {
3036
if (!isNoMore) return false;
@@ -42,7 +48,9 @@ const useInfiniteScroll = <TData extends Data>(
4248
} else {
4349
setFinalData({
4450
...currentData,
45-
list: [...(lastData.list ?? []), ...currentData.list],
51+
list: isScrollToTop
52+
? [...currentData.list, ...(lastData.list ?? [])]
53+
: [...(lastData.list ?? []), ...currentData.list],
4654
});
4755
}
4856
return currentData;
@@ -56,9 +64,19 @@ const useInfiniteScroll = <TData extends Data>(
5664
onBefore: () => onBefore?.(),
5765
onSuccess: (d) => {
5866
setTimeout(() => {
59-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
60-
scrollMethod();
67+
if (isScrollToTop) {
68+
let el = getTargetElement(target);
69+
el = el === document ? document.documentElement : el;
70+
if (el) {
71+
const scrollHeight = getScrollHeight(el);
72+
(el as Element).scrollTo(0, scrollHeight - scrollBottom.current);
73+
}
74+
} else {
75+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
76+
scrollMethod();
77+
}
6178
});
79+
6280
onSuccess?.(d);
6381
},
6482
onError: (e) => onError?.(e),
@@ -88,18 +106,25 @@ const useInfiniteScroll = <TData extends Data>(
88106
};
89107

90108
const scrollMethod = () => {
91-
let el = getTargetElement(target);
92-
if (!el) {
93-
return;
94-
}
95-
96-
el = el === document ? document.documentElement : el;
97-
98-
const scrollTop = getScrollTop(el);
99-
const scrollHeight = getScrollHeight(el);
100-
const clientHeight = getClientHeight(el);
101-
102-
if (scrollHeight - scrollTop <= clientHeight + threshold) {
109+
const el = getTargetElement(target);
110+
if (!el) return;
111+
112+
const targetEl = el === document ? document.documentElement : el;
113+
const scrollTop = getScrollTop(targetEl);
114+
const scrollHeight = getScrollHeight(targetEl);
115+
const clientHeight = getClientHeight(targetEl);
116+
117+
if (isScrollToTop) {
118+
if (
119+
lastScrollTop.current !== undefined &&
120+
lastScrollTop.current > scrollTop &&
121+
scrollTop <= threshold
122+
) {
123+
loadMore();
124+
}
125+
lastScrollTop.current = scrollTop;
126+
scrollBottom.current = scrollHeight - scrollTop;
127+
} else if (scrollHeight - scrollTop <= clientHeight + threshold) {
103128
loadMore();
104129
}
105130
};

0 commit comments

Comments
 (0)