记录一下最全的手写无限循环滑动

1,035 阅读5分钟

最近一直在做业务,遇到一个需求是: 页面顶部需要展示图片,可以拖动,拖动到最后一张的时候需要无缝切换到第一张,可以实现循环滑动。

这种需求场景挺常见的,比如淘宝,网易云,页面顶部都是这个模式:

image.png

如果不要求循环滑动,css就能实现:

父元素定宽,同时设置overflow-x:auto。子元素有宽度,横向排列即可。

一旦要求循环滑动,或者不能用overflow,就会有点麻烦:

  1. 需要监听touchStart,touchMove,touchEnd,计算位移
  2. 将位移量设置到元素的translateX(${transform}px)
  3. 元素排列第一个前面需要补充最后一个元素,同理最后一个元素需要补充第一个元素,进行循环滑动
  4. 滑动偏移量计算到左边和右边那个更近,进行回弹,不能存在两个卡片滑动到一半的情况
  5. 需要区分touchEndclick事件,touchEnd优先click事件触发。
  6. 解决兼容问题,阻止部分浏览器滑动时的页面下拉
  7. 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.区分touchEndclick事件

判断策略是:

(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);
    }

添加事件到touchStarttouchMove中,不放在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>
}

其他推荐