您的位置:首页 > 产品设计 > UI/UE

mint-ui swipe组件源码解析

2017-06-22 15:54 369 查看

前叙

mint-ui组件库中swipe组件,实现的是常见的轮播图效果。但是它的实现方式,和常见的实现有所不同。

常见的实现方式: 通过移动轮播图的wrapper来实现item的切换效果(也就是修改wrapper的translate3d属性来实现)。如果支持循环播放,需要在首部插入一个最后一个轮播图item的clone版,以及在尾部插入一个第一个轮播图item的clone版。

swipe组件实现的方式: 只显示当前显示的轮播图item,当切换的时候,显示出当前item的前后相邻的两个item;通过设置三个item的translate3d来实现切换的效果。

两个实现方式的对比:

第一种方式,初始会渲染出所有的item,通过translate3d来实现切换和滑动,这种方式会启动硬件加速提升性能。但是毕竟是在所有轮播图的基础上的渲染。

第二种方式,通过切换item的display属性来实现对应item的显示和隐藏,虽然会引起回流和重绘,但是每个item的position为absolute,脱离文档流,所以并不会引起其他dom的回流和重绘。每个item的translate3d引发的渲染只是在当前item的基础上。

通过上面分析,可以得出: 如果轮播图的数量不多,第一种方式不会引起回流和重绘,并且translate引发渲染的item不多,性能相对好;但是轮播图的数量比较多的话,第二种性能相对比较好。

swipe接入示例

html代码

<div id="app">
<div class="swipe-wrapper">
<mt-swipe :auto="0" ref="swipeWrapper">
<mt-swipe-item class="swip-item-1 item">1</mt-swipe-item>
<mt-swipe-item class="swip-item-2 item">2</mt-swipe-item>
<mt-swipe-item class="swip-item-3 item">3</mt-swipe-item>
</mt-swipe>
</div>

<div class="button-wrapper">
<button class="prev-button flex-item" @click="prev">prev</button>
<button class="next-button flex-item" @click="next">next</button>
</div>
</div>


css代码

<!-- 引入组件库css -->
<link rel="stylesheet" href="../css/mint-style.css">
<style>
html,body{
width: 100%;
height: 100%;
margin: 0;
}
#app{
width: 100%;
height: 100%;
}
.swipe-wrapper{
width: 100%;
height: 300px;
}
.swip-item-1{
background: red;
}
.swip-item-2{
background: blue;
}
.swip-item-3{
background: green;
}
.item{
text-align: center;
font-size: 40px;
color: white;
}

.button-wrapper{
display: flex;
height: 100px;
}
.flex-item{
flex: 1;
display: inline-block;
text-align: center;
height: 100%;
line-height: 100%;
font-size: 40px;
}
.prev-button{
background: darkorange;
}
.next-button{
background: green;
}

</style>


js代码

<!-- 先引入 Vue -->
<script src="../js/vue.js"></script>
<!-- 引入组件库 -->
<script src="../js/index.js"></script>
<script>
new Vue({
el: '#app',
methods: {
prev: function () {
this.$refs.swipeWrapper.prev();
console.log(this.$children);
},
next: function () {
this.$refs.swipeWrapper.next();
}
}
});
</script>


原理解析

初始只显示选中index的item,将其他item都隐藏

当拖动开始的时候,显示当前index的相邻两个item

当拖动的时候,计算出手指滑动的距离,通过设置当前item和其相关两个item的translate3d来改变他们的位置的方式,来实现切换的效果

自动播放:通过设置定时器,触发上面拖动相同的切换代码,来实现切换。

源码解析

首先看子组件swipe-item组件,代码很简单,如下:

<template>
<div class="mint-swipe-item">
<slot></slot>
</div>
</template>

<script>
export default {
name: 'mt-swipe-item',

mounted() { // 页面显示的时候触发
this.$parent && this.$parent.swipeItemCreated(this); // 内部实现:调用父组件的reinit()业务
},

destroyed() { // item隐藏的时候触发
this.$parent && this.$parent.swipeItemDestroyed(this); // 内部实现同上
}
};
</script>


上面的代码很简单,mounted和destoryed都是调用的父组件的实现。实现如下:

swipeItemCreated() {
if (!this.ready) return;

clearTimeout(this.reInitTimer);
this.reInitTimer = setTimeout(() => {
this.reInitPages();
}, 100);
},

swipeItemDestroyed() {
if (!this.ready) return;

clearTimeout(this.reInitTimer);
this.reInitTimer = setTimeout(() => {
this.reInitPages();
}, 100);
},


父组件swipe的props

这些数据都是允许外部传入的,这是数据的含义说明可以参考mint-ui的官网的说明mint-ui swipe组件

swipe组件的data说明

