最近忙于写业务代码和修改上古MPA的JS页面,对React欠缺使用和学习,感觉自己都快写不来代码了。拿来主义思想占据了思维,所以还是要造造轮子。因为最近在做移动端的东西,所以尝试写一个移动端的无缝轮播图,当前版本只支持手势切换和点击切换功能。文章主要包括从简单雏形到最终效果所有的思路和代码。
问:无缝轮播需要解决的问题在于,切换到最后一个轮播图时,如何流畅的到达第一个?
答:核心思想是利用视觉上的感觉,在用户无感的情况下切换回去,也就是快速回滚。为了达成这个目的,就是在最后一个轮播图的后面加上第一个轮播图,当从最后一个切换到第一个时,先切换到备用的第一个,然后快速回滚到真正的第一个轮播图。第一个同理,可能有点绕,可以看图理解:
布局思路就是这样,这样布局也就是需要多增加一个轮播子组件,如果子组件的布局复杂(类似卡片或者其他复杂组件),就有点浪费资源,为了减少不必要dom的渲染,可以使用类似摩天轮的方式,循环补位,本质上思路不变,只是当在最后一个轮播图时,把第一个轮播图移动到它的后面,然后瞬间把第一个轮播图又移动到第一个位置。第一个轮播图同理。文字描述不好理解,还是看图说话吧:
先创建一个外层包裹容器,也就是可视区容器,然后使用一个包裹容器把所有的轮播子组件进行包裹,之后轮播图的滚动都是控制包裹容器的位置来进行切换的。轮播图子组件需要位置可移动所以都使用绝对定位。点击按钮单独呈现,代码如下:
/* 可视区容器 */
<div>
/* 包裹容器 */
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
/* 按钮容器 */
<div>
<button>Left</button>
<button>Right</button>
</div>
</div>
其实轮播图核心功能就是切换。只是切换的方式不同,比如点击切换、手势切换、自动切换,所以我们先从基础的切换入手。
import React, { useState, useEffect, useRef } from 'react';
import styles from './index.css';
const Carousel = ({children, selectedIndex = 1}) => {
// 当切换的时候,改变的就是当前位置状态
// 所以定义当前位置,可以通过传入的selectedIndex来控制最开始显示第几个轮播图,默认从1开始
const [active, setActive] = useState(selectedIndex);
// 获取包裹容器
const container = useRef(null);
// 获取当前可视区容器宽度
const SCREEN_WIDTH = window.screen.width;
// 统一处理,当active发生变化的时候,我们需要做的就是切换轮播图到某个位置,转场通过控制包裹容器的transform来进行切换,对transform的控制封装成setTransition函数
useEffect(() => {
setTransition();
}, [active]);
const setTransition = () => {
// 计算需要移动的距离并进行修改,这是切换的核心
const distance = (1 - active) * SCREEN_WIDTH;
container.current.style.transform = `translate3d(${distance}px, 0, 0)`;
}
// 为了演示是否成功,添加两个按钮来切换
// 上一页
const handlePrev = () => {
// 对临界值进行处理
setActive(active === 1 ? children.length : active - 1)
}
// 下一页
const handleNext = () => {
// 对临界值进行处理
setActive(active === children.length ? 1 : active + 1);
}
return (
<div className={style.carousel}>
<div
ref={container}
className={styles.container}>
{
React.Children.map(children, (child, index) => {
return (
<div
style={{left: index * SCREEN_WIDTH}}
className={styles.items}>{child}</div>
)
})
}
</div>
<div>
<div onClick={handlePrev} className={styles.buttonLeft}>Left</div>
<div onClick={handleNext} className={styles.buttonRight}>Right</div>
</div>
</div>
)
}
附css代码
.carousel{
width: 100%;
overflow: hidden;
position: relative;
}
.container{
transform: translate3d(0,0,0);
transition-duration: 1s;
transition-property: all;
transition-timing-function: ease;
position: relative;
height: 100px;
}
.items {
width: 100%;
flex-shrink: 0;
position: absolute;
top: 0;
}
.buttonLeft {
position: absolute;
left: 0;
top: 0;
bottom: 0;
margin: auto;
width: 30px;
height: 30px;
background: #fff;
}
.buttonRight {
position: absolute;
right: 0;
top: 0;
bottom: 0;
margin: auto;
width: 30px;
height: 30px;
background: #fff;
}
效果如图:
这样就完成了基本的切换了。但是还没有实现无缝切换,最后一个切换到第一个的时候我们还没有用上面的思路进行处理。现在开始处理无缝的问题,主要处理如何循环补位能达到瞬间转换的效果,我这里是使用container.current.style.transitionProperty = ‘none’关闭动画来进行瞬间切换。
const Carousel = ({children, selectedIndex = 1}) => {
// 创建一个参数,对轮播图的状态进行控制 1为静止,2为进行中。主要目的是避免快速切换导致的bug。
// 所以只有在动画结束过后,也就是静止的时候才能再次切换轮播图
const [status, setStatus] = useState(1);
...
...相同代码省略
...
// 因为要达到流畅的切换,已当前为第一个轮播图为例,向左切换时,最后一个轮播图补位,然后瞬间归位(在此时取消过渡动画,完成流畅切换); 对setTransition进行修改并新增offset参数对函数进行增强,后面具体会讲到此参数的作用。
const setTransition = (offset = 0) => {
...新增的代码
function transitionend() {
// 动画结束后就关闭动画
container.current.style.transitionProperty = 'none';
// 恢复状态为1静止
setStatus(1);
// 当前位置在补位的位置时马上切换到本该在的位置
if (active === 0) {
// 使用setTimeout包裹,避免transitionProperty动画未关闭就切换的闪频
setTimeout(() => {
setActive(children.length);
}, 0)
}
if (active === children.length + 1) {
setTimeout(() => {
setActive(1);
}, 0)
}
container.current.removeEventListener('transitionend', transitionend, false);
}
container.current.addEventListener('transitionend', transitionend, false);
const distance = (1 - active) * SCREEN_WIDTH;
...修改的代码,新增offset
container.current.style.transform = `translate3d(${distance+offset}px,0,0)`;
}
}
...新增代码,封装对active修改的操作
const handleChangeActive = (number) => {
// 当在动画进行时,不允许切换
if (status === 2) return;
// 切换前先把动画参数打开
container.current.style.transitionProperty = 'all';
// 修改状态为进行时
setStatus(2);
// 改变当前位置
setActive(number);
}
...修改的代码
const handlePrev = () => {
// 根据之前的理论,当前位置如果是第一个的情况下,最后一个轮播图会跳到第一个的前面
// 切换到前面的时候active就要减去到0才能到达位置,所以对此进行修改,并使用前面封装的handleChangeActive方法进行包裹
// 之前的代码
// setActive(active === 1 ? children.length : active - 1);
// 修改过后的代码
handleChangeActive(active === 0 ? children.length : active - 1);
}
const handleNext = () => {
// 此处同上同理
// 之前的代码
// setActive(active === children.length ? 1 : active + 1);
handleChangeActive(active === children.length + 1 ? 1 : active + 1);
}
return (
<div className={styles.carousel}>
<div
ref={container}
className={styles.container}>
{
React.Children.map(children, (child, index) => {
...修改的代码
// 当轮播图处于第一个时,最后一个组件时,提取到最前面去
if (active <= 1 && index + 1 === children.length) {
return (
<div style={{left: -1 * SCREEN_WIDTH}} className={styles.items}>{child}</div>
)
}
// 当轮播图处于最后一个时,第一个组件提取到最后面
if (active >= children.length && index === 0) {
return (
<div style={{left: children.length * SCREEN_WIDTH}} className={styles.item}>{child}</div>
)
}
return (
<div
style={{left: index * SCREEN_WIDTH}}
className={styles.items}>{child}</div>
)
})
}
</div>
</div>
)
}
到这里无缝轮播就算大功告成。之后就是对轮播图进行扩展了。不管怎么切换,使用核心的两个函数就可以解决大部分功能需求(setTransition、handleChangeActive)。现在我们再对此进行增加,加入手势的滑动,这里我引入了第三方库hammerjs来作为手势的处理
...相同代码省略
import Hammer from 'hammerjs';
...
...相同代码省略
...
useEffect(() => {
cosnt manager = new Hammer(container.current);
manager.add(new Hammer.Pan());
manager.on('panend panmove', function(e) {
// 状态在进行中时,不允许切换
if (status === 2) return;
// e.eventType 判断当前状态
// INPUT_MOVE 移动中
// INPUT_END 结束
if (e.eventType === Hammer.INPUT_MOVE) {
// 之前的offset参数的在此起到了作用,在手动滑动的时候并不是直接滑动到下一页,只是跟随手指进行偏移量改变
setTransition(e.deltaX);
} else if (e.eventType === Hammer.INPUT_END) {
// e.direction 判断移动方向
// Hammer.DIRECTION_LEFT 向左
// Hammer.DIRECTION_RIGHT 向右
// 当滑动距离大于1/3时,直接滑动到下一页,否则恢复偏移量
if (e.direction === Hammer.DIRECTION_LEFT && Math.abs(e.deltaX) > SCREEN_WIDTH / 3) {
handleNext();
} else if (e.direction === Hammer.DIRECTION_RIGHT && Math.abs(e.deltaX) > SCREEN_WIDTH / 3) {
handlePrev();
} else {
setTransition(0);
}
}
return () => {
manager.off('panmove');
manager.off('panend');
}
})
}, [status, active])
手势切换也加上了。其它方式的切换道理也是一样的。
到这里,一个简易版的移动端手势滚动组件就完成了,里面还有很多的不足、功能缺陷和优化点,例如容器宽度和高度的判断,宽度直接取得手机宽度,高度我直接写死的;轮播子组件的懒加载等等,之后也会慢慢进行增强和优化。毕竟路漫漫其修远兮。