根据 UI 库源码的理解,跟进上一篇 picker 选择器的实现,该篇主要通过原生 js 实现一个滑动轮播图,可在此基础上进行扩展到自己的项目中(最后附上通过 Vue 实现的代码)
实现效果图如下:
实现原理很简单,和大多数的轮播图一样:
- 首先创建显示容器
- 接着创建轨道容器,主要用来放置滑动卡片,并且通过css样式控制一行排列
- 然后通过js配合css控制轨道容器的滑动距离,实现过渡效果
- 最后对于滑到左侧与右侧进行边界处理
原生代码实现如下 (html 及 css 部分):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>轮播</title>
<style>
html,body {
margin: 0;
padding: 0;
}
.swiper-demo {
/* width: 375px; */
width: 100%;
height: 150px;
}
.swiper-item {
width: 100%;
height: 100%;
border: 1px solid #999;
box-sizing: border-box;
}
.swiper {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
cursor: grab;
}
.track-container {
display: flex;
height: 100%;
}
.indicator {
position: absolute;
z-index: 9;
right: 10px;
bottom: 10px;
}
</style>
</head>
<body>
<div class="swiper-demo">
<!-- 滑动组件 -->
<div class="swiper">
<div ref="track" class="track-container">
<div class="swiper-item">1</div>
<div class="swiper-item">2</div>
<div class="swiper-item">3</div>
<div class="swiper-item">4</div>
</div>
<!-- 指示器 -->
<div class="indicator"></div>
</div>
</div>
</body>
<script src="./index.js"></script>
</html>
原生 JS 代码
class Swiper {
// 常量
MIN_DISTANCE = 10
supportsPassive = false
constructor(option = {}) {
this.initValue(option)
this._initValue()
this.initComputed()
// 初始化
this.onMounted()
}
// 用户变量
initValue(option) {
this.width = option.width || 0 // 卡片元素宽
this.height = option.height || 0 // 卡片元素高
this.autoplay = option.autoplay || 0 // 自动播放时间间隔
this.vertical = option.vertical || false // 默认横向滑动
this.loop = option.loop || true // 默认无缝衔接
this.duration = option.duration || 500 // 滑动时长
this.touchable = option.touchable || true // 可滑动
this.initialSwipe = option.initialSwipe || 0 // 初始化滑动轮播下标
this.showIndicators = option.showIndicators || true // 下标指示器
this.stopPropagationBool = option.stopPropagationBool || true // 阻止事件冒泡
this.isShowIndicator = option.isShowIndicator || false // 默认隐藏指示器
}
// 私有变量
_initValue() {
const swiper = document.querySelector('.swiper') // 显示容器
this.direction = '' // 滑动方向
this.startX = 0 // 初始位置水平方向
this.startY = 0 // 初始位置
this.deltaX = 0 // 水平方向滑动距离
this.deltaY = 0
this.offsetX = 0 // 滑动距离--水平方向
this.offsetY = 0
this.offset = 0 // 滑动距离
this.active = 0 // 当前显示的滑动卡片下标
this.swiping = false // 滑动状态
this.computedWidth = 0 // 单个滑动卡片宽度
this.computedHeight = 0
this.rect = swiper.getBoundingClientRect()
this.$el = swiper // 显示区域 父容器
this.track = document.querySelector('.track-container') // 包含滑动卡片的轨道元素
this.indicator = document.querySelector('.indicator') // 指示器
this.timer = null // 定时器
}
// 计算属性--动态计算滑动需要的数据
initComputed(option) {
// 滑动卡片子元素个数
this.count = this.track.children.length
// 卡片元素宽/高尺寸
this.size = this[this.vertical ? 'computedHeight' : 'computedWidth']
// 最大偏移量
this.minOffset = (this.vertical ? this.rect.height : this.rect.width) - this.size * this.count
// 最大偏移数量
this.maxCount = Math.ceil(Math.abs(this.minOffset) / this.size)
// 滑动距离
this.delta = this.vertical ? this.deltaY : this.deltaX
// 当前激活元素的下标
this.activeIndicator = (this.active + this.count) % this.count
// 判断实际滑动方向与预期滑动方向是否一致
this.isCorrectDirection = this.direction === (this.vertical ? 'vertical' : 'horizontal')
// 滑动轨道容器宽度
this.trackSize = this.count * this.size
// 滑动动画
this.setTransform()
}
// 滑动动画关键
setTransform() {
// 滑动动画关键步骤--主要利用css3 translate 配合 transition过渡效果实现
// 动态重置轨道容器样式,配合边界处理,实现滑动效果
const mainAxis = this.vertical ? 'height' : 'width'
const crossAxis = this.vertical ? 'width' : 'height'
this.trackStyle = {
[mainAxis]: `${this.trackSize}px`,
[crossAxis]: this[crossAxis] ? `${this[crossAxis]}px` : '',
transitionDuration: `${this.swiping ? 0 : this.duration}ms`,
transform: `translate${this.vertical ? 'Y' : 'X'}(${this.offset}px)`,
}
}
// 页面初始化
onMounted() {
this.bindTouchEvent(this.track)
this.initialize()
}
// 初始化数据、滑动
initialize(active = +this.initialSwipe) {
if (!this.$el || this.isHidden(this.$el)) {
return;
}
clearTimeout(this.timer)
this.swiping = true;
this.active = active;
this.computedWidth = Math.floor(+this.width || this.rect.width);
this.computedHeight = Math.floor(+this.height || this.rect.height);
this.offset = this.getTargetOffset(active);
Array.from(this.track.children).forEach((swipe) => {
swipe.offset = 0;
});
// 重新刷新计算属性
this.setIndicator()
this.initComputed()
this.setSwiperStyle()
this.autoPlay();
}
// 设置指示器
setIndicator() {
if (this.isShowIndicator && this.indicator) {
this.indicator.innerHTML = `当前下标: ${this.activeIndicator} --- ${this.activeIndicator + 1}/${this.track.children.length}`
}
}
// 设置轮播组件样式
setSwiperStyle() {
this.setTransform()
this.setSwiperItemdStyle()
this.setSwiperTrackStyle()
}
// 设置轨道样式
setSwiperTrackStyle() {
this.track.style.width = this.trackStyle.width
this.track.style.height = this.trackStyle.height
this.track.style.transitionDuration = this.trackStyle.transitionDuration
this.track.style.transform = this.trackStyle.transform
}
// 设置每个卡片偏移量--样式
setSwiperItemdStyle() {
Array.from(this.track.children).forEach(swiperItem => {
// if (swiperItem.offset) {
swiperItem.style.transform = `translate${this.vertical ? "Y" : "X"}(${swiperItem.offset}px)`
// }
})
}
// 自动播放函数
autoPlay() {
const { autoplay } = this;
if (autoplay > 0 && this.count > 1) {
this.clear();
this.timer = setTimeout(() => {
this.next();
this.autoPlay();
}, autoplay);
}
}
// xiayizhang
next() {
this.correctPosition();
this.resetTouchStatus();
this.doubleRaf(() => {
this.swiping = false;
this.move({
pace: 1,
emitChange: true,
});
});
}
// 重置滑动中状态
correctPosition() {
this.swiping = true;
// 画到最左边的边界处理
if (this.active <= -1) {
this.move({ pace: this.count });
}
// 滑动最右边的边界处理
if (this.active >= this.count) {
this.move({ pace: -this.count });
}
}
// 动画滑动函数
move({ pace = 0, offset = 0, emitChange }) {
const { loop, count, active, trackSize, minOffset } = this;
const children = this.track.children
if (count <= 1) {
return;
}
const targetActive = this.getTargetActive(pace);
const targetOffset = this.getTargetOffset(targetActive, offset);
if (loop) {
// 滑到右边最后一张边界处理
if (children[0] && targetOffset !== minOffset) {
const outRightBound = targetOffset < minOffset;
children[0].offset = outRightBound ? trackSize : 0;
}
// 滑到左边第一张边界处理
if (children[count - 1] && targetOffset !== 0) {
const outLeftBound = targetOffset > 0;
children[count - 1].offset = outLeftBound ? -trackSize : 0;
}
}
this.active = targetActive;
this.offset = targetOffset;
this.setSwiperStyle()
// 当前激活元素的下标
this.activeIndicator = (this.active + this.count) % this.count
if (emitChange && targetActive !== active) {
this.setIndicator()
}
}
// 确定下次要展示在容器中的滑动卡片元素下标
getTargetActive(pace) {
const { active, count, maxCount } = this;
if (pace) {
if (this.loop) {
return this.range(active + pace, -1, count);
}
return this.range(active + pace, 0, maxCount);
}
return active;
}
// 获取滑动偏移量
getTargetOffset(targetActive, offset = 0) {
let currentPosition = targetActive * this.size;
if (!this.loop) {
currentPosition = Math.min(currentPosition, -this.minOffset);
}
let targetOffset = Math.round(offset - currentPosition);
if (!this.loop) {
targetOffset = this.range(targetOffset, this.minOffset, 0);
}
return targetOffset;
}
// 清空定时器
clear() {
clearTimeout(this.timer)
}
// 滑动动画回调函数
doubleRaf(fn) {
this.raf(() => {
this.raf(fn);
});
}
raf(fn) {
return window.requestAnimationFrame(fn)
}
// 滑动范围限制
range(num, min, max) {
return Math.min(Math.max(num, min), max);
}
// 公共滑动方法
touchStart(event) {
this.resetTouchStatus()
this.startX = event.touches[0].clientX
this.startY = event.touches[0].clientY
}
touchMove(event) {
const touch = event.touches[0]
this.deltaX = touch.clientX - this.startX
this.deltaY = touch.clientY - this.startY
this.offsetX = Math.abs(this.deltaX)
this.offsetY = Math.abs(this.deltaY)
this.direction = this.direction || this.getDirection(this.offsetX, this.offsetY)
}
resetTouchStatus() {
this.direction = ''
this.deltaX = 0
this.deltaY = 0
this.offsetX = 0
this.offsetY = 0
}
bindTouchEvent(el) {
const { onTouchStart, onTouchMove, onTouchEnd } = this;
this.on(el, 'touchstart', onTouchStart);
this.on(el, 'touchmove', onTouchMove);
if (onTouchEnd) {
this.on(el, 'touchend', onTouchEnd);
this.on(el, 'touchcancel', onTouchEnd);
}
}
on(target, event, handler, passive = false) {
target.addEventListener(
event,
handler,
this.supportsPassive ? { capture: false, passive } : false
);
}
getDirection(x, y) {
if (x > y && x > this.MIN_DISTANCE) {
return 'horizontal';
}
if (y > x && y > this.MIN_DISTANCE) {
return 'vertical';
}
return '';
}
isHidden(el) {
const style = window.getComputedStyle(el);
const hidden = style.display === 'none';
const parentHidden = el.offsetParent === null && style.position !== 'fixed';
return hidden || parentHidden;
}
preventDefault = (event, isStopPropagation) => {
if (typeof event.cancelable !== 'boolean' || event.cancelable) {
event.preventDefault();
}
if (isStopPropagation) {
this.stopPropagation(event);
}
}
// 阻止事件冒泡
stopPropagation(event) {
event.stopPropagation();
}
// touchstart 滑动事件监听
onTouchStart = (event) => {
if (!this.touchable) return;
this.clear();
this.touchStartTime = Date.now();
this.touchStart(event);
this.correctPosition();
}
// touchmove 滑动事件监听
onTouchMove = (event) => {
if (!this.touchable || !this.swiping) return;
this.touchMove(event);
// 断实际滑动方向与预期滑动方向是否一致
this.isCorrectDirection = this.direction === (this.vertical ? 'vertical' : 'horizontal')
// 滑动距离
this.delta = this.vertical ? this.deltaY : this.deltaX
if (this.isCorrectDirection) {
this.preventDefault(event, this.stopPropagationBool);
this.move({ offset: this.delta });
}
}
onTouchEnd = () => {
if (!this.touchable || !this.swiping) return;
const { size, delta } = this;
const duration = Date.now() - this.touchStartTime;
const speed = delta / duration;
const shouldSwipe = Math.abs(speed) > 0.25 || Math.abs(delta) > size / 2;
if (shouldSwipe && this.isCorrectDirection) {
const offset = this.vertical ? this.offsetY : this.offsetX;
let pace = 0;
if (this.loop) {
pace = offset > 0 ? (delta > 0 ? -1 : 1) : 0;
} else {
pace = -Math[delta > 0 ? 'ceil' : 'floor'](delta / size);
}
// 解决动画滑动过程更平滑
this.swiping = false
this.move({
pace,
emitChange: true,
});
} else if (delta) {
// 解决动画滑动过程更平滑
this.swiping = false
this.move({ pace: 0 });
}
this.swiping = false
this.autoPlay();
}
}
new Swiper({ autoplay: 1500, isShowIndicator: true })
以上滑动轮播图的原生实现,可以在项目中结合框架,进行单独组件的提取,合理进行扩展,将不部分公共方法属性提取出来,提高可复用性
附:Vue 版实现滑动轮播图效果(可再进行扩展)
<template>
<div class="swiper-demo">
<Swiper :autoplay="100000">
<SwiperItem>1</SwiperItem>
<SwiperItem>2</SwiperItem>
<SwiperItem>3</SwiperItem>
<SwiperItem>4</SwiperItem>
</Swiper>
</div>
</template>
<script>
import Swiper from "@/components/Swiper/Swiper";
import SwiperItem from "@/components/SwiperItem/SwiperItem";
export default {
name: "SwiperDemo",
components: { Swiper, SwiperItem }
}
</script>
<style lang="scss" scoped>
.swiper-demo {
width: 100%;
height: 150px;
}
.swiper-item {
width: 100%;
height: 100%;
border: 1px solid #999;
box-sizing: border-box;
}
</style>
<template>
<div class="swiper" ref="swiper">
<div ref="track" :style="trackStyle" class="track-container">
<slot/>
</div>
<!-- 指示器 -->
</div>
</template>
<script>
import {TouchMixin, doubleRaf,range, isHidden, preventDefault} from './touch'
export default {
name: 'Swiper',
mixins: [TouchMixin],
props: {
width: [Number, String],
height: [Number, String],
autoplay: [Number, String], // 自动播放间隔
vertical: Boolean
loop: {
type: Boolean,
default: true,
},
duration: {
type: [Number, String],
default: 500,
},
touchable: {
type: Boolean,
default: true,
},
initialSwipe: {
type: [Number, String],
default: 0,
},
showIndicators: {
type: Boolean,
default: true,
},
stopPropagation: {
type: Boolean,
default: true,
},
},
data() {
return {
rect: null,
offset: 0,
active: 0,
deltaX: 0,
deltaY: 0,
swiping: false,
computedWidth: 0,
computedHeight: 0,
$el: null, // 显示区域 父容器
};
},
computed: {
// 子元素个数
count() {
return this.$slots.default.length;
},
// 父显示区域宽/高尺寸
size() {
return this[this.vertical ? 'computedHeight' : 'computedWidth'];
},
// 最大偏移量
minOffset() {
return (
(this.vertical ? this.rect.height : this.rect.width) -
this.size * this.count);
},
// 最大偏移数量
maxCount() {
return Math.ceil(Math.abs(this.minOffset) / this.size);
},
// 滑动距离
delta() {
return this.vertical ? this.deltaY : this.deltaX;
},
// 当前激活元素的下标
activeIndicator() {
return (this.active + this.count) % this.count;
},
// 判断实际滑动方向与预期滑动方向是否一致
isCorrectDirection() {
const expect = this.vertical ? 'vertical' : 'horizontal';
return this.direction === expect;
},
// 滑动轨道容器宽度
trackSize() {
return this.count * this.size;
},
// 滑动动画关键步骤
trackStyle() {
const mainAxis = this.vertical ? 'height' : 'width'
const crossAxis = this.vertical ? 'width' : 'height'
return {
[mainAxis]: `${this.trackSize}px`,
[crossAxis]: this[crossAxis] ? `${this[crossAxis]}px` : '',
transitionDuration: `${this.swiping ? 0 : this.duration}ms`,
transform: `translate${this.vertical ? 'Y' : 'X'}(${this.offset}px)`,
}
}
},
mounted() {
this.$el = this.$refs.swiper
this.bindTouchEvent(this.$refs.track)
this.initialize()
},
methods: {
// 初始化数据、滑动
initialize(active = +this.initialSwipe) {
if (!this.$el || isHidden(this.$el)) {
return;
}
clearTimeout(this.timer);
const rect = this.$el.getBoundingClientRect();
this.rect = rect;
this.swiping = true;
this.active = active;
this.computedWidth = Math.floor(+this.width || rect.width);
this.computedHeight = Math.floor(+this.height || rect.height);
this.offset = this.getTargetOffset(active);
// this.children.forEach((swipe) => {
this.$children.forEach((swipe)=>{
swipe.offset = 0;
});
this.autoPlay();
},
autoPlay() {
const { autoplay } = this;
if (autoplay > 0 && this.count > 1) {
this.clear();
this.timer = setTimeout(() => {
this.next();
this.autoPlay();
}, autoplay);
}
},
next() {
this.correctPosition();
this.resetTouchStatus();
doubleRaf(() => {
this.swiping = false;
this.move({
pace: 1,
emitChange: true,
});
});
},
correctPosition() {
this.swiping = true;
if (this.active <= -1) {
this.move({ pace: this.count });
}
if (this.active >= this.count) {
this.move({ pace: -this.count });
}
},
move({ pace = 0, offset = 0, emitChange }) {
const { loop, count, active, trackSize, minOffset } = this;
const children = this.$children
if (count <= 1) {
return;
}
const targetActive = this.getTargetActive(pace);
const targetOffset = this.getTargetOffset(targetActive, offset);
// auto move first and last swipe in loop mode
if (loop) {
// console.log(children)
// 滑到右边最后一张边界处理
if (children[0] && targetOffset !== minOffset) {
const outRightBound = targetOffset < minOffset;
children[0].offset = outRightBound ? trackSize : 0;
}
// 滑到左边第一张边界处理
if (children[count - 1] && targetOffset !== 0) {
const outLeftBound = targetOffset > 0;
children[count - 1].offset = outLeftBound ? -trackSize : 0;
}
}
this.active = targetActive;
this.offset = targetOffset;
if (emitChange && targetActive !== active) {
this.$emit('change', this.activeIndicator)
}
},
getTargetActive(pace) {
const { active, count, maxCount } = this;
if (pace) {
if (this.loop) {
return range(active + pace, -1, count);
}
return range(active + pace, 0, maxCount);
}
return active;
},
getTargetOffset(targetActive, offset = 0) {
let currentPosition = targetActive * this.size;
if (!this.loop) {
currentPosition = Math.min(currentPosition, -this.minOffset);
}
let targetOffset = Math.round(offset - currentPosition);
if (!this.loop) {
targetOffset = range(targetOffset, this.minOffset, 0);
}
return targetOffset;
},
clear() {
clearTimeout(this.timer)
},
onTouchStart(event) {
if (!this.touchable) return;
this.clear();
this.touchStartTime = Date.now();
this.touchStart(event);
this.correctPosition();
},
onTouchMove(event) {
if (!this.touchable || !this.swiping) return;
this.touchMove(event);
if (this.isCorrectDirection) {
preventDefault(event, this.stopPropagation);
this.move({ offset: this.delta });
}
},
onTouchEnd() {
if (!this.touchable || !this.swiping) return;
const { size, delta } = this;
const duration = Date.now() - this.touchStartTime;
const speed = delta / duration;
const shouldSwipe = Math.abs(speed) > 0.25 || Math.abs(delta) > size / 2;
if (shouldSwipe && this.isCorrectDirection) {
const offset = this.vertical ? this.offsetY : this.offsetX;
let pace = 0;
if (this.loop) {
pace = offset > 0 ? (delta > 0 ? -1 : 1) : 0;
} else {
pace = -Math[delta > 0 ? 'ceil' : 'floor'](delta / size);
}
this.move({
pace,
emitChange: true,
});
} else if (delta) {
this.move({ pace: 0 });
}
this.swiping = false;
this.autoPlay();
},
}
}
</script>
<style lang="scss" scoped>
.swiper {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
cursor: grab;
}
.track-container {
display: flex;
height: 100%;
}
</style>
<template>
<div class="swiper-item" :style="style">
<slot />
</div>
</template>
<script>
export default {
name: "SwiperItem",
data() {
return {
offset: 0,
mounted: false,
};
},
mounted() {
this.$nextTick(() => {
this.mounted = true;
});
},
computed: {
style() {
const style = {};
const { size, vertical } = this.$parent
style[vertical ? "height" : "width"] = `${size}px`;
if (this.offset) {
style.transform = `translate${vertical ? "Y" : "X"}(${this.offset}px)`;
}
return style;
}
},
};
</script>
export let supportsPassive = false;
export function on(target, event, handler, passive = false) {
target.addEventListener(
event,
handler,
supportsPassive ? { capture: false, passive } : false
);
}
const MIN_DISTANCE = 10;
function getDirection(x, y) {
if (x > y && x > MIN_DISTANCE) {
return 'horizontal';
}
if (y > x && y > MIN_DISTANCE) {
return 'vertical';
}
return '';
}
export const TouchMixin = {
data() {
return { direction: '' };
},
methods: {
touchStart(event) {
this.resetTouchStatus();
this.startX = event.touches[0].clientX;
this.startY = event.touches[0].clientY;
},
touchMove(event) {
const touch = event.touches[0];
this.deltaX = touch.clientX - this.startX;
this.deltaY = touch.clientY - this.startY;
this.offsetX = Math.abs(this.deltaX);
this.offsetY = Math.abs(this.deltaY);
this.direction = this.direction || getDirection(this.offsetX, this.offsetY);
},
resetTouchStatus() {
this.direction = '';
this.deltaX = 0;
this.deltaY = 0;
this.offsetX = 0;
this.offsetY = 0;
},
// avoid Vue 2.6 event bubble issues by manually binding events
// https://github.com/youzan/vant/issues/3015
bindTouchEvent(el) {
const { onTouchStart, onTouchMove, onTouchEnd } = this;
on(el, 'touchstart', onTouchStart);
on(el, 'touchmove', onTouchMove);
if (onTouchEnd) {
on(el, 'touchend', onTouchEnd);
on(el, 'touchcancel', onTouchEnd);
}
},
},
};
// 利用 window.requestAnimationFrame(fn) 动画
// 对应 MDN 文档地址:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
const root = window
const iRaf = root.requestAnimationFrame || fallback;
export function raf(fn) {
return iRaf.call(root, fn);
}
// animation 效果
export function doubleRaf(fn) {
raf(() => {
raf(fn);
});
}
export function range(num, min, max) {
return Math.min(Math.max(num, min), max);
}
export function isHidden(el) {
const style = window.getComputedStyle(el);
const hidden = style.display === 'none';
const parentHidden = el.offsetParent === null && style.position !== 'fixed';
return hidden || parentHidden;
}
export function stopPropagation(event) {
event.stopPropagation();
}
export function preventDefault(event, isStopPropagation) {
/* istanbul ignore else */
if (typeof event.cancelable !== 'boolean' || event.cancelable) {
event.preventDefault();
}
if (isStopPropagation) {
stopPropagation(event);
}
}
以上根据 UI 库源码简单实现,可根据具体需求进行扩展