尝试自己写一个 React 的轮播组件,用来练练手~
讲解写的不多,主要还是代码,不过里面也有不少注释
难点
- 随时停止。在切换或者复位的移动过程中,若手指再次进入,将使移动暂停,转而跟随手指移动
- 无限滚动。可以一直向同一个方向切换
- 高度自适应。不用固定高度,整个轮播组件的高度将根据图片的高度来,整个轮播组件的高度将由最高的那张图片来决定
知识点
带过渡的前进
- 使用二分法来前进,每次前进剩余距离的二分之一
- 在
window.requestAnimationFrame中不断移动
边界的划定
/**
* 标记实际所存在的dom个数及分布方式
*
* |-2| |-1| |0| |1| |2| |3| |3|
* ↑
* 在中间可视区域内显示的那个
*
* 左边有2个“预留”元素,右边有4个“预留”元素
* 不对称性的原因是,轮播组件中“向右移动”的情况居多
*
* p.s. 可以适当增加左右侧预留数量,但是数量越多,性能越差(因为dom变多了)
*/
const Reserved = [-2, -1, 0, 1, 2, 3, 4];
源码
// Carousel.module.less
.carousel {
width: 100%;
overflow: hidden;
position: relative;
font-size: 0;
> .surface {
position: absolute;
top: 0;
height: 100%;
> ul {
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
transform: translate3d(0, 0, 0);
&:after,
&:before {
content: "";
display: table;
clear: both;
}
> li {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
> * {
display: block;
width: 100%;
}
}
}
}
> .skeleton {
position: relative;
visibility: hidden;
opacity: 0;
pointer-events: none;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
> li {
flex: 1;
> * {
display: block;
width: 100%;
}
}
}
}
// Carousel.tsx
import React from "react";
import classes from "./Carousel.module.less";
/**
* 触发swiper的阀值
* 目前情况已经能捕捉绝大部分情况,如果还有体验较差(捕捉不够灵敏、或太过灵敏)等情况,再调整数值
*/
const TriggerSwiperThreshold = {
size: 0.5, // 0.5 个元素的宽度
speed: 0.6, // 每毫秒移动 0.6 个像素
};
/**
* 移动速率常量
*
* 这里使用二分法来“前进”,所以是2
* 即第一个周期(16ms)移动路程的二分之一 (50%),第二个周期移动剩余路程的二分之一(25%),第三个周期移动到剩余路程的二分之一(12.5%)
*
* 修改能使路程分割的更细(移动得更顺滑),但是基本不会影响周期数(数学原理),所以完成一段路程所需时间差不多
*/
const MovingRateConstant = 2;
/**
* 移动抵达阀值
*
* 因为二分某个数字,只能无限接近于0
* 当距离目标值已经小于 [MovingApproachThreshold] 了,则判断为已经抵达目的地,直接赋予目标值
*/
const MovingApproachThreshold = 12;
/**
* 标记实际所存在的dom个数及分布方式
*
* |-2| |-1| |0| |1| |2| |3| |3|
* ↑
* 在中间可视区域内显示的那个
*
* 左边有2个“预留”元素,右边有4个“预留”元素
* 不对称性的原因是,轮播组件中“向右移动”的情况居多
*
* p.s. 可以适当增加左右侧预留数量,但是数量越多,性能越差(因为dom变多了)
*/
const Reserved = [-2, -1, 0, 1, 2, 3, 4];
const LeftOffsetCount = Reserved.indexOf(0); // 左侧有几个
const RightOffsetCount = Reserved.length - LeftOffsetCount - 1; // 右侧有几个
interface ICarouselProps {
/**
* 是否开启自动轮播
**/
autoplay: boolean;
/**
* 每个元素占据屏幕宽度
* 取值范围为 (0,1]
* 当为 1 时,每个元素的宽度与屏幕宽度一致
**/
itemSize: number;
/**
* 非当前元素的 scale 值(缩放比例)
* 取值范围为 (0,1]
* 当值小于 1 时,非当前元素的尺寸就会比当前元素小,在切换时就会有缩放效果(带过渡时间的)
**/
inactiveScale: number;
/**
* 每次切换之前会触发
**/
beforeChange?: (from: number, to: number) => void;
}
interface ICarouselState {
current: number;
list: Array<number>;
transformX: number;
disabledTouchAction: boolean;
}
export default class Carousel extends React.Component<
ICarouselProps,
ICarouselState
> {
itemRef = React.createRef<HTMLLIElement>();
timeout?: number; // 滚动过程定时器
interval?: number; // 自动轮播定时器
targetX?: number; // 本次移动将要前往的目标位置(px)
// 记录手指开始触碰屏幕时的位置信息
mark = {
current: 0,
position: 0,
timestamp: 0,
};
/**
* 连续滚动次数
*
* 因为当每次 switch 结束都会 [reset],但是如果每次都还没等切换自动结束时就迅速的再次尝试 switch,
* 如此连续触发而不给它机会去 [reset],那么 [Reserved] 中定义的“预留”元素将会不足,
* 从而导致如果迅速向同一方向切换的话,将会发现后续并没有元素可以延续了(出现空白)
*
* 这里引入一个属性来记录当前已经连续切换多少次了,在连续往同一方向切换一定次数后,将会禁止继续往该方向切换
* 范围是: -RightOffsetCount <= uninterruptedSwitch <= LeftOffsetCount
* 负数为切换到下一个(to-next),即dom向左移动,右侧元素将进入可视区域,正数反之
*
* 在每次to-next时减1,每次to-prev时加1,在reset时重置为0,
* 数值将会被约束在 [-RightOffsetCount,LeftOffsetCount] 这一区间内,超出了则会禁用to-next和to-prev
*/
uninterruptedSwitch: number = 0;
constructor(props: ICarouselProps) {
super(props);
this.state = {
current: 0,
list: [],
transformX: 0,
disabledTouchAction: false,
};
}
static defaultProps = {
autoplay: false,
itemSize: 1,
inactiveScale: 1,
};
componentDidMount() {
this.reset();
}
get itemWidth() {
return this.itemRef.current?.clientWidth || 0;
}
get count() {
return React.Children.toArray(this.props.children).length;
}
/**
* 每个元素的scale值其实就是n个向左右偏移的抛物线
* y=a(x-h)²+k
*
* h:偏移量,每一个元素偏移 [itemWidth]
* a:开口,开口向下,值由y变化范围 [1-inactiveScale] 以及h决定,即 -((1-inactiveScale)/(itemWidth)²)
* k:顶点,scale的最大值,即为1
*/
get scale() {
const itemWidth = this.itemWidth,
{ inactiveScale } = this.props,
{ transformX } = this.state,
calcScale = (index: number) => {
const scale =
-((1 - inactiveScale) / Math.pow(itemWidth, 2)) * // a
Math.pow(-transformX - index * itemWidth, 2) + // (x-h)²
1; // k
return scale < inactiveScale ? inactiveScale : scale; // 两段式,当计算的scale小于inactiveScale时,取inactiveScale
};
return Reserved.map((item) => calcScale(item));
}
/**
* 重置dom
* 计算几个dom分别展示的是哪些children
*/
private reset() {
const { current } = this.state;
let list: Array<number> = [];
const count = this.count;
switch (count) {
case 0:
list = [];
break;
case 1:
list = Reserved.map(() => 0);
break;
default:
list = Reserved.map((item) => {
if (item < 0) {
return (count + (current + item)) % count;
} else if (item === 0) {
return current;
} else {
return (current + item) % count;
}
});
break;
}
this.setState(
{
list,
transformX: 0,
},
() => {
this.uninterruptedSwitch = 0;
}
);
}
componentWillUnmount() {
window.clearInterval(this.interval);
}
handleTouchStart(e: React.TouchEvent<HTMLUListElement>) {
// console.log(e.nativeEvent);
this.timeout && window.cancelAnimationFrame(this.timeout);
this.mark = {
current: this.state.transformX,
position: e.nativeEvent.targetTouches[0].pageX,
timestamp: e.timeStamp,
};
}
handleTouchMove(e: React.TouchEvent<HTMLUListElement>) {
e.stopPropagation();
const diff = e.nativeEvent.targetTouches[0].pageX - this.mark.position;
if (Math.abs(diff) < 10) {
/**
* 当手指头只是移动很小的距离时,轮播组件不响应
* 用户可能只是想滚动整个页面,而非操作轮播组件时,从而防止页面在纵向滚动的同时,轮播组件也在横向移动
*/
this.setState({
disabledTouchAction: true,
});
} else {
this.setState({
disabledTouchAction: false,
transformX:
this.mark.current +
e.nativeEvent.targetTouches[0].pageX -
this.mark.position,
});
}
}
handleTouchEnd(e: React.TouchEvent<HTMLUListElement>) {
/**
* needContinue 是一个关键值,其标识着上一次 touchend 是否有未完成的事情
*
* 如迅速连续切换,使每次 touchend 都没有完成整个周期(即 scroll 完成后 reset )的话,
* 就意味着,在本次 touchend 所要移动的距离,是需要考虑上一次 touchend 未完成距离的~
*/
const needContinue =
this.mark.current !== 0 && typeof this.targetX !== "undefined";
// 计算移动距离
const diff = Math.abs(
e.nativeEvent.changedTouches[0].pageX - this.mark.position
);
// 计算移动速度
const speed = diff / (e.nativeEvent.timeStamp - this.mark.timestamp);
// 当距离和速度达到触发阀值,则认为其是切换,都则认为并未切换,滚动到该去的位置
if (
diff < this.itemWidth * TriggerSwiperThreshold.size && // 距离不满足
speed < TriggerSwiperThreshold.speed // 速度不满足
) {
if (needContinue) {
// 上次还有未完成的距离,所以本次要完成
this.moveTo(this.targetX, () => this.reset());
} else {
// 上次没有未完成的距离,所以需要归位
this.moveTo(0);
}
return;
}
// 计算方向,-1或者是1
let direction =
e.nativeEvent.changedTouches[0].pageX - this.mark.position < 0 ? -1 : 1;
// 更新连续切换次数
this.uninterruptedSwitch = this.uninterruptedSwitch + direction;
// 如果连续切换次数超出范围,则本次当作“未切换”
if (
this.uninterruptedSwitch < RightOffsetCount * -1 ||
this.uninterruptedSwitch > LeftOffsetCount
) {
direction = 0;
}
// 计算上次未完成的切换方向矢量值,可能不止-1或者1,这里是“未完成距离➗每个元素宽度”
const offsetCount = needContinue
? Math.round((this.targetX || 0) / this.itemWidth)
: 0;
// 得到新的“当前元素”的index
const newCurrent =
(this.count + (this.state.current - direction)) % this.count;
this.setState({
current: newCurrent,
});
this.props.beforeChange &&
this.props.beforeChange(this.state.current, newCurrent);
// 开始移动到对应位置
this.moveTo(this.itemWidth * (direction + offsetCount), () => {
this.reset();
});
}
moveTo(targetX: number, callback?: () => void) {
this.timeout && window.cancelAnimationFrame(this.timeout);
this.targetX = targetX; // 每次尝试移动前,都要先记录本次的目标位置
let denominator = MovingRateConstant; // 分母,初始值为速率常量
const fn = () => {
const { transformX: nowX } = this.state;
const direction = targetX > nowX ? 1 : -1;
const diff = Math.abs(
(nowX < 0 ? nowX * -1 : nowX) - (targetX < 0 ? targetX * -1 : targetX)
);
if (diff > 0) {
if (diff < MovingApproachThreshold) {
this.setState({
transformX: targetX,
});
} else {
if (denominator > diff) {
denominator = MovingRateConstant;
}
this.setState({
transformX: nowX + (diff / denominator) * direction,
});
denominator = denominator * MovingRateConstant;
}
this.timeout = window.requestAnimationFrame(fn);
} else {
this.timeout && window.cancelAnimationFrame(this.timeout);
callback && callback();
}
};
this.timeout = window.requestAnimationFrame(fn);
}
render() {
const { list, transformX, disabledTouchAction } = this.state;
const { itemSize } = this.props;
const childrens = React.Children.toArray(this.props.children);
return (
<div className={classes.carousel}>
<div
className={classes.surface}
style={{
width: Reserved.length * 100 * itemSize + "%",
left: ((1 - itemSize) / 2 - LeftOffsetCount * itemSize) * 100 + "%",
}}
>
<ul
style={{
transform: `translate3d(${transformX}px, 0, 0)`,
touchAction: disabledTouchAction ? "none" : "",
}}
onTouchStart={this.handleTouchStart.bind(this)}
onTouchMove={this.handleTouchMove.bind(this)}
onTouchEnd={this.handleTouchEnd.bind(this)}
>
{list.map((item, n) => (
<li
ref={this.itemRef}
style={{
transform: `scale(${this.scale[n]})`,
}}
key={`${item}-${n}`}
>
{childrens[item]}
</li>
))}
</ul>
</div>
{/* skeleton,意为“骨架”,撑起整个轮播组件的高度,但是不可见并不可触碰 */}
<ul
className={classes.skeleton}
style={{
width: childrens.length * 100 * itemSize + "%",
}}
>
{childrens.map((item, n) => (
<li key={n}>{item}</li>
))}
</ul>
</div>
);
}
}