模拟 iOS 日期时间选择器的拨轮效果图:
在线Demo: http://demo.ijuer.com/spinner.html,可用手机浏览器或者 PC 上得 Chrome 浏览器模拟查看。
JS 代码:
/** * 模拟 iOS 日期时间选择器的拨轮。但是没有 3D 卷曲效果 * @param {DOMElement} spinnerLayerElement 具有 .spinner-layer 类的一个 DIV 元素 * @param {Array} slots 拨轮中的数据,数组对象。其中每个元素为一个对象,包括 2 个属性:width 和 data。 * 其中,width 为字符串表示的宽度百分比,例如:'30%'。不知道这样设定会不会出现浮点误差。 * 如果出现了浮点误差,再来修改这里的代码。data 属性为一个数组,数组中的元素又是对象, * 包括 value 和 label 2 个属性,分别表示项目的值和在列表中显示的文本。 * 假定是一个选择年和月的拨轮,这里传入的 slots 参数可能是: * [ * {width: '50%', data: [{value: 0, label: '一月'}, {value: 1, label: '二月'}]}, * {width: '50%', data: [{value: 1, label: '1日'}, {value: 2, label: '2日'}]} * ] * * @param {function} cancelCallback 用户点击‘取消’按钮时候的回调函数。 * 调用发生时,this 为 window, * 传入 1 个参数:spinnerObj,表示哪个 MISpinner 对象发生的事件。可以留空 * @param {function} okCallback 用户点击‘确定’按钮时候的回调函数。 * 调用发生时,this 为 window, * 传入 1 个参数:spinnerObj,表示哪个 MISpinner 对象发生的事件。可以留空 * @param {function} valueChangeCallback 用户拨动拨轮导致当前选中的值发生变化的时候的回调函数。 * 调用发生时,this 为 window, * 传入 1 个参数:spinnerObj,表示哪个 MISpinner 对象发生的事件; * @param {object} options 扩展属性。目前仅接受一个参数 selectedIndex,数组,表示初始化的时候设置为选中项的下标。 * 请在传入之前,控制好下标的越界问题,确保要设置的下标是相对于 slots 里面的数据来说,是有效的范围。 * 这里的代码中没有处理越界的情况。 */ function MISpinner(spinnerLayerElement, slots, cancelCallback, okCallback, valueChangeCallback, options) { this.cancelCallback = cancelCallback; this.okCallback = okCallback; this.valueChangeCallback = valueChangeCallback; var opt = { itemHeight: 35, itemSize: 5 }; if (options && options.selectedIndexes) { this.selectedIndexes = options.selectedIndexes; } else { this.selectedIndexes = []; for (var i = 0; i < slots.length; i++) { this.selectedIndexes.push(0); } } this.option = opt; this.layerElement = spinnerLayerElement; this.layerElement.className += ' spinner-layer'; // Set the layer's size this.layerElement.style.width = window.innerWidth + 'px'; this.layerElement.style.height = opt.itemHeight * (opt.itemSize) + 46 + 'px'; this.layerElement.style.top = window.innerHeight + 'px'; // Create layer title var titleBar = document.createElement('DIV'); titleBar.className = 'spinner-title-bar'; var cancelButton = document.createElement('DIV'); cancelButton.className = 'button cancel-button'; cancelButton.innerText = '取消'; cancelButton.addEventListener('click', function(evt) { self.hide(); if (self.cancelCallback && typeof self.cancelCallback === 'function') { self.cancelCallback.call(window, self); } }); titleBar.appendChild(cancelButton); var title = document.createElement('DIV'); title.className = 'title'; title.innerText = 'Here is the title text'; titleBar.appendChild(title); var self = this; var okButton = document.createElement('DIV'); okButton.className = 'button ok-button'; okButton.innerText = '确定'; okButton.addEventListener('click', function(evt) { self.hide(); if (self.okCallback && typeof self.okCallback === 'function') { self.okCallback.call(window, self); } }); titleBar.appendChild(okButton); this.layerElement.appendChild(titleBar); for (var i = 0; i < slots.length; i++) { var slot = slots[i]; var slotBox = document.createElement('DIV'); slotBox.className = 'spinner-slot-box'; slotBox.style.width = slot.width; slotBox.style.height = opt.itemHeight * opt.itemSize + 'px'; slotBox.setAttribute('data-item-count', slot.data.length); // 当显示 5 个项目的时候,就是最多向下移动 2 个项目高 // 最多向上移动总项目数 - 3 个项目高 slotBox.setAttribute('data-max-translatey', Math.floor(opt.itemSize / 2) * opt.itemHeight); slotBox.setAttribute('data-min-translatey', -(slot.data.length - Math.ceil(opt.itemSize / 2)) * opt.itemHeight); var slotElement = document.createElement('DIV'); slotElement.className = 'spinner-slot'; slotElement.setAttribute('data-selected-index', this.selectedIndexes[i]); slotElement.setAttribute('data-last-translatey', (Math.floor(opt.itemSize / 2) - this.selectedIndexes[i]) * opt.itemHeight); slotElement.style.webkitTransform = 'translateY(' + slotElement.getAttribute('data-last-translatey') + 'px)'; slotBox.appendChild(slotElement); for (var j = 0; j < slot.data.length; j++) { var data = slot.data[j]; var item = document.createElement('DIV'); item.className = 'spinner-item'; item.setAttribute('data-value', data.value); item.innerText = data.label; slotElement.appendChild(item); } var cover = document.createElement('DIV'); cover.className = 'cover up'; cover.style.top = 0; cover.style.height = Math.floor(opt.itemSize / 2) * opt.itemHeight + 'px'; slotBox.appendChild(cover); cover = document.createElement('DIV'); cover.className = 'cover down'; cover.style.height = Math.ceil(opt.itemSize / 2) * opt.itemHeight + 'px'; cover.style.top = Math.ceil(opt.itemSize / 2) * opt.itemHeight + 'px'; slotBox.appendChild(cover); slotBox.addEventListener('touchstart', function(evt){ self.onTouchStart(this, evt); }, true); slotBox.addEventListener('touchmove', function(evt){ evt.preventDefault(); evt.stopPropagation(); self.onTouchMove(this, evt); }, true); slotBox.addEventListener('touchend', function(evt){ evt.preventDefault(); evt.stopPropagation(); self.onTouchEnd(this, evt); }, true); this.layerElement.appendChild(slotBox); } if (this.valueChangeCallback && typeof this.valueChangeCallback === 'function') { var selectedIndexes = this.getSelectedIndexes(); this.valueChangeCallback.call(window, this); } } /** * 显示拨轮浮层 * @param {Array} selectedIndexes 显示拨轮之前,设置选中项的下标。可选参数 * @return {void} */ MISpinner.prototype.show = function(selectedIndexes) { if (selectedIndexes) { this.setSelectedIndexes(selectedIndexes); } this.layerElement.style.display = 'block'; var self = this; setTimeout(function() { self.layerElement.style.webkitTransform = 'translateY(-' + self.layerElement.getBoundingClientRect().height + 'px)'; }, 400); }; /** * 隐藏拨轮浮层 * @return {void} */ MISpinner.prototype.hide = function() { this.layerElement.style.webkitTransform = ''; var self = this; setTimeout(function(){ self.layerElement.style.display = 'none'; }, 400); }; // CAUTION: 以下 3 个事件处理函数本来不应该暴漏出来的, // 请调用者不要直接调用这 3 个函数。 /** * 触摸开始。 * 在触摸开始的时候,要将对应的滚动槽位的动画效果去掉, * 并且记录开始的时间、开始的位置。 * @param {[type]} element [description] * @param {[type]} evt [description] * @return {[type]} [description] */ MISpinner.prototype.onTouchStart = function(element, evt) { element.setAttribute('data-starty', evt.touches[0].clientY); element.setAttribute('data-lasty', evt.touches[0].clientY); element.setAttribute('data-start-time', new Date().getTime()); var slot = element.querySelector('.spinner-slot'); slot.style.webkitTransitionProperty = 'none'; }; /** * 触摸移动。 * 触摸移动的时候,滚动槽位要跟随手指的移动而动 * @param {[type]} element [description] * @param {[type]} evt [description] * @return {[type]} [description] */ MISpinner.prototype.onTouchMove = function(element, evt) { element.setAttribute('data-lasty', evt.touches[0].clientY); var slot = element.querySelector('.spinner-slot'); var lastTranslateY = +(slot.getAttribute('data-last-translatey')) || 0; var startY = +(element.getAttribute('data-starty')); var dy = evt.touches[0].clientY - startY; var minTranslateY = +(element.getAttribute('data-min-translatey')); var maxTranslateY = +(element.getAttribute('data-max-translatey')); var newTranslateY = (lastTranslateY + dy); // 如果计算之后的滚动位置超出了最大的滚动位置,那么将滚动位置的变化量减小到 // 超出部分的平方根的 2 倍,产生阻滞的效果 if (newTranslateY > maxTranslateY) { newTranslateY = maxTranslateY + (Math.sqrt(newTranslateY - maxTranslateY) * 3); } if (newTranslateY < minTranslateY) { newTranslateY = minTranslateY - (Math.sqrt(minTranslateY - newTranslateY) * 3); } slot.style.webkitTransform = 'translateY(' + (newTranslateY) + 'px)'; slot.setAttribute('data-current-translatey', newTranslateY); }; /** * 触摸结束 * 触摸结束之后,要处理最后的效果 * @param {[type]} element [description] * @param {[type]} evt [description] * @return {[type]} [description] */ MISpinner.prototype.onTouchEnd = function(element, evt) { var startY = +(element.getAttribute('data-starty')); var endY = +(element.getAttribute('data-lasty')); var startTime = +(element.getAttribute('data-start-time')); var endTime = new Date().getTime(); var itemHeight = this.option.itemHeight; var movedY = endY - startY; var v0 = Math.abs((endY - startY) / (endTime - startTime)); // 计算扫动屏幕产生的初始速度 var a = -9.8; // 假定一个加速度。单位可以认为是 像素/毫秒^2 var t = 200; // 速度降低到 0 所需的时间 var s = 0; // 速度降低到 0 移动的距离 // 如果整个触摸动作完成所用的时间小于 200 ms, // 那么我们就认为是一个扫动动作,要产生大幅度的滚动。 if (endTime - startTime < 200) { // 计算初始速度 v0 在加速度 a 的作用下,降低到 0 所需的时间。 // 这里 v0 和 a 的方向是相反的,也就是说,a 实际上是一个减速度 // 但是时间不能是负数,所以做了一个绝对值。最后的 10 只是一个系数,为了模拟一个比较符合感受的时间 // 别问我怎么算出来的这个 10,我就是瞎蒙的。 t = Math.abs(v0 / a) * 10; // 根据加速度公式计算速度降低到 0 的时候,位移距离。 // 加速度、速度和距离公式为:s = v0*t + 0.5 * a * t^2 // 公式中的最后一个 50 也是一个系数,为了模拟一个比较符合感受的距离 // 另外,移动的距离是都是正数,但是到底是向上移动还是向下移动是根据 endY 和 startY 的比对产生的 s = (endY > startY ? -1 : 1) * (v0 * t + 0.5 * a * t * t * 50); } var minTranslateY = +(element.getAttribute('data-min-translatey')); var maxTranslateY = +(element.getAttribute('data-max-translatey')); var slot = element.querySelector('.spinner-slot'); var lastTranslateY = +(slot.getAttribute('data-last-translatey')) || 0; var currentTranslateY = +(slot.getAttribute('data-current-translatey')) || 0; var targetTranslateY = lastTranslateY + movedY + s; if (targetTranslateY > maxTranslateY) { targetTranslateY = maxTranslateY; } else if (targetTranslateY < minTranslateY) { targetTranslateY = minTranslateY; } else { targetTranslateY = Math.round(targetTranslateY / itemHeight) * itemHeight; } // 计算总共需要用动画效果移动多少像素 // 然后按照 1 像素 1ms 的比例去计算。但是动画效果不低于 500ms,不高于 1000ms t = Math.abs(targetTranslateY - currentTranslateY); if (t < 500) t = 500; if (t > 1000) t = 1000; slot.style.webkitTransitionProperty = '-webkit-transform'; slot.style.webkitTransitionTimingFunction = 'ease-out'; slot.style.webkitTransitionDuration = t + 'ms'; slot.style.webkitTransform = 'translateY(' + targetTranslateY + 'px)'; slot.setAttribute('data-last-translatey', targetTranslateY); slot.setAttribute('data-selected-index', Math.floor(this.option.itemSize / 2) - Math.round(targetTranslateY / itemHeight)); var self = this; if (self.valueChangeCallback && typeof self.valueChangeCallback === 'function') { setTimeout(function(){ self.valueChangeCallback.call(window, self); }, t); } }; /** * 获取当前选中的下标 * @return {Array} 当前选中的下标数组。 */ MISpinner.prototype.getSelectedIndexes = function() { var selectedIndexes = []; var slots = this.layerElement.querySelectorAll('.spinner-slot'); for (var i = 0; i < slots.length; i++) { selectedIndexes.push(slots[i].getAttribute('data-selected-index')); } return selectedIndexes; }; /** * 设置选中的下标 * @param {Array} selectedIndexes 要将哪些下标设置为选中状态 */ MISpinner.prototype.setSelectedIndexes = function(selectedIndexes) { var slots = this.layerElement.querySelectorAll('.spinner-slot'); for (var i = 0; i < slots.length; i++) { var slotElement = slots[i]; slotElement.setAttribute('data-selected-index', selectedIndexes[i]); slotElement.setAttribute('data-last-translatey', (Math.floor(this.option.itemSize / 2) - selectedIndexes[i]) * this.option.itemHeight); slotElement.style.webkitTransform = 'translateY(' + slotElement.getAttribute('data-last-translatey') + 'px)'; } if (this.valueChangeCallback && typeof this.valueChangeCallback === 'function') { this.valueChangeCallback.call(window, this); } }; /** * 获取当前选中的值 * @return {Array} 当前选中的值 */ MISpinner.prototype.getSelectedValues = function() { var selectedValues = []; var slots = this.layerElement.querySelectorAll('.spinner-slot'); for (var i = 0; i < slots.length; i++) { var items = slots[i].querySelectorAll('.spinner-item'); selectedValues.push(items[slots[i].getAttribute('data-selected-index')].getAttribute('data-value')); } return selectedValues; }; /** * 设置拨轮弹层的标题 * @param {string} textOrHtml 要设置的标题 */ MISpinner.prototype.setTitle = function(textOrHtml) { this.layerElement.querySelector('.title').innerHTML = textOrHtml; };
CSS 代码:
.spinner-layer { position: absolute; border-top: 1px solid #c4c4c4; user-select: none; -webkit-user-select: none; -webkit-transition-property: -webkit-transform; -webkit-transition-duration: 300ms; -webkit-transition-timing-function: ease-out; } .spinner-layer .spinner-title-bar { height: 44px; background-color: #f6f6f6; border-bottom: 1px solid #c4c4c4; } .spinner-layer .spinner-title-bar .title { position: absolute; left: 60px; right: 60px; font-family: sans-serif; height: 44px; line-height: 44px; vertical-align: middle; font-size: 14px; text-align: center; color: #323232; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .spinner-layer .spinner-title-bar .title span { font-size: inherit; font-family: inherit; } .spinner-layer .spinner-title-bar .button { display: inline-block; height: 44px; line-height: 44px; vertical-align: middle; font-size: 16px; font-family: sans-serif; width: 60px; text-align: center; color: #0c60fe; } .spinner-layer .spinner-title-bar .cancel-button { float: left; } .spinner-layer .spinner-title-bar .ok-button { float: right; } .spinner-box { float: left; overflow: hidden; user-select: none; -webkit-user-select: none; } .spinner-slot-box { float: left; position: relative; overflow: hidden; } .spinner-slot-box .cover { background-color: #f0f0f0; opacity: 0.7; position: absolute; width: 100%; } .spinner-slot-box .cover.up { /*background: -webkit-linear-gradient(top, #e0e0e0, #fff); */ border-bottom: 1px solid #d0d0d0; } .spinner-slot-box .cover.down { /*background: -webkit-linear-gradient(top, #fff, #e0e0e0);*/ border-top: 1px solid #d0d0d0; } .spinner-slot.ani { -webkit-transition-property: -webkit-transform; /*-webkit-transition-timing-function: ease-out;*/ } .spinner-item { height: 35px; line-height: 35px; vertical-align: middle; font-family: Arial; font-size: 18px; padding: 0 10px 0 10px; color: #323232; text-align: right; background-color: #fff; }
调用实例:
< !DOCTYPE HTML>Spinner Click to show spinner layer