data() {
return {
ready: false, // 当前组件是否 mounted
dragging: false, // 当前是否正在拖动
userScrolling: false, // 判定当前用户在上下滚动,就不执行drag动作
animating: false, // 当前是否在执行动画(也就是自动切换页面)
index: 0, // 当前所在的item的index
pages: [], // 存储当前child 的dom
timer: null, // 自动播放的定时器 timerid
reInitTimer: null, // item组件触发reInit触发 定时器id
noDrag: false, // 存储是否运行拖动的标识
isDone: false // 当前动画是否执行完成
};
}


上面的注释都是我通过分析源码得出的,有了说明,下面看代码就更容易了。

swipe组件的入口函数mounted回调的实现

mounted() {
this.ready = true;

this.initTimer();// 初始化自动播放的timer

this.reInitPages(); // 初始化drag状态, 以及dom节点的样式信息

var element = this.$el;
// 为当前组件的dom节点 注册touch时间
element.addEventListener('touchstart', (event) => {
if (this.prevent) event.preventDefault();
if (this.stopPropagation) event.stopPropagation();
if (this.animating) return; // 如果当前在执行移动动画, 直接返回
this.dragging = true; // 设置dragging状态标识
this.userScrolling = false; // 重置
this.doOnTouchStart(event);
});

element.addEventListener('touchmove', (event) => {
if (!this.dragging) return;
if (this.timer) this.clearTimer(); // 将当前自动播放停止
this.doOnTouchMove(event);
});

element.addEventListener('touchend', (event) => {
if (this.userScrolling) { // 纵向滚动,重置状态并返回
this.dragging = false;
this.dragState = {};
return;
}
if (!this.dragging) return;
this.initTimer(); // 启动自动播放定时器
this.doOnTouchEnd(event);
this.dragging = false; // 重置拖动状态
});
}


关于初始化自动播放的定时器的代码,最后在分析。现在来看初始化dom样式的reInitPages函数实现如下:

reInitPages() {
var children = this.$children;
// 设置拖动状态
this.noDrag = children.length === 1 && this.noDragWhenSingle; // 当前只有一个item,并且设置了只有一个不支持拖动

var pages = [];
var intDefaultIndex = Math.floor(this.defaultIndex);
var defaultIndex = (intDefaultIndex >= 0 && intDefaultIndex < children.length) ? intDefaultIndex : 0;
this.index = defaultIndex; // 设置当前显示的索引值
//初始化显示样式, 将当前index的item显示出来,其他的都隐藏
children.forEach(function(child, index) {
pages.push(child.$el);

removeClass(child.$el, 'is-active');

if (index === defaultIndex) {
addClass(child.$el, 'is-active');
}
});
// 设置所有轮播图的item的dom
this.pages = pages;
},


swipe的touchstart事件回调的处理

上面已经有了回调的代码,主要看处理的核心函数doOnTouchStart的实现如下:

doOnTouchStart(event) { // 创建dragState, 包括touch事件的信息,当前drag item以及它前后两个item,并将其显示出来
if (this.noDrag) return; // 不支持拖动

var element = this.$el;
var dragState = this.dragState;
var touch = event.touches[0];
// 设置dragstate的信息(也就是当前滑动的信息数据)
dragState.startTime = new Date();
dragState.startLeft = touch.pageX;
dragState.startTop = touch.pageY;
dragState.startTopAbsolute = touch.clientY;

dragState.pageWidth = element.offsetWidth;
dragState.pageHeight = element.offsetHeight;

var prevPage = this.$children[this.index - 1];
var dragPage = this.$children[this.index];
var nextPage = this.$children[this.index + 1];

if (this.continuous && this.pages.length > 1) { // 当前支持循环播放, 并且pages的长度大于1
if (!prevPage) {
prevPage = this.$children[this.$children.length - 1];
}
if (!nextPage) {
nextPage = this.$children[0];
}
}

dragState.prevPage = prevPage ? prevPage.$el : null;
dragState.dragPage = dragPage ? dragPage.$el : null;
dragState.nextPage = nextPage ? nextPage.$el : null;
// 将当前index下的前后两个item显示出来
if (dragState.prevPage) {
dragState.prevPage.style.display = 'block';
}

if (dragState.nextPage) {
dragState.nextPage.style.display = 'block';
}
}


获取当前touchstart状态下面的拖动的状态信息(包括touch的信息,页面宽高,prev、current、next三个item的dom)。同时将prev、next显示出来。

touchmove事件回调的处理

