广告轮播效果是一个很常见的APP特效,多个图片广告自动翻滚展示,同时也支持用户的手势滑动。
我实现这个特效,一方面是想锻炼react/redux程序的设计思路,更重要的是会接触到css动画,这个前端技术我从未接触。
设计思路
轮播特效,最生动的一个感受就是播放电影胶片。电影放映机插入了一卷胶片,胶片的每一格是一帧图片,在播放的时候放映机通过一个小窗口打光到胶片的一格上,胶片缓慢的移动,从而播放出一帧一帧的画面,就形成了电影。
因此,在html结构上,我会放置1个固定长宽的<div>作为窗口,在其内部横向放置一个很长的图片列表<ul>,重要的是<ul>超过窗口大小的部分被遮盖,而我们只能看见当前处于窗口中的图片。
另外一个需要注意的点,就是我们最多在窗口中看到2个图片,这会发生在胶片移动的中途,很容易想象。
组件实现
使用方法
因为轮播特效很常用,所以我要将其实现成一个通用组件Slider。上层使用时,需要传入一些配置参数,例如:窗口的长宽,轮播的间隔,图片列表以及对应的跳转地址,最终我在MsgCreatePage组件中引入了Slider组件,看起来会是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
render() { // 将轮播图片转换成数组 let imgsArr = []; for (var key in imgs) { imgsArr.push(imgs[key]); } // 定义轮播区域高度,内部使用Slider组件实现轮播 return ( <div> <Slider imgs={imgsArr} options={ { height: "130px", width: window.innerWidth, interval: 3500 } }/> </div> ); } |
实现关键点
首先,为了实现电影放映机的类似效果,我会定义一个窗口容器div#SliderWrapper,内部的胶卷使用ul#SliderList并且相对于窗口绝对定位(position:absolute),这样才能基于css left进行胶卷的滑动。
render渲染方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
render() { let wrapStyle = { height: this.props.options.height, width:this.props.options.width }; let listStyle = { left: 0, }; // 至少有2个图片才轮播 // 一个设计技巧:为了循环滚动的视觉效果,需要为数组头尾增加衔接的图片 let imgs = []; if (this.props.imgs.length > 1) { imgs.push(this.props.imgs[this.props.imgs.length - 1]); // 左边放一个尾巴 imgs = imgs.concat(this.props.imgs); imgs.push(this.props.imgs[0]); // 右边放一个头部 // 跳过衔接图片 listStyle.left = `${-1 * this.props.options.width}px`; } else { imgs = this.props.imgs; } let imgBlocks = imgs.map((src, index) => { let refName = `IMG_${index}`; return ( <li key={index}> <img key={index} src={src} ref={refName} style={wrapStyle} /> </li> ); }); return ( <div id={style.SliderWrapper} ref="SliderWrapper" style={wrapStyle}> <ul id={style.SliderList} ref="SliderList" style={listStyle} onTouchStart={this.onTouchStart.bind(this)} onTouchMove={this.onTouchMove.bind(this)} onTouchEnd={this.onTouchEnd.bind(this)} > {imgBlocks} </ul> </div> ); } |
这里做了几个重要的事情:
- 轮播和胶卷的一个重要差别就是胶卷播放结束无法从头播放,而轮播在遇到末尾时会继续从头滚动。为了实现这种轮回的感觉,如果图片大于1张(1张图片不用轮播)那么我将第一张图片放到图片列表的末尾,最后一张图片放到图片列表的头部,这样最后一张图片可以向右衔接到第一张图片(仅视觉上),同样第一张图片也可以向左衔接到最后一张图片(仅视觉上),配合一些代码逻辑可以在衔接阶段的背后进行真实的left偏移修正。
- 为了支持手势,我们在胶卷(ul)上配置了onTouchXXX系列监听。
componentDidMount组件首次渲染完成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// 重置组件状态 resetSlider() { this.props.initState(this.props.imgs.length); // 取消之前的定时器 this.stopAuto(); // 至少2张图片,才能启动轮播 this.tryStartAuto(); } componentDidMount() { this.resetSlider(); } |
在render方法调用后,componentDidMount被紧接着调用,我们要做的就是初始化redux的state,并且启动轮播定时器。
tryStartAuto启动轮播
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// 启动轮播 tryStartAuto() { // 手势和动画期间,不能启动轮播 if (this.autoTimer || this.props.imgs.length <=1 || this.isTouching || this.isAnamating) { console.log("手势和动画期间,不能启动轮播"); return; } this.autoTimer = setInterval( () => { this.props.slide(1); }, this.props.options.interval ); console.log("启动定时器成功"); } |
这里最重要的就是启动定时器,间隔interval毫秒后触发一次向右的滑动,这里this.props.slide是一个redux action。
slide 滑动action
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// 组件初始化状态,其实就是把component的constructor的挪到这里就完事了 const initState = { curIndex: 0, // 多余1个图片的情况下,第0个图片是用于衔接的图片 imgsCount: 0, // 真实的图片数量加上头尾2个衔接的图片 step: 0, // 本次移动的方向, -2: 头部衔接 -1:显示左边的图片,0:没有动作,1:显示右边的图片 2:尾部衔接 }; // 这是action export function slide(step) { return { type: consts.SLIDER_SLIDE, step: step, }; } // 这是reducer function SLIDER_SLIDE_reducer(state, action) { let nextIndex = (state.curIndex + state.imgsCount + action.step) % state.imgsCount; return Object.assign({}, state, { curIndex: nextIndex, step: action.step, }); } |
看一下slide这个action,它传入一个step指示胶卷向哪个方向移动几格,经过reducer计算后生成新的下标,这里redux state中的curIndex和step被注入到Slider组件的Props供访问。
componentDidUpdate 重新渲染完成
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
shouldComponentUpdate(nextProps, nextState) { // 本次渲染是否因为图片滚动引起 this.isSliding = nextProps.curIndex != this.props.curIndex; // 本次渲染是否因为上层参数变化引起(图片数量或者宽度改变) this.paramsChanged = nextProps.imgs.length != this.props.imgs.length || nextProps.options.width != this.props.options.width; return true; } componentDidUpdate() { // 渲染参数改变,必须重新计算并重置 if (this.paramsChanged) { return this.resetSlider(); } // 非图片滚动引起 if (!this.isSliding) { return; } // 右侧图片滑入 if (this.props.step == 1 || this.props.step == -1) { // 停止轮播,进行动画,完成后恢复动画 this.stopAuto(); this.isAnamating = true; $(this.refs.SliderList).animate({ left: -1 * this.props.curIndex * this.props.options.width, }, { complete: () => { if (this.props.curIndex == this.props.imgs.length + 1) { this.props.slide(2); } else if (this.props.curIndex == 0) { this.props.slide(-2); } this.isAnamating = false; // 动画完成 this.tryStartAuto(); // 恢复轮播 } }); } else if (this.props.step == 2) { // 右侧图片穷尽,重置位置到数组头部 $(this.refs.SliderList).css("left", `${-1 * this.props.options.width}px`); } else if (this.props.step == -2) { // 左侧图片穷尽,重置位置到数组尾部 $(this.refs.SliderList).css("left", `${-1 * this.props.imgs.length * this.props.options.width}px`); } } |
当定时器通过slide action触发curIndex和step改变后,shouldComponentUpdate被首先调用,这里主要判断本次更新的触发原因是Slider组件的用户传入的props改变(例如imgs,options)还是slide引起的props改变,因此通过curIndex进行了简单判定。
render方法被调用重画dom,紧接着componentDidUpdate被调用。
如果是因为slide滚动引起的重画,并且step是1说明是普通的滚动事件,那么应该先停止轮播定时器,之后调用jquery的动画函数animate对ul进行滚动1格,同时在animate的complete回调中恢复轮播定时器。不过要注意的是,这里特殊判断了当前下标如果是位于头尾的衔接处,那么紧接着发起同步slide action,传入的step是2/-2,经过redux一系列流程后再次回到componentDidUpdate的step == 2/-2分支,直接进行css修正left,相当于将胶卷重新插入到放映机中。
手势
首先图片的滚动是一个动画,执行是异步的,花费若干时间。因为轮播,手势,动画三个事件同时存在,如果引入过多的并发组合会导致过渡复杂,无法避免bug。因此整个Slider实现时遵循2个思路:
- 动画期间,手势和轮播定时器都应禁止。
- 手势期间,轮播定时器应该禁止。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
// 手势滚动 moveSlider(step) { if (this.props.imgs.length <=1 || this.isAnamating) { console.log("动画期间,手势被忽略"); return; } // 手势触发滚动 this.props.slide(step); } // 接触屏幕 onTouchStart(ev) { // 正在手势操作 this.isTouching = true; // 记录起点 this.startX = this.endX = ev.touches[0].clientX; // 停止自动轮播 this.stopAuto(); } // 拖拽中 onTouchMove(ev) { this.endX = ev.touches[0].clientX; if (!this.isAnamating && this.props.imgs.length > 1) { // 动画期间,手势被屏蔽 let offset = this.endX - this.startX; let curLeft = -1 * (this.props.curIndex * this.props.options.width); let newLeft = curLeft + offset; //console.log(`${offset} ${curLeft} ${newLeft}`); $(this.refs.SliderList).css("left", `${newLeft}px`); } } // 离开屏幕 onTouchEnd(ev) { let offsetX = this.endX - this.startX; this.isTouching = false; // console.log(offsetX); // 向右滑 if (offsetX > 1) { console.log("to right"); this.moveSlider(-1); } else if (offsetX < -1) { // 向左滑 console.log("to left"); this.moveSlider(1); } else { // 点击跳转 // 恢复轮播 this.tryStartAuto(); console.log("onclick"); } } |
手势期间通过isTouching来标识,并且关闭了定时器。通过判断x轴偏移确认是往左滑还是往右滑,从而为slide action传入不同的step,而通过手势触发的slide操作最终会在其动画结束后恢复轮播定时器,这个可以在componentDidUpdate中看到。
体验
使用专业二维码软件扫描(不要用微信),或者手机浏览器输入网址:http://t.cn/RVIE4r0
源码
休息了2天,我又为组件的右下角添加了小圆点的进度效果,源代码和二维码都做了相应的更新,其中用到了css3的border-radix实现小圆点效果。
如果文章帮助您解决了工作难题,您可以帮我点击屏幕上的任意广告,或者赞助少量费用来支持我的持续创作,谢谢~

下载的源码解压缩后打不开
打不开是指无法运行么?
Pingback引用通告: React.js系列学习-Java小咖秀
1