diff --git a/packages/hooks/src/useInfiniteScroll/__tests__/index.test.ts b/packages/hooks/src/useInfiniteScroll/__tests__/index.test.ts index 12d0c8654b..1c3e8ce882 100644 --- a/packages/hooks/src/useInfiniteScroll/__tests__/index.test.ts +++ b/packages/hooks/src/useInfiniteScroll/__tests__/index.test.ts @@ -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 { @@ -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 = (service: Service, options?: InfiniteScrollOptions) => renderHook(() => useInfiniteScroll(service, options)); @@ -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); @@ -123,6 +194,8 @@ describe('useInfiniteScroll', () => { expect(result.current.loadingMore).toBe(false); mockAddEventListener.mockRestore(); + scrollHeightSpy.mockRestore(); + clientHeightSpy.mockRestore(); }); it('reload should be work', async () => { diff --git a/packages/hooks/src/useInfiniteScroll/demo/scrollTop.tsx b/packages/hooks/src/useInfiniteScroll/demo/scrollTop.tsx new file mode 100644 index 0000000000..b49688dd23 --- /dev/null +++ b/packages/hooks/src/useInfiniteScroll/demo/scrollTop.tsx @@ -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 { + 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(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 ( +
+ {loading ? ( +

loading

+ ) : ( +
+
+ {!noMore && ( + + )} + + {noMore && No more data} +
+ {data?.list?.map((item) => ( +
+ item-{item} +
+ ))} +
+ )} +
+ ); +}; diff --git a/packages/hooks/src/useInfiniteScroll/index.en-US.md b/packages/hooks/src/useInfiniteScroll/index.en-US.md index b26f5f1f46..9d3a58fd33 100644 --- a/packages/hooks/src/useInfiniteScroll/index.en-US.md +++ b/packages/hooks/src/useInfiniteScroll/index.en-US.md @@ -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 +the scroll to top demo + + ## 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. @@ -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` | - | -| 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 |
  • The default is `false`. That is, the service is automatically executed during initialization.
  • If set to `true`, you need to manually call `run` or `runAsync` to trigger execution
| `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` | - | +| 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 |
  • The default is `false`. That is, the service is automatically executed during initialization.
  • If set to `true`, you need to manually call `run` or `runAsync` to trigger execution
| `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` | - | diff --git a/packages/hooks/src/useInfiniteScroll/index.tsx b/packages/hooks/src/useInfiniteScroll/index.tsx index a559c562d1..2d082ea0c2 100644 --- a/packages/hooks/src/useInfiniteScroll/index.tsx +++ b/packages/hooks/src/useInfiniteScroll/index.tsx @@ -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'; @@ -15,6 +15,7 @@ const useInfiniteScroll = ( target, isNoMore, threshold = 100, + direction = 'bottom', reloadDeps = [], manual, onBefore, @@ -25,6 +26,11 @@ const useInfiniteScroll = ( const [finalData, setFinalData] = useState(); 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(); + // scrollBottom is used to record the distance from the bottom of the scroll bar + const scrollBottom = useRef(0); const noMore = useMemo(() => { if (!isNoMore) return false; @@ -42,7 +48,9 @@ const useInfiniteScroll = ( } else { setFinalData({ ...currentData, - list: [...(lastData.list ?? []), ...currentData.list], + list: isScrollToTop + ? [...currentData.list, ...(lastData.list ?? [])] + : [...(lastData.list ?? []), ...currentData.list], }); } return currentData; @@ -56,9 +64,19 @@ const useInfiniteScroll = ( 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), @@ -88,18 +106,25 @@ const useInfiniteScroll = ( }; 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(); } }; diff --git a/packages/hooks/src/useInfiniteScroll/index.zh-CN.md b/packages/hooks/src/useInfiniteScroll/index.zh-CN.md index f9483407e2..e60dcc2a83 100644 --- a/packages/hooks/src/useInfiniteScroll/index.zh-CN.md +++ b/packages/hooks/src/useInfiniteScroll/index.zh-CN.md @@ -36,9 +36,14 @@ useInfiniteScroll 的第一个参数 `service` 是一个异步函数,对这个 - `options.target` 指定父级元素(父级元素需设置固定高度,且支持内部滚动) - `options.isNoMore` 判断是不是没有更多数据了 +- `options.direction` 滚动的方向,默认为向下滚动 +向下滚动示例 +向上滚动示例 + + ## 数据重置 通过 `reload` 即可实现数据重置,下面示例我们演示在 `filter` 变化后,重置数据到第一页。 @@ -111,14 +116,15 @@ const { ### Options -| 参数 | 说明 | 类型 | 默认值 | -| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------- | -| target | 父级容器,如果存在,则在滚动到底部时,自动触发 `loadMore`。需要配合 `isNoMore` 使用,以便知道什么时候到最后一页了。 **当 target 为 document 时,定义为整个视口** | `() => Element` \| `Element` \| `MutableRefObject` | - | -| isNoMore | 是否有最后一页的判断逻辑,入参为当前聚合后的 `data` | `(data?: TData) => boolean` | - | -| threshold | 下拉自动加载,距离底部距离阈值 | `number` | `100` | -| reloadDeps | 变化后,会自动触发 `reload` | `any[]` | - | -| manual |
  • 默认 `false`。 即在初始化时自动执行 service。
  • 如果设置为 `true`,则需要手动调用 `reload` 或 `reloadAsync` 触发执行。
| `boolean` | `false` | -| onBefore | service 执行前触发 | `() => void` | - | -| onSuccess | service resolve 时触发 | `(data: TData) => void` | - | -| onError | service reject 时触发 | `(e: Error) => void` | - | -| onFinally | service 执行完成时触发 | `(data?: TData, e?: Error) => void` | - | +| 参数 | 说明 | 类型 | 默认值 | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- | +| target | 父级容器,如果存在,则在滚动到底部时,自动触发 `loadMore`。需要配合 `isNoMore` 使用,以便知道什么时候到最后一页了。 **当 target 为 document 时,定义为整个视口** | `() => Element` \| `Element` \| `MutableRefObject` | - | +| isNoMore | 是否有最后一页的判断逻辑,入参为当前聚合后的 `data` | `(data?: TData) => boolean` | - | +| threshold | 下拉自动加载,距离底部距离阈值 | `number` | `100` | +| direction | 滚动的方向 | `bottom` \| `top` | `bottom` | +| reloadDeps | 变化后,会自动触发 `reload` | `any[]` | - | +| manual |
  • 默认 `false`。 即在初始化时自动执行 service。
  • 如果设置为 `true`,则需要手动调用 `reload` 或 `reloadAsync` 触发执行。
| `boolean` | `false` | +| onBefore | service 执行前触发 | `() => void` | - | +| onSuccess | service resolve 时触发 | `(data: TData) => void` | - | +| onError | service reject 时触发 | `(e: Error) => void` | - | +| onFinally | service 执行完成时触发 | `(data?: TData, e?: Error) => void` | - | diff --git a/packages/hooks/src/useInfiniteScroll/types.ts b/packages/hooks/src/useInfiniteScroll/types.ts index cc17a688e4..251e81130c 100644 --- a/packages/hooks/src/useInfiniteScroll/types.ts +++ b/packages/hooks/src/useInfiniteScroll/types.ts @@ -24,6 +24,7 @@ export interface InfiniteScrollOptions { target?: BasicTarget; isNoMore?: (data?: TData) => boolean; threshold?: number; + direction?: 'bottom' | 'top'; manual?: boolean; reloadDeps?: DependencyList;