doOnTouchMove(event) {
if (this.noDrag) return;

var dragState = this.dragState;
var touch = event.touches[0];

dragState.currentLeft = touch.pageX;
dragState.currentTop = touch.pageY;
dragState.currentTopAbsolute = touch.clientY;
//计算滑动的距离
var offsetLeft = dragState.currentLeft - dragState.startLeft;
var offsetTop = dragState.currentTopAbsolute - dragState.startTopAbsolute;

var distanceX = Math.abs(offsetLeft);
var distanceY = Math.abs(offsetTop);
// 判断是 竖向滚动,还是横向滚动
if (distanceX < 5 || (distanceX >= 5 && distanceY >= 1.73 * distanceX)) {
this.userScrolling = true; // 判定当前用户在上下滚动,就不执行drag动作
return;
} else {
this.userScrolling = false;
event.preventDefault(); // 阻止默认事件的触发,也就是点击事件的触发
}
// 设置最大的拖拽距离在当前dom里面
offsetLeft = Math.min(Math.max(-dragState.pageWidth + 1, offsetLeft), dragState.pageWidth - 1);

var towards = offsetLeft < 0 ? 'next' : 'prev'; // 拖动的方向的确定
//prev方向: prev dom移动到指定的位置
if (dragState.prevPage && towards === 'prev') {
this.translate(dragState.prevPage, offsetLeft - dragState.pageWidth);
}
// current dom移动到指定的位置
this.translate(dragState.dragPage, offsetLeft);
// next方向: next dom 移动到指定的位置
if (dragState.nextPage && towards === 'next') {
this.translate(dragState.nextPage, offsetLeft + dragState.pageWidth);
}
}


主要确定当前滚动不是竖向滚动,并确定滚动的方向以确定移动prev还是next。

下面看translate移动dom的核心函数实现:

/**
* @param element 要移动的dom节点
* @param offset // dom移动的距离
* @param speed 如果传递, 执行动画的移动; 没有,则直接translate执行的距离
* @param callback 处理完成的回调函数
*/
translate(element, offset, speed, callback) {
if (speed) {
this.animating = true; // 当前正在执行动画,此时不能拖拽
element.style.webkitTransition = '-webkit-transform ' + speed + 'ms ease-in-out'; // transition过渡状态
setTimeout(() => {
element.style.webkitTransform = `translate3d(${offset}px, 0, 0)`;
}, 50);

var called = false;

var transitionEndCallback = () => {
if (called) return;
called = true;
this.animating = false; // 停止动画
element.style.webkitTransition = '';
element.style.webkitTransform = '';
if (callback) {
callback.apply(this, arguments); // 调用回调
}
};

once(element, 'webkitTransitionEnd', transitionEndCallback); // 此事件只执行一次
// 防止低版本android, 无法触发此事件
setTimeout(transitionEndCallback, speed + 100); // webkitTransitionEnd maybe not fire on lower version android.
} else {
element.style.webkitTransition = '';
element.style.webkitTransform = `translate3d(${offset}px, 0, 0)`;
}
}


如果设置了speed,就会执行平滑的动画切换(speed是动画执行的时间);如果没有设置,直接移动到指定的位置,没有过渡效果。

touchend事件回调的实现

分析其中核心代码doTouchEnd函数:

doOnTouchEnd() {
if (this.noDrag) return;

var dragState = this.dragState;

var dragDuration = new Date() - dragState.startTime;
var towards = null; // 决定下面进入哪个页面, null: 当前页面, prev: 前一个页面, next: 下一个页面

var offsetLeft = dragState.currentLeft - dragState.startLeft;
var offsetTop = dragState.currentTop - dragState.startTop;
var pageWidth = dragState.pageWidth;
var index = this.index;
var pageCount = this.pages.length;

// 判断当前是否是 tap事件(轻触事件)
if (dragDuration < 300) {
let fireTap = Math.abs(offsetLeft) < 5 && Math.abs(offsetTop) < 5;
if (isNaN(offsetLeft) || isNaN(offsetTop)) {
fireTap = true;
}
if (fireTap) {
this.$children[this.index].$emit('tap'); // 当前轮播图item发送给外部的tab事件
}
}
// 触发时长小于300ms,并且没有执行touchmove事件, 不处理
if (dragDuration < 300 && dragState.currentLeft === undefined) return;

if (dragDuration < 300 || Math.abs(offsetLeft) > pageWidth / 2) {
towards = offsetLeft < 0 ? 'next' : 'prev';
}

if (!this.continuous) { // 当前不支持循环, 向前或向后 都回到当前页面
if ((index === 0 && towards === 'prev') || (index === pageCount - 1 && towards === 'next')) {
towards = null;
}
}

if (this.$children.length < 2) {
towards = null;
}
// 动画的方式切换到指定的item
this.doAnimate(towards, {
offsetLeft: offsetLeft,
pageWidth: dragState.pageWidth,
prevPage: dragState.prevPage,
currentPage: dragState.dragPage,
nextPage: dragState.nextPage
});

this.dragState = {};// 清空dragState
}


判断当前是否是tap事件,并且确定下面要切换到哪个item。

下面来看doAnimate动画的方式切换的实现:

doAnimate(towards, options) {
if (this.$children.length === 0) return;
if (!options && this.$children.length < 2) return;

var prevPage, nextPage, currentPage, pageWidth, offsetLeft;
var speed = this.speed || 300;
var index = this.index;
var pages = this.pages;
var pageCount = pages.length;

if (!options) { // 没有options,是 自动播放或手动触发切换页面的处理
pageWidth = this.$el.clientWidth;
currentPage = pages[index];
prevPage = pages[index - 1];
nextPage = pages[index + 1];
if (this.continuous && pages.length > 1) {
if (!prevPage) {
prevPage = pages[pages.length - 1];
}
if (!nextPage) {
nextPage = pages[0];
}
}
// 将 prevPage 和 nextPage 定位到应该的位置(也就是开始执行切换页面的位置)
if (prevPage) {
prevPage.style.display = 'block'; // 显示出来
this.translate(prevPage, -pageWidth); // 移到当前index的前面
}
if (nextPage) {
nextPage.style.display = 'block';
this.translate(nextPage, pageWidth); // 移到当前index的后面
}
} else {
prevPage = options.prevPage;
currentPage = options.currentPage;
nextPage = options.nextPage;
pageWidth = options.pageWidth;
offsetLeft = options.offsetLeft;
}
// 确定 要切换的item的索引
var newIndex;

var oldPage = this.$children[index].$el;

if (towards === 'prev') {
if (index > 0) {
newIndex = index - 1;
}
if (this.continuous && index === 0) {
newIndex = pageCount - 1;
}
} else if (towards === 'next') {
if (index < pageCount - 1) {
newIndex = index + 1;
}
if (this.continuous && index === pageCount - 1) {
newIndex = 0;
}
}

var callback = () => { // 动画完成的回调: 重置dom的样式信息
if (newIndex !== undefined) {
// 重置dom的样式信息
var newPage = this.$children[newIndex].$el;
removeClass(oldPage, 'is-active'); // is-active 设置当前item的display:block
addClass(newPage, 'is-active');

this.index = newIndex;
}
if (this.isDone) { // 切换了页面,向外部发送切换页面完成的事件
this.end();
}

// 在touchStart 时设置的style中的display清空, 也就是使用class里面的display:none隐藏属性
if (prevPage) {
prevPage.style.display = '';
}

if (nextPage) {
nextPage.style.display = '';
}
};

setTimeout(() => {
if (towards === 'next') { // 切换到下一页
this.isDone = true;
this.before(currentPage); // 执行切换页面之前,向外部发送事件
this.translate(currentPage, -pageWidth, speed, callback);
if (nextPage) {
this.translate(nextPage, 0, speed);
}
} else if (towards === 'prev') { // 切换到上一页
this.isDone = true;
this.before(currentPage);
this.translate(currentPage, pageWidth, speed, callback);
if (prevPage) {
this.translate(prevPage, 0, speed);
}
} else { // 回到当前页面,不切换页面
this.isDone = false; // 当前没有进入到前一个页面和后一个页面, 还是回到当前页面
this.translate(currentPage, 0, speed, callback);
if (typeof offsetLeft !== 'undefined') {
if (prevPage && offsetLeft > 0) {
this.translate(prevPage, pageWidth * -1, speed);
}
if (nextPage && offsetLeft < 0) {
this.translate(nextPage, pageWidth, speed);
}
} else {
if (prevPage) {
this.translate(prevPage, pageWidth * -1, speed);
}
if (nextPage) {
this.translate(nextPage, pageWidth, speed);
}
}
}
}, 10);
}


此函数代码比较,但是不难,结合上面的注释,应该很容易读懂。实现:如果没有options(手动触发切换页面),会生成options中的信息(也就是下面处理需要用到的数据),并把prev和next两个dom定位到指定的位置;然后执行切换页面的操作,并且在结束的回调中重置相应dom的样式以及当前选中的index。

下面来看 手动触发的切换页面的代码

next() { // 切换到下一个页面
this.doAnimate('next');
},

prev() { // 切换到上一个页面
this.doAnimate('prev');
}


最后,来看自动播放的代码:

initTimer() {
if (this.auto > 0) {
this.timer = setInterval(() => {
// 如果不支持循环播放,并且当前播放到了 末尾位置,  停止定时器
if (!this.continuous && (this.index >= this.pages.length - 1)) {
return this.clearTimer();
}
if (!this.dragging && !this.animating) { // 没有在拖动, 也没有执行动画
this.next(); // 播放下一个item
}
}, this.auto);
}
}


读懂了doAnimate函数了,就很简单了。

总结

了解了一种新的轮播图的实现方式

两个轮播图的实现方式的差别以及性能的比较

touch事件边界条件的处理,比如tap事件的判断,横纵向滚动的判断
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签:  源码 swipe vue mint-ui