UNPKG

seamless-scroll

Version:
561 lines (524 loc) 20.7 kB
/** * 判断是否是非负数 * @param {Sting} field 字段名 * @param {*} value 值 */ function isNonNegativeNumber(field, value) { const checked = typeof value === 'number' && value >= 0 && value !== Infinity; if (!checked) { throw new Error(`${field} option must be a non-negative number`); } } /** * 判断是否是布尔值 * @param {Sting} field 字段名 * @param {*} value 值 */ function isBoolean(field, value) { if (value !== true && value !== false) { throw new Error(`${field} option must be a boolean`); } } /** * 为配置参数设置默认值并检测是否符合要求 * @param {Object} opts */ function checkOpts(opts) { // 必须是对象 if (Object.prototype.toString.apply(opts) !== '[object Object]') { throw new Error('config must be an object'); } // 设置默认值 if (opts.direction === undefined) { opts.direction = 'left'; } if (opts.duration === undefined) { opts.duration = 300; } if (opts.delay === undefined) { opts.delay = 3000; } if (opts.activeIndex === undefined) { opts.activeIndex = 0; } if (opts.autoPlay === undefined) { opts.autoPlay = true; } if (opts.prevent === undefined) { opts.prevent = true; } // 校验参数 if (typeof opts.el !== 'object' && !document.getElementById(opts.el)) { throw new Error('el option must be an existing HTML Element'); } if (['left', 'right', 'up', 'down'].indexOf(opts.direction) === -1) { throw new Error('direction option must be one of ["left", "right", "up", "down"]'); } isNonNegativeNumber('width', opts.width); isNonNegativeNumber('height', opts.height); isNonNegativeNumber('duration', opts.duration); isNonNegativeNumber('delay', opts.delay); isNonNegativeNumber('activeIndex', opts.activeIndex); isBoolean('autoPlay', opts.autoPlay); isBoolean('prevent', opts.prevent); if (opts.onChange && typeof opts.onChange !== 'function') { throw new Error('onChange option must be a function'); } } // /** // * 获取当前浏览器 requestAnimationFrame 的执行间隔(效果不好,暂时直接使用1000 / 60) // * @param {Function} cb 回调函数,入参为两次间隔的毫秒数 // */ // function getFrameDiff(cb){ // let start; // requestAnimationFrame(step); // function step(timestamp){ // if(start){ // cb(timestamp - start); // }else{ // start = timestamp; // requestAnimationFrame(step); // } // } // } // 这两个方法支持到:IOS7+, Safari6.2+, Android5+,IE10+ const requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame; const cancelAnimationFrame = window.cancelAnimationFrame || window.mozCancelAnimationFrame; if (!requestAnimationFrame) { throw new Error('seamless-scroll can\'t work, because of requestAnimationFrame is not supported in your browser!'); } // translate 兼容到:IOS9+, Safari9.1+, Android5+, IE10+。虽然使用定位 + left/top,也可以实现本插件的效果,但是效果不如 translate if ('transition' in document.body.style === false) { console.log('seamless-scroll may not work, because of css transition is support in your browser!'); } function SeamlessScroll(opts) { if (!(this instanceof SeamlessScroll)) { throw new TypeError('SeamlessScroll must be called by the \'new\' keyword as a constructor'); } const _this = this; // 参数校验 checkOpts(opts); // 获取 DOM 元素 const wrap = typeof opts.el === 'object' ? opts.el : document.getElementById(opts.el); // 父容器 const list = wrap.children[0]; // 列表 const items = list.children; // 子元素 const length = items.length; // 子元素数量 // 初始化内部变量 const isHorizontal = opts.direction === 'left' || opts.direction === 'right'; // 是否是水平方向移动 const pagePos = isHorizontal ? 'pageX' : 'pageY'; const translate = isHorizontal ? 'translateX' : 'translateY'; let delayTimer, // 屏与屏切换时的延迟定时器 moveRequestId, // requestAnimationFrame 的返回值 offset, // 列表元素当前的位置偏移量 (通过读取元素的样式也可以获取,但这样通过 JS 变量来记录,对性能的开销显然小于直接操作 DOM) destination, // 列表元素需要移动到的目标位置 startPos, // 触摸开始时的X或Y轴坐标 代替startX/startY startOffset, // 触摸开始时的偏移量 代替startLeft/startTop startTime, // 触摸开始时的时间戳 startIndex, // 触摸开始时,触摸的元素的索引值 stopped, // 是否已被停止 eleSize = isHorizontal ? opts.width : opts.height, // 单个元素在移动方向上的尺寸 oneStep = ((eleSize / opts.duration) * 1000) / 60; // 每一小步移动的距离(requestAnimationFrame 的回调函数执行次数通常是每秒60次) // 监听内部索引值的变化,并在被更改时调用 opts.onChange 方法通知外部 let observerObj = { _innerActive: opts.activeIndex + 1 }; // 内部索引 Object.defineProperty(observerObj, 'innerActive', { configurable: false, enumerable: true, get() { return observerObj._innerActive; }, set(value) { observerObj._innerActive = value; value > 0 && value < length + 1 && opts.onChange && opts.onChange(value - 1); } }); // 设置父容器样式 wrap.style.display = 'block'; wrap.style.width = opts.width + 'px'; wrap.style.height = opts.height + 'px'; wrap.style.overflow = 'hidden'; // 设置列表样式 list.style.display = 'block'; list.style.zIndex = '10'; if (isHorizontal) { list.style.height = opts.height + 'px'; list.style.width = opts.width * (length + 2) + 'px'; } else { list.style.height = opts.height * (length + 2) + 'px'; list.style.width = opts.width + 'px'; } // 设置元素样式 for (let i = 0; i < length; i++) { items[i].style.display = 'block'; items[i].style.width = opts.width + 'px'; items[i].style.height = opts.height + 'px'; if (isHorizontal) { items[i].style.float = 'left'; } } // 前后各补充一个边界元素,以实现“无缝”的视觉效果 const firstItem = items[0].cloneNode(true); const lastItem = items[length - 1].cloneNode(true); list.insertBefore(lastItem, items[0]); list.appendChild(firstItem); // 开始 if (opts.autoPlay) { play(); } else { resetStatus(); } /** * 获取当前屏的索引 * 当用户用手指快速滑动时,或者通过 go 方法指定式的操作跳转时 * 用户潜意识里通常会认为占据屏幕大部分面积的那一屏是当前屏 */ function getVisualIndex() { let index; index = Math.round(Math.abs(offset) / eleSize); if (index === 0) { // 补位到最前面的那一屏 index = length; } else if (index === length + 1) { // 补位到最后面的那一屏 index = 1; } return --index; } /** * 重置状态 */ function resetStatus() { // 如果到达临界状态,更新内部索引,以达到“无缝”的效果 if (observerObj.innerActive > length) { observerObj.innerActive = 1; } else if (observerObj.innerActive < 1) { observerObj.innerActive = length; } // 偏移量立即回归到准确的位置 offset = -eleSize * observerObj.innerActive; list.style.transform = `${translate}(${offset}px)`; } /** * 播放时,每移动一屏,调一次 play 方法,以重置状态和确认新的目标位置 */ function play(delay = opts.delay) { // 重置状态 resetStatus(); // 停留一段时间后,确认新的目标位置,并开始下一波的移动 delayTimer = setTimeout(function() { if (['left', 'up'].includes(opts.direction)) { observerObj.innerActive++; } else { observerObj.innerActive--; } destination = -eleSize * observerObj.innerActive; move(opts.direction, oneStep); }, delay); } /** * 调用 requestAnimationFrame,一小步一小步的移动,直到到达目标位置 * @param {Number} direction 目标位置 * @param {Number} step 一小步的距离,步子越大,速度越快 */ function move(direction, step) { // https://developer.mozilla.org/zh-CN/docs/Web/CSS/will-change(效果还不如不加的流畅) // if ('willChange' in list.style) { // list.style.willChange = 'transform'; // } function moveStep() { // 由于 cancelAnimationFrame 的兼容性比较差,stop 方法触发时并不一定能让这个递归动作取消,也就是移动停止 // 所以需要通过配合 stopped 字段来决定行止 if (stopped) { return; } if (['left', 'up'].includes(direction)) { offset -= step; if (offset > destination) { // 即使向前走一步也不会超出目标,那就走呗 list.style.transform = `${translate}(${offset}px)`; moveRequestId = requestAnimationFrame(moveStep); } else { // if ('willChange' in list.style) { // list.style.willChange = 'auto'; // } // 到达或超过了目标位置后,如果已经播放过了,那就可以调用 play 方法继续了,如果从来没播放过,调整好位置,静静的待着 delayTimer ? play() : resetStatus(); } } else { offset += step; if (offset < destination) { list.style.transform = `${translate}(${offset}px)`; moveRequestId = requestAnimationFrame(moveStep); } else { delayTimer ? play() : resetStatus(); } } } moveRequestId = requestAnimationFrame(moveStep); } /** * 调用 requestAnimationFrame,一小步一小步的移动,分两段走,以达到“最短距离”的视觉效果 * @param {Number} direction 目标位置 * @param {Number} step 一小步的距离,步子越大,速度越快 */ function bestMove(direction, step) { let max = 0; // 偏移量的正边界 let min = -(length + 1) * eleSize; // 偏移量的负边界 function moveStep() { if (stopped) { return; } if (['left', 'up'].includes(direction)) { if (offset < destination && offset - step > min) { // 一直移动到负边界 offset -= step; list.style.transform = `${translate}(${offset}px)`; moveRequestId = requestAnimationFrame(moveStep); } else if (offset - step <= min) { // 临界状态,重置 offset = -eleSize; list.style.transform = `${translate}(${offset}px)`; moveRequestId = requestAnimationFrame(moveStep); } else if (offset - step > destination) { // 继续向目标位置移动 offset -= step; list.style.transform = `${translate}(${offset}px)`; moveRequestId = requestAnimationFrame(moveStep); } else { delayTimer ? play() : resetStatus(); } } else { if (offset > destination && offset + step < max) { offset += step; list.style.transform = `${translate}(${offset}px)`; moveRequestId = requestAnimationFrame(moveStep); } else if (offset + step >= max) { offset = -eleSize * length; list.style.transform = `${translate}(${offset}px)`; moveRequestId = requestAnimationFrame(moveStep); } else if (offset + step < destination) { offset += step; list.style.transform = `${translate}(${offset}px)`; moveRequestId = requestAnimationFrame(moveStep); } else { delayTimer ? play() : resetStatus(); } } } moveRequestId = requestAnimationFrame(moveStep); } // 触摸开始 function touchStartHandler(event) { _this.stop(); startTime = Date.now(); startPos = event.touches[0][pagePos]; startOffset = offset; startIndex = getVisualIndex(); } wrap.addEventListener('touchstart', touchStartHandler); // 滑动 function touchMoveHandler(event) { // 防止父级滚动 opts.prevent && event.preventDefault(); const diff = event.touches[0][pagePos] - startPos; // 不能超出边界位置 const max = 0; const min = -eleSize * (length + 1); offset = Math.min(max, Math.max(min, startOffset + diff)); list.style.transform = `${translate}(${offset}px)`; } wrap.addEventListener('touchmove', touchMoveHandler); // 触摸结束 function touchEndHandler(event) { const moveTime = Date.now() - startTime; const endPos = event.changedTouches[0][pagePos]; const speed = (endPos - startPos) / moveTime; if (speed < -0.6) { // 向左快速滑 _this.go(startIndex === length - 1 ? 0 : startIndex + 1); } else if (speed > 0.6) { // 向右快速滑 _this.go(startIndex === 0 ? length - 1 : startIndex - 1); } else { // 慢慢的滑,停下来后,贴到最近的那一边 observerObj.innerActive = Math.round(Math.abs(offset) / eleSize); destination = -eleSize * observerObj.innerActive; // 开始下一波移动 stopped = false; if (isHorizontal) { move(offset < destination ? 'right' : 'left', Math.abs(destination - offset) / 15); } else { move(offset < destination ? 'down' : 'up', Math.abs(destination - offset) / 15); } } } wrap.addEventListener('touchend', touchEndHandler); // 开始(该方法只能调用一次,用于非自动播放时,手动开始播放) this.start = function() { if (delayTimer) { // setTimeout 的返回值是正整数,一旦 play 方法被调用,该值即为 Truthy return; } stopped = false; play(0); // 0ms 延迟,立即开始移动 }; // 暂停 this.stop = function() { stopped = true; delayTimer && clearTimeout(delayTimer); moveRequestId && cancelAnimationFrame && cancelAnimationFrame(moveRequestId); }; // 继续 this.continue = function() { // 只允许在“播放过”且“被停止”的状态下调用 if (delayTimer && stopped) { stopped = false; move(opts.direction, oneStep); } }; // 以最短的距离从当前位置移动到目标屏 this.go = function(target) { if (typeof target !== 'number' && ['left', 'right', 'up', 'down'].indexOf(target) === -1) { throw new Error('only support index or one of ["left", "right", "up", "down"]'); } // 想跳转的索引 let index; if (typeof target === 'number') { index = target; } else if ((isHorizontal && ['left', 'right'].indexOf(target) === -1) || (!isHorizontal && ['up', 'down'].indexOf(target) === -1)) { // 方向冲突 throw new Error('direction conflict'); } else { index = getVisualIndex(); if (target === 'left' || target === 'up') { index = index === length - 1 ? 0 : index + 1; } else { index = index === 0 ? length - 1 : index - 1; } } // 使得 innerActive 不落在两侧的补位屏上 observerObj.innerActive = Math.max(Math.min(index + 1, length), 1); // 停止原本的活动状态 _this.stop(); // 到下一帧再开始新的动作,这么做的原因在于: // cancelAnimationFrame 的兼容性并不好,stop 方法的执行并不能保证原本 move/bestMove 方法中 requestAnimationFrame 动作已经取消 // 等一帧,让 stopped 字段先发挥作用 requestAnimationFrame(function() { // 此时之前的 move/bestMove 动作已经结束,重置 stopped 为 false,以开始新的动作 stopped = false; let len = eleSize * length; // 如果此时出现了补位屏,立即重置位置 if (offset > -eleSize) { offset -= len; } else if (offset < -len) { offset += len; } list.style.transform = `${translate}(${offset}px)`; // 确认目标位置,并以最短的距离直接从当前位置移动到目标屏(比如从第五屏到第二屏,如果按照 5,4,3,2 的顺序走,是不如5,1,2 的顺序的) destination = -eleSize * observerObj.innerActive; let diff = Math.abs(destination - offset); if (isHorizontal) { if (diff <= len / 2) { move(offset < destination ? 'right' : 'left', diff / 20); } else { bestMove(offset < destination ? 'left' : 'right', (len - diff) / 20); } } else { if (diff <= len / 2) { move(offset < destination ? 'down' : 'up', diff / 20); } else { bestMove(offset < destination ? 'up' : 'down', (len - diff) / 20); } } }); }; // 重置宽高 this.resize = function(width, height) { isNonNegativeNumber('width', width); isNonNegativeNumber('height', height); // 保存之前的宽高 let widthBak = opts.width; let heightBak = opts.height; // 更新内部数据 opts.width = width; opts.height = height; eleSize = isHorizontal ? width : height; oneStep = (((isHorizontal ? width : height) / opts.duration) * 1000) / 60; // 更新样式 wrap.style.width = width + 'px'; wrap.style.height = height + 'px'; if (isHorizontal) { list.style.height = height + 'px'; list.style.width = width * (length + 2) + 'px'; // 满屏状态下的边界情况处理 if (offset % widthBak === 0) { if (destination === 0) { destination = -widthBak * length; // 最后一屏 } else if (destination === -widthBak * (length + 1)) { destination = -widthBak; // 第一屏 } } // 等比缩放偏移量和目标位置的值 destination = destination * (width / widthBak); offset = offset * (width / widthBak); list.style.transform = `${translate}(${offset}px)`; } else { list.style.height = height * (length + 2) + 'px'; list.style.width = width + 'px'; // 满屏状态下的边界情况处理 if (offset % heightBak === 0) { if (destination === 0) { destination = -heightBak * length; // 最后一屏 } else if (destination === -heightBak * (length + 1)) { destination = -heightBak; // 第一屏 } } // 等比缩放偏移量和目标位置的值 destination = destination * (height / heightBak); offset = offset * (height / heightBak); list.style.transform = `${translate}(${offset}px)`; } for (let i = 0; i < length + 2; i++) { items[i].style.width = width + 'px'; items[i].style.height = height + 'px'; } }; // 销毁 this.destroy = function() { // 停止移动 _this.stop(); // 移除监听器 wrap.removeEventListener('touchstart', touchStartHandler); wrap.removeEventListener('touchmove', touchMoveHandler); wrap.removeEventListener('touchend', touchEndHandler); // 清除添加的样式 // 1. 父容器样式 wrap.style.display = ''; wrap.style.width = ''; wrap.style.height = ''; wrap.style.overflow = ''; // 2. 列表样式 list.style.display = ''; list.style.height = ''; list.style.width = ''; list.style.transform = ''; // 3. 移除边界元素 list.removeChild(items[0]); list.removeChild(items[length]); // 4. 元素样式 for (let i = 0; i < length; i++) { items[i].style.display = ''; items[i].style.width = ''; items[i].style.height = ''; if (isHorizontal) { items[i].style.float = ''; } } // 释放内存 (JS 有自带垃圾回收机制,而且似乎也没有办法从构造函数内部删除已创建的实例) // 参考链接:https://stackoverflow.com/questions/21118952/javascript-create-and-destroy-class-instance-through-class-method for (let key in _this) { delete _this[key]; } _this['__proto__'] = null; }; } export default SeamlessScroll;