最近一直在做业务,遇到一个需求是: 页面顶部需要展示图片,可以拖动,拖动到最后一张的时候需要无缝切换到第一张,可以实现循环滑动。
这种需求场景挺常见的,比如淘宝,网易云,页面顶部都是这个模式:
如果不要求循环滑动,css就能实现:
父元素定宽,同时设置overflow-x:auto。子元素有宽度,横向排列即可。
一旦要求循环滑动,或者不能用overflow,就会有点麻烦:
- 需要监听
touchStart,touchMove,touchEnd,计算位移 - 将位移量设置到元素的
translateX(${transform}px) - 元素排列第一个前面需要补充最后一个元素,同理最后一个元素需要补充第一个元素,进行循环滑动
- 滑动偏移量计算到左边和右边那个更近,进行回弹,不能存在两个卡片滑动到一半的情况
- 需要区分
touchEnd和click事件,touchEnd优先click事件触发。 - 解决兼容问题,阻止部分浏览器滑动时的页面下拉
css样式问题
本文会实现以上的全部功能,相信看完一定有所收获~
1. 监听 touch 事件,计算位移
监听touchStart,touchMove,touchEnd,
let start = 0, move = 0, end = 0
const touchStart = (e) => {
// 计算滑动的起点,同时需要保存上一次的停留的位置
start = e.touches[0].clientX - end
}
const touchMove = (e) => {
// 记录滑动的距离
move = e.touches[0].clientX - start
}
const touchEnd = () => {
// 记录结束滑动的位置
end = move
}
2.给父元素绑定位移量
```js
<div className={cx('swapper')}
onTouchStart={touchStart}
onTouchMove={touchMove}
onTouchEnd={touchEnd}
style={{ transform: `translateX(${transform}px)`, transition: transition ? 'transform 0.3s' : 'none' }}
>
{
list.map((item, index) => {
return <div></div>
})
}
</div>
```
当滑动到最后一张的时候,跳回第一张,实现首尾衔接:
3.循环滚动
将列表的第一张复制一份,放到最后一张后面。把最后一张复制一份,放到第一张前面
if (length > 1) {
const startItem = list[0]
const endItem = list[length - 1]
list.push(startItem)
list.unshift(endItem)
}
4.滑动回弹效果
滑动的时候设置弹性回弹,比如卡片滑动了一半,需要就近回弹到最近的卡片
需要计算下卡片的滑动节点:比如[-750,-375,0,375]
const length = list.length
const boundary = []
const width = document.body.clientWidth
for (let i = -length + 1; i <= 0; i++) {
boundary.push(i * width)
}
boundary.unshift(boundary[0] - width)
boundary.push(boundary[boundary.length - 1] + width)
判断下滑动时,距离左右卡片哪个最近:
for (let i = 1; i < boundary.length; i++) {
// 最后卡片结束的位置在某个区间之内
if (end > boundary[i - 1] && end < boundary[i]) {
// 通过减法判断下距离左边还是距离右边更近
if (end - boundary[i - 1] < boundary[i] - end) {
end = boundary[i - 1]
// 设置最终的位置
setTransform(boundary[i - 1])
} else {
end = boundary[i]
setTransform(boundary[i])
}
break
}
}
回弹卡片的时候需要过渡效果,但滑动卡片的时候不需要过渡效果,所以在touchMove阶段移除transition,在touchEnd阶段开启过渡效果。
滑动结束后,需要判断边界情况:是否是最后一张,需要跳转到第一张。需要注意,这个时候是不需要过渡效果的:
if (end < boundary[0]) {
end = boundary[0]
setTransform(end)
}
if (end > boundary[boundary.length - 1]) {
end = boundary[boundary.length - 1]
setTransform(end)
}
setTimeout(() => {
// 关闭过渡效果
setTransition(false)
// 如果是第二张,需要跳转到倒数第二张
if (end < boundary[1]) {
end = boundary[boundary.length - 2]
setTransform(end)
}
// 如果是倒数第二张,需要跳转到第二张
// 确保滑动的图片都在中间,而不是在列表的第一张和最后一张
if (end > boundary[boundary.length - 2]) {
end = boundary[1]
setTransform(end)
}
// 300 是因为需要等待回弹动画结束,transition设置的0.3s
}, 300);
如果觉得滑动到一半,才到下一张,距离比较远,可以手动加上移动距离
// 判断是左滑还是右滑
if (oneMove - oneStart > 0) {
// 加上一大段位移
end = move + (document.body.clientWidth / 2)
} else {
// 加上负数的一大段位移
end = move - (document.body.clientWidth / 2)
}
// 区分如果滑动的太多,就不需要再加上位移了,否则会滑动好几张
if (Math.abs(oneMove - oneStart) >= document.body.clientWidth / 2) {
end = move
}
这样只需要一个轻微的滑动即可回弹到下一张
5.区分touchEnd和click事件
判断策略是:
(1).有没有位移量
(2).触屏时间小于150ms
// 设置三个全局变量
let startTime = 0, endTime = 0
let isMove = false
const touchStart = (e) => {
startTime = Date.now()
}
const touchMove = (e) => {
isMove = true
}
const touchEnd = (e) => {
endTime = Date.now()
if (!isMove && endTime - startTime < 150) return
isMove = false
}
6.阻止滑动时的页面下拉
在safari浏览器,页面是可以进行下拉操作的,如果滑动的组件是在页面顶部,就会出现滑动时一边下一张,一边下拉,影响用户体验,所以我们需要在滑动时阻止页面下拉的操作。
需要注意的是,等滑动完成后,需要再次打开页面下拉的操作。否则页面将不能滑动。
阻止方法如下:
// 阻止滑动时的默认事件和冒泡,避免冒泡到document会导致下拉
const preventDefault = (e) => {
e.stopPropagation();
e.preventDefault();
}
// 给document会导致下拉添加阻止默认事件和冒泡
const addTouchDefault = () => {
document.addEventListener('touchmove', preventDefault, { passive: false })
}
// 滑动结束后,移除document的阻止事件
const removeTouchDefault = () => {
if (touchDefaultRef.current) {
clearInterval(touchDefaultRef.current)
}
touchDefaultRef.current = setTimeout(() => {
document.removeEventListener('touchmove', preventDefault, { passive: false })
}, 400);
}
添加事件到touchStart和touchMove中,不放在touchEnd中是为了防止某些浏览器touchEnd不触发,导致无法移除document事件,导致页面不能滚动。
const touchStart = (e) => {
addTouchDefault()
oneStart = e.touches[0].clientX
start = oneStart - end
startTime = Date.now()
}
const touchMove = (e) => {
oneMove = e.touches[0].clientX
move = oneMove - start
isMove = true
setTransition(false)
setTransform(move)
removeTouchDefault()
}
7.css部分
.swapper{
// 脱离文档流
// 因为放在页面的最顶层,相对定位会让页面在加载完数据后,把页面撑开,
// 有闪动效果,绝对定位不会
position: absolute;
top: 0;
left: 0;
width: 750px;
// 父容器需要设置超出隐藏
overflow: hidden;
z-index: 2;
&-container {
// 元素横向排列
display: flex;
flex-flow: row nowrap;
position: relative;
&_item {
position: relative;
// 必须要设置宽度,否则页面不能被撑开,导致父级没有宽度
width: 750px;
}
}
}
8.完整代码如下:
// 必须要放在组件外
let start = 0, move = 0, end = 0, oneMove = 0, oneStart = 0
let startTime = 0, endTime = 0
let isMove = false
const cx = classBind.bind(styles);
const Component = (props) => {
const { arr = [] } = props
const [list, setList] = useState(arr)
// 控制位移
const [transform, setTransform] = useState(0)
// 控制过渡效果
const [transition, setTransition] = useState(false)
// 阻止默认事件
const preventDefault = (e) => {
e.stopPropagation();
e.preventDefault();
}
// 给document添加阻止默认事件
const addTouchDefault = () => {
document.addEventListener('touchmove', preventDefault, { passive: false })
}
// 移除document阻止默认事件
const removeTouchDefault = () => {
if (touchDefaultRef.current) {
clearInterval(touchDefaultRef.current)
}
touchDefaultRef.current = setTimeout(() => {
document.removeEventListener('touchmove', preventDefault, { passive: false })
}, 400);
}
// 记录初始位置
const touchStart = (e) => {
// 阻止默认事件
addTouchDefault()
// 记录start的位置
oneStart = e.touches[0].clientX
start = oneStart - end
startTime = Date.now()
}
// 计算位移
const touchMove = (e) => {
oneMove = e.touches[0].clientX
move = oneMove - start
isMove = true
setTransition(false)
setTransform(move)
removeTouchDefault()
}
// 结束后计算回弹,和边界情况
const touchEnd = (e) => {
// 记录结束时间
endTime = Date.now()
if (!isMove && endTime - startTime < 150) return
// 移动结束
isMove = false
const length = list[0].listLength
const boundary = []
const width = document.body.clientWidth
// 计算卡片的位移距离和边界
for (let i = -length + 1; i <= 0; i++) {
boundary.push(i * width)
}
boundary.unshift(boundary[0] - width)
boundary.push(boundary[boundary.length - 1] + width)
// 判断是左滑还是右滑,加上一段距离
if (oneMove - oneStart > 0) {
end = move + (document.body.clientWidth / 2)
} else {
end = move - (document.body.clientWidth / 2)
}
// 如果用户滑动的距离太远,就不需要加上距离
if (Math.abs(oneMove - oneStart) >= document.body.clientWidth / 2) {
end = move
}
setTransition(true)
// 滑动的距离超出边界,让其回到边界
if (end < boundary[0]) {
end = boundary[0]
setTransform(end)
}
// 滑动的距离超出边界,让其回到边界
if (end > boundary[boundary.length - 1]) {
end = boundary[boundary.length - 1]
setTransform(end)
}
if (timeRef.current) {
clearInterval(timeRef.current)
}
// 滑动到重复补充的卡片时,需要移除动画
timeRef.current = setTimeout(() => {
setTransition(false)
if (end < boundary[1]) {
end = boundary[boundary.length - 2]
setTransform(end)
}
if (end > boundary[boundary.length - 2]) {
end = boundary[1]
setTransform(end)
}
}, 300);
// 判断距离那个卡片最近,回弹到那一张
for (let i = 1; i < boundary.length; i++) {
if (end > boundary[i - 1] && end < boundary[i]) {
if (end - boundary[i - 1] < boundary[i] - end) {
end = boundary[i - 1]
setTransform(boundary[i - 1])
} else {
end = boundary[i]
setTransform(boundary[i])
}
break
}
}
}
useEffect(() => {
const { length } = list
list.forEach((item, index) => {
// 保存真实的位置,因为后面会补充前后两张卡片
item.activeKey = index
// 保存真实的列表长度,不直接减2是因为有1张卡片的情况
item.listLength = length
})
if (length > 1) {
const startItem = list[0]
const endItem = list[length - 1]
list.push(startItem)
list.unshift(endItem)
}
setList([...list])
// 采用 addEventListener 监听是因为 react 的合成事件默认passive:true
// 如果需要在事件中阻止冒泡和默认事件,会导致报错
// react事件目前没有参数去传递passive属性
if (swapperRef.current && list.length > 1) {
swapperRef.current.addEventListener('touchstart', touchStart, { passive: false })
swapperRef.current.addEventListener('touchmove', touchMove, { passive: false })
swapperRef.current.addEventListener('touchend', touchEnd, { passive: false })
}
return () => {
if (swapperRef.current && list.length > 1) {
swapperRef.current.removeEventListener('touchstart', touchStart, { passive: false })
swapperRef.current.removeEventListener('touchmove', touchMove, { passive: false })
swapperRef.current.removeEventListener('touchend', touchEnd, { passive: false })
}
}
}, [arr]);
return <div className={cx('swapper')}>
<div className={cx('swapper-container')}
ref={swapperRef}
style={{ transform: `translateX(${transform}px)`, transition: transition ? 'transform 0.3s' : 'none' }}
>
{
list.map((item, index) => {
return <div className={cx('swapper-container_item') key={index}>这是卡片</div>
})
}
</div>
</div>
}