好久没记笔记了,最近又做了h5的开发,使用了 antd-mobile 的 PullToRefresh 拉动刷新,但是毛病很多,最后还是决定自己封装。
踩坑
原理和之前做的下拉刷新一样,只是拉动的方向不一样,上拉加载需要判断滚动条处于底部位置,这个地方也有坑。
1、正常情况下我们认为 scrollHeight - scrollTop - clientHeight === 0 则判断滚动条处于底部位置,但是真机测试发现这个数值不为零,而是0.xxx,所以我使用了5,我估计这也是 antd-mobile 的 PullToRefresh 失效的原因。
2、对于全局滚动条的判断就更夸张了,在三星手机上 scrollHeight - scrollTop - clientHeight 大概是55.xxx,所以全局位置的判断我用了一个更大的值 100。
废话不多说,上代码
1、index.tsx
/**
* 上拉加载组件
*
* lvxh
*/
import { CSSProperties, useCallback, useEffect, useRef, useState } from 'react'
import styles from './index.module.less'
import { geiComtainer } from './utils'
import { useCalculateHeight } from './useCalculateHeight'
import { useTouchHandle } from './useTouchHandle'
import { ReactNode } from 'react'
interface Props {
children: ReactNode // 子元素
loadMore: (page: number) => Promise // 上拉加载函数,会传page参数,需要返回一个 Promise 以更新加载提示
pageSize?: number // pageSize
initPage?: number // 初始page,默认1
total?: number // 分页里的总数据,传入可以在加载完所有数据就停止加载并给出提示,不传会一直加载
fixedHeight?: boolean // 是否不改变滚动元素高度,不改变的话 height = 窗口高度,不传 height = 窗口高度 - 列表距离顶部高度
resetPage?: boolean | string // 是否重置 page,一个页面多个列表共用一个下拉刷新时重置状态,传入列表唯一的key即可
docIsBottom?: boolean // 是否监听 html 的滚动条也到底部才触发更新,默认为 true
height?: number | string // 支持自己配置height,不传值则使用 useCalculateHeight 计算出的height
distanceToRefresh?: number // 刷新距离,默认值 20
damping?: number // 拉动距离限制, 建议小于 200 默认值 100
textStyle?: CSSProperties // 底部提示语样式
}
export default ({
children,
loadMore,
pageSize = 10,
initPage = 1,
total,
fixedHeight,
resetPage,
docIsBottom = true,
height,
distanceToRefresh = 20,
damping = 100,
textStyle
}: Props) => {
const [show, setShow] = useState(false) // 提示元素隐藏
const [text, setText] = useState('') // 提示信息
const pageRef = useRef(initPage)
const totalRef = useRef(total) // 执行加载标志
const pageSizeRef = useRef(pageSize)
const [_height] = useCalculateHeight({ fixedHeight })
useEffect(() => {
if (total) totalRef.current = total
}, [total])
useEffect(() => {
if (pageSize) pageSizeRef.current = pageSize
}, [pageSize])
useEffect(() => {
if (resetPage) pageRef.current = initPage
}, [resetPage, initPage])
// 加载函数,每次 page加1,并且loadMore需返回一个Promies
const loadMoreHandle = useCallback(async () => {
pageRef.current++
if (typeof loadMore === 'function') {
await loadMore(pageRef.current)
setShow(false)
setText('')
const container: any = geiComtainer()
if (container) {
// 加载完滚动条上移30,让用户看到新加载的数据
container.scrollTop = container?.scrollTop + 30
}
}
}, [loadMore])
const [touchstart, touchmove, touchend] = useTouchHandle({
totalRef,
pageRef,
pageSizeRef,
docIsBottom,
distanceToRefresh,
damping,
setText,
setShow,
loadMoreHandle
})
const init = useCallback(() => {
const container: any = geiComtainer()
container?.addEventListener('touchstart', touchstart, false)
container?.addEventListener('touchmove', touchmove, false)
container?.addEventListener('touchend', touchend, false)
}, [touchstart, touchmove, touchend])
const remove = useCallback(() => {
const container: any = geiComtainer()
container?.removeEventListener('touchstart', touchstart, false)
container?.removeEventListener('touchmove', touchmove, false)
container?.removeEventListener('touchend', touchend, false)
}, [touchstart, touchmove, touchend])
useEffect(() => {
const timer = setTimeout(() => {
init()
}, 0);
() => {
remove()
clearTimeout(timer)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
{children}
{show && {text}
}
)
}
2、useTouchHandle.ts
import { useCallback, useRef } from 'react'
import { geiComtainer, getScrollIsBottom } from './utils'
let _startPos: any = 0,
_startX: any = 0,
_transitionWidth: any = 0,
_transitionHeight: any = 0
export const useTouchHandle = ({
totalRef,
pageSizeRef,
pageRef,
setText,
setShow,
loadMoreHandle,
docIsBottom,
damping,
distanceToRefresh
}: any) => {
const flageRef = useRef(false) // 执行加载标志
// 手势起点,获取初始位置,初始化加载标志
const touchstart = useCallback((e: any) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
_startPos = e.touches[0].pageY
// eslint-disable-next-line react-hooks/exhaustive-deps
_startX = e.touches[0].pageX
flageRef.current = false
}, [])
// 手势移动,计算滚动条位置、移动距离,判断在滚动条到达底部切移动距离大于30,设置加载标志为true,显示提示信息
const touchmove = useCallback((e: any) => {
if (_transitionWidth === 0) { // 阻止其频繁变动,保证能进入【上拉加载】就能继续【释放加载更多】
// eslint-disable-next-line react-hooks/exhaustive-deps
_transitionWidth = Math.abs(e.touches[0].pageX - _startX) // 防止横向滑动
}
// eslint-disable-next-line react-hooks/exhaustive-deps
_transitionHeight = _startPos - e.touches[0].pageY
if (getScrollIsBottom(docIsBottom) && _transitionWidth < 10 && _transitionHeight > 0 && _transitionHeight < damping) {
const container: any = geiComtainer()
container.style.transition = 'transform 0s'
container.style.transform = `translateY(-${_transitionHeight}px)`
if (totalRef.current < pageSizeRef.current * pageRef.current) {
setText('没有更多数据了')
setShow(true)
flageRef.current = false
} else {
setText('上拉加载')
setShow(true)
if (_transitionHeight > distanceToRefresh) {
flageRef.current = true
setText('释放加载数据')
_transitionHeight = 0
}
}
}
}, [pageSizeRef, pageRef, setShow, setText, totalRef, docIsBottom, distanceToRefresh, damping])
// 判断加载标志flage,执行加载函数
const touchend = useCallback(() => {
const container: any = geiComtainer()
container.style.transition = 'transform 0.5s'
container.style.transform = 'translateY(0)'
if (flageRef.current) {
setText('加载中...')
loadMoreHandle()
} else {
setShow(false)
setText('')
}
}, [loadMoreHandle, setShow, setText])
return [touchstart, touchmove, touchend]
}
3、utils.ts
export const geiComtainer = () => {
return document.getElementById('load-more-Container')
}
const isBottom = (dom: any, min = 5) => {
return (dom?.scrollHeight || 0) - (dom?.scrollTop || 0) - (dom?.clientHeight || 0) < min // 滚动条到底部位置
}
export const getScrollIsBottom = (docIsBottom?: boolean) => {
return docIsBottom ? (isBottom(document.documentElement, 100) || isBottom(document.body, 100)) && isBottom(geiComtainer()) : isBottom(geiComtainer())
}
4、useCalculateHeight.ts
/**
* 计算元素高度
*/
import { useCallback, useEffect, useState } from 'react'
import { geiComtainer } from './utils'
interface Props {
fixedHeight?: boolean // 是否不改变滚动元素高度,不改变的话 height = 窗口高度,不传 height = 窗口高度 - 列表距离顶部高度
}
export const useCalculateHeight = ({ fixedHeight }: Props) => {
const [height, setHeight] = useState(0)
// 计算滚动元素初始高度
const calculateHeight = useCallback(() => {
if (fixedHeight) {
setHeight('100vh')
return
}
const initHeight = Math.max(document.body.clientHeight, document.documentElement.clientHeight)
// eslint-disable-next-line react/no-find-dom-node
const offsetTop = geiComtainer()?.offsetTop || 0
let _height = initHeight - offsetTop
if (_height < initHeight * 0.66) _height = initHeight * 0.66 // 最小三分之二
setHeight(_height)
}, [fixedHeight])
// 计算滚动元素最终高度,在数据少的时候根据子级元素高度,设置滚动元素高度
useEffect(() => {
const timer = setTimeout(() => {
calculateHeight()
}, 0);
() => clearTimeout(timer)
}, [calculateHeight])
return [height]
}
5、index.module.less
.load-more {
overflow: hidden;
.load-more-Container {
overflow-y: auto;
position: relative;
}
.load-more-text {
position: fixed;
bottom: 2rem;
z-index: 10;
width: 100%;
padding-bottom: 0.2rem;
font-size: 0.32rem;
text-align: center;
}
}