Skip to content

Commit

Permalink
feat(useInfiniteScroll): support scroll to top (#2565)
Browse files Browse the repository at this point in the history
* feat(useInfiniteScroll): support scroll to top

* test: add useInfiniteScroll test case

* refactor: 重构代码

---------

Co-authored-by: huangcheng <huangcheng.lq@bytedance.com>
Co-authored-by: lxr <1076629390@qq.com>
Co-authored-by: 潇见 <xiaojian.lj@antgroup.com>
  • Loading branch information
4 people authored Sep 26, 2024
1 parent f58f34d commit 299fa2a
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 49 deletions.
95 changes: 84 additions & 11 deletions packages/hooks/src/useInfiniteScroll/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ let count = 0;
export async function mockRequest() {
await sleep(1000);
if (count >= 1) {
return { list: [] };
return { list: [4, 5, 6] };
}
count++;
return {
Expand All @@ -19,6 +19,14 @@ export async function mockRequest() {

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

// set target property
function setTargetInfo(key: 'scrollTop', value) {
Object.defineProperty(targetEl, key, {
value,
configurable: true,
});
}

const setup = <T extends Data>(service: Service<T>, options?: InfiniteScrollOptions<T>) =>
renderHook(() => useInfiniteScroll(service, options));

Expand Down Expand Up @@ -93,27 +101,90 @@ describe('useInfiniteScroll', () => {
jest.advanceTimersByTime(1000);
});
expect(result.current.loading).toBe(false);
const scrollHeightSpy = jest
.spyOn(targetEl, 'scrollHeight', 'get')
.mockImplementation(() => 150);
const clientHeightSpy = jest
.spyOn(targetEl, 'clientHeight', 'get')
.mockImplementation(() => 300);
setTargetInfo('scrollTop', 100);
act(() => {
events['scroll']();
});
expect(result.current.loadingMore).toBe(true);
await act(async () => {
jest.advanceTimersByTime(1000);
});
expect(result.current.loadingMore).toBe(false);

// mock scroll
Object.defineProperties(targetEl, {
clientHeight: {
value: 150,
},
scrollHeight: {
value: 300,
},
scrollTop: {
value: 100,
// not work when no more
expect(result.current.noMore).toBe(true);
act(() => {
events['scroll']();
});
expect(result.current.loadingMore).toBe(false);
// get list by order
expect(result.current.data?.list).toMatchObject([1, 2, 3, 4, 5, 6]);

mockAddEventListener.mockRestore();
scrollHeightSpy.mockRestore();
clientHeightSpy.mockRestore();
});

it('should auto load when scroll to top', async () => {
const events = {};
const mockAddEventListener = jest
.spyOn(targetEl, 'addEventListener')
.mockImplementation((eventName, callback) => {
events[eventName] = callback;
});
// Mock scrollTo using Object.defineProperty
Object.defineProperty(targetEl, 'scrollTo', {
value: (x: number, y: number) => {
setTargetInfo('scrollTop', y);
},
writable: true,
});

const { result } = setup(mockRequest, {
target: targetEl,
direction: 'top',
isNoMore: (d) => d?.nextId === undefined,
});
// not work when loading
expect(result.current.loading).toBe(true);
events['scroll']();
await act(async () => {
jest.advanceTimersByTime(1000);
});
expect(result.current.loading).toBe(false);

// mock first scroll
const scrollHeightSpy = jest
.spyOn(targetEl, 'scrollHeight', 'get')
.mockImplementation(() => 150);
const clientHeightSpy = jest
.spyOn(targetEl, 'clientHeight', 'get')
.mockImplementation(() => 500);
setTargetInfo('scrollTop', 300);

act(() => {
events['scroll']();
});
// mock scroll upward
setTargetInfo('scrollTop', 50);

act(() => {
events['scroll']();
});

expect(result.current.loadingMore).toBe(true);
await act(async () => {
jest.advanceTimersByTime(1000);
});
expect(result.current.loadingMore).toBe(false);
//reverse order
expect(result.current.data?.list).toMatchObject([4, 5, 6, 1, 2, 3]);

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

mockAddEventListener.mockRestore();
scrollHeightSpy.mockRestore();
clientHeightSpy.mockRestore();
});

it('reload should be work', async () => {
Expand Down
95 changes: 95 additions & 0 deletions packages/hooks/src/useInfiniteScroll/demo/scrollTop.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React, { useRef } from 'react';
import { useInfiniteScroll } from 'ahooks';

interface Result {
list: string[];
nextId: string | undefined;
}

const resultData = [
'15',
'14',
'13',
'12',
'11',
'10',
'9',
'8',
'7',
'6',
'5',
'4',
'3',
'2',
'1',
'0',
];

function getLoadMoreList(nextId: string | undefined, limit: number): Promise<Result> {
let start = 0;
if (nextId) {
start = resultData.findIndex((i) => i === nextId);
}
const end = start + limit;
const list = resultData.slice(start, end).reverse();
const nId = resultData.length >= end ? resultData[end] : undefined;
return new Promise((resolve) => {
setTimeout(() => {
resolve({
list,
nextId: nId,
});
}, 1000);
});
}

export default () => {
const ref = useRef<HTMLDivElement>(null);
const isFirstIn = useRef(true);

const { data, loading, loadMore, loadingMore, noMore } = useInfiniteScroll(
(d) => getLoadMoreList(d?.nextId, 5),
{
target: ref,
direction: 'top',
threshold: 0,
isNoMore: (d) => d?.nextId === undefined,
onSuccess() {
if (isFirstIn.current) {
isFirstIn.current = false;
setTimeout(() => {
const el = ref.current;
if (el) {
el.scrollTo(0, 999999);
}
});
}
},
},
);

return (
<div ref={ref} style={{ height: 150, overflow: 'auto', border: '1px solid', padding: 12 }}>
{loading ? (
<p>loading</p>
) : (
<div>
<div style={{ marginBottom: 10 }}>
{!noMore && (
<button type="button" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading more...' : 'Click to load more'}
</button>
)}

{noMore && <span>No more data</span>}
</div>
{data?.list?.map((item) => (
<div key={item} style={{ padding: 12, border: '1px solid #f5f5f5' }}>
item-{item}
</div>
))}
</div>
)}
</div>
);
};
28 changes: 17 additions & 11 deletions packages/hooks/src/useInfiniteScroll/index.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,14 @@ In the infinite scrolling scenario, the most common case is to automatically loa

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

the scroll to bottom demo
<code src="./demo/scroll.tsx" />

the scroll to top demo
<code src="./demo/scrollTop.tsx" />

## Data reset

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

### Options

| Property | Description | Type | Default |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------- |
| 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>` | - |
| isNoMore | determines if there is no more data, the input parameter is the latest merged `data` | `(data?: TData) => boolean` | - |
| threshold | The pixel threshold to the bottom for the scrolling to load | `number` | `100` |
| reloadDeps | When the content of the array changes, `reload` will be triggered | `any[]` | - |
| 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` |
| onBefore | Triggered before service execution | `() => void` | - |
| onSuccess | Triggered when service resolve | `(data: TData) => void` | - |
| onError | Triggered when service reject | `(e: Error) => void` | - |
| onFinally | Triggered when service execution is complete | `(data?: TData, e?: Error) => void` | - |
| Property | Description | Type | Default |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- |
| 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>` | - |
| isNoMore | determines if there is no more data, the input parameter is the latest merged `data` | `(data?: TData) => boolean` | - |
| threshold | The pixel threshold to the bottom for the scrolling to load | `number` | `100` |
| direction | The direction of the scrolling | `bottom` \|`top` | `bottom` |
| reloadDeps | When the content of the array changes, `reload` will be triggered | `any[]` | - |
| 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` |
| onBefore | Triggered before service execution | `() => void` | - |
| onSuccess | Triggered when service resolve | `(data: TData) => void` | - |
| onError | Triggered when service reject | `(e: Error) => void` | - |
| onFinally | Triggered when service execution is complete | `(data?: TData, e?: Error) => void` | - |
57 changes: 41 additions & 16 deletions packages/hooks/src/useInfiniteScroll/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import useEventListener from '../useEventListener';
import useMemoizedFn from '../useMemoizedFn';
import useRequest from '../useRequest';
Expand All @@ -15,6 +15,7 @@ const useInfiniteScroll = <TData extends Data>(
target,
isNoMore,
threshold = 100,
direction = 'bottom',
reloadDeps = [],
manual,
onBefore,
Expand All @@ -25,6 +26,11 @@ const useInfiniteScroll = <TData extends Data>(

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

const noMore = useMemo(() => {
if (!isNoMore) return false;
Expand All @@ -42,7 +48,9 @@ const useInfiniteScroll = <TData extends Data>(
} else {
setFinalData({
...currentData,
list: [...(lastData.list ?? []), ...currentData.list],
list: isScrollToTop
? [...currentData.list, ...(lastData.list ?? [])]
: [...(lastData.list ?? []), ...currentData.list],
});
}
return currentData;
Expand All @@ -56,9 +64,19 @@ const useInfiniteScroll = <TData extends Data>(
onBefore: () => onBefore?.(),
onSuccess: (d) => {
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
scrollMethod();
if (isScrollToTop) {
let el = getTargetElement(target);
el = el === document ? document.documentElement : el;
if (el) {
const scrollHeight = getScrollHeight(el);
(el as Element).scrollTo(0, scrollHeight - scrollBottom.current);
}
} else {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
scrollMethod();
}
});

onSuccess?.(d);
},
onError: (e) => onError?.(e),
Expand Down Expand Up @@ -88,18 +106,25 @@ const useInfiniteScroll = <TData extends Data>(
};

const scrollMethod = () => {
let el = getTargetElement(target);
if (!el) {
return;
}

el = el === document ? document.documentElement : el;

const scrollTop = getScrollTop(el);
const scrollHeight = getScrollHeight(el);
const clientHeight = getClientHeight(el);

if (scrollHeight - scrollTop <= clientHeight + threshold) {
const el = getTargetElement(target);
if (!el) return;

const targetEl = el === document ? document.documentElement : el;
const scrollTop = getScrollTop(targetEl);
const scrollHeight = getScrollHeight(targetEl);
const clientHeight = getClientHeight(targetEl);

if (isScrollToTop) {
if (
lastScrollTop.current !== undefined &&
lastScrollTop.current > scrollTop &&
scrollTop <= threshold
) {
loadMore();
}
lastScrollTop.current = scrollTop;
scrollBottom.current = scrollHeight - scrollTop;
} else if (scrollHeight - scrollTop <= clientHeight + threshold) {
loadMore();
}
};
Expand Down
Loading

0 comments on commit 299fa2a

Please sign in to comment.