因爲最近作的一個移動端項目須要使用到相似 WeUI Picker組件 的選擇效果, 因此在這裏來分析下 WeUI Picker 的實現邏輯。(weui.js項目地址)css
以前也作過相似的組件, 是基於iscroll實現的。單列滑動的效果還能夠。至於多列聯動,數據結構整的太亂了, 不太好擴展。html
你們經過上面 weui.js 的項目地址去下載到本地, 打開以後找到 src 下面的 picker 就是咱們今天要學習的 picker 組件的代碼了。git
其中picker.js 和 scroll.js 就是咱們主要研究的對象。github
在 picker.js 中有兩個方法,picker 和 datePicker。其中 picker 是核心, datePicker 就是將日期數據整理好以後再去調用 pickerweb
如下是不包含 datePicker 的 picker 註釋代碼數組
import $ from '../util/util';//dom選擇器, 在balajs上面又添加了處理dom的方法 import cron from './cron';//應用對應的日期規則,生成picker須要的數據格式 import './scroll';//滑動核心 import * as util from './util';//提供了一個獲取數據嵌套深度的方法depthOf import pickerTpl from './picker.html';//picker組件的html模版 import groupTpl from './group.html';//具體的每一個滑動列表的html模版 /** * 處理輸入數據的每一項的結構成爲 { label: item, value: item } 結構 */ function Result(item) { if(typeof item != 'object'){ item = { label: item, value: item }; } $.extend(this, item); } Result.prototype.toString = function () { return this.value; }; Result.prototype.valueOf = function () { return this.value; }; let _sington; // 單例模式, 建立完成後爲當前實例, 關閉的時候設置爲false let temp = {}; // temp 儲存上一次滑動的位置 function picker() { if (_sington) return _sington;//保證同時只能存在一個picker對象 // 動態獲取最後一個參數做爲配置項 const options = arguments[arguments.length - 1]; // 擴展傳入的配置項到默認值 const defaults = $.extend({ id: 'default', className: '', container: 'body', onChange: $.noop, onConfirm: $.noop, onClose: $.noop }, options); // 數據處理 let items; let isMulti = false; // 是否多列的類型 // 當參數大於2的時候說明是多列 if (arguments.length > 2) { let i = 0; items = []; while (i < arguments.length - 1) { items.push(arguments[i++]); } isMulti = true; } else { items = arguments[0]; } // 獲取緩存 temp[defaults.id] = temp[defaults.id] || []; // 選擇結果, 會看成回調方法onChange的參數 const result = []; // 根據id獲取當前picker實例 選中的值的緩存, 因此聲明實例的時候id要惟一 const lineTemp = temp[defaults.id]; // 根據模版和defaults渲染出dom,這裏只渲染了一個className const $picker = $($.render(pickerTpl, defaults)); // depth:數據結構的深度, 多列的時候就是列數, 單列的時候是嵌套的數據的深度。 // groups:具體的滑動的列的html let depth = options.depth || (isMulti ? items.length : util.depthOf(items[0])), groups = ''; // 顯示與隱藏的方法 function show(){ //將渲染好的pciker插入到 設置的container中, 此時每一列的內容都尚未添加進去 $(defaults.container).append($picker); // 這裏獲取一下計算後的樣式,強制觸發渲染. fix IOS10下閃現的問題 $.getStyle($picker[0], 'transform'); // 展現組件 $picker.find('.weui-mask').addClass('weui-animate-fade-in'); $picker.find('.weui-picker').addClass('weui-animate-slide-up'); } function _hide(callback){ _hide = $.noop; // 防止二次調用致使報錯 // 隱藏組件 $picker.find('.weui-mask').addClass('weui-animate-fade-out'); $picker.find('.weui-picker') .addClass('weui-animate-slide-down') .on('animationend webkitAnimationEnd', function () { //動畫結束後將picker移除, _sington設置爲false, 執行onClose回掉, 執行hide函數傳入的回掉。 $picker.remove(); _sington = false; defaults.onClose(); callback && callback(); }); } function hide(callback){ _hide(callback); } /** * 初始化滾動的方法 * level: 第幾列或者嵌套的時候第幾層 * items: level對應的列的所有數據 */ function scroll(items, level) { if (lineTemp[level] === undefined && defaults.defaultValue && defaults.defaultValue[level] !== undefined) { // 沒有緩存選項,並且存在defaultValue const defaultVal = defaults.defaultValue[level]; let index = 0, len = items.length; // 取得默認值在items這一列中的index位置 if(typeof items[index] == 'object'){ for (; index < len; ++index) { if (defaultVal == items[index].value) break; } }else{ for (; index < len; ++index) { if (defaultVal == items[index]) break; } } // 緩存當前實例的第level層的選中項的index if (index < len) { lineTemp[level] = index; } else { console.warn('Picker has not match defaultValue: ' + defaultVal); } } // 尋找到第level層對應的weui-picker__group容器進行 scroll 對應的事件的綁定 // scroll的具體實現放在scroll.js之中 /** * items: level對應的列的所有數據 * temp: level選中項的索引 */ $picker.find('.weui-picker__group').eq(level).scroll({ items: items, temp: lineTemp[level], onChange: function (item, index) { //爲當前的result賦值。把對應的第level層選中的值放到result中 if (item) { result[level] = new Result(item); } else { result[level] = null; } //更新當前實例的第level層的選中項的索引 lineTemp[level] = index; if (isMulti) { // 多列的狀況, 每一列都有選中的值的時候纔會觸發onChange回掉事件 if(result.length == depth){ defaults.onChange(result); } } else { /** * @子列表處理 * 1. 在沒有子列表,或者值列表的數組長度爲0時,隱藏掉子列表。 * 2. 滑動以後發現從新有子列表時,再次顯示子列表。 * * @回調處理 * 1. 由於滑動其實是一層一層傳遞的:父列表滾動完成以後,會call子列表的onChange,從而帶動子列表的滑動。 * 2. 因此,使用者的傳進來onChange回調應該在最後一個子列表滑動時再call */ if (item.children && item.children.length > 0) { $picker.find('.weui-picker__group').eq(level + 1).show(); !isMulti && scroll(item.children, level + 1); // 不是多列的狀況下才繼續處理children } else { //若是子列表test不經過,子孫列表都隱藏。 const $items = $picker.find('.weui-picker__group'); $items.forEach((ele, index) => { if (index > level) { $(ele).hide(); } }); result.splice(level + 1); defaults.onChange(result); } } }, onConfirm: defaults.onConfirm }); } // 根據depth添加對應的的滑動容器個數 let _depth = depth; while (_depth--) { groups += groupTpl; } // 滑動容器添加到picker組件後展現出來 $picker.find('.weui-picker__bd').html(groups); show(); // 展現出picker組件後根據是不是多列採用, 採用不一樣的機制處理 // 具體都是調用 scroll 處理每一列的元素的渲染和滾動綁定 if (isMulti) { items.forEach((item, index) => { scroll(item, index); }); } else { scroll(items, 0); } // 給picker 綁定對應的取消和確認事件 $picker .on('click', '.weui-mask', function () { hide(); }) .on('click', '.weui-picker__action', function () { hide(); }) .on('click', '#weui-picker-confirm', function () { defaults.onConfirm(result); }); // picker的dom元素賦值給到_sington而且綁定hide函數後返回 _sington = $picker[0]; _sington.hide = hide; return _sington; }
原本想給scroll.js寫點註釋的, 後來發現人家註釋已經寫的很好了, OTZ。緩存
import $ from '../util/util'; /** * set transition * @param $target * @param time */ const setTransition = ($target, time) => { return $target.css({ '-webkit-transition': `all ${time}s`, 'transition': `all ${time}s` }); }; /** * set translate */ const setTranslate = ($target, diff) => { return $target.css({ '-webkit-transform': `translate3d(0, ${diff}px, 0)`, 'transform': `translate3d(0, ${diff}px, 0)` }); }; /** * @desc get index of middle item * @param items * @returns {number} */ const getDefaultIndex = (items) => { let current = Math.floor(items.length / 2); let count = 0; while (!!items[current] && items[current].disabled) { current = ++current % items.length; count++; if (count > items.length) { throw new Error('No selectable item.'); } } return current; }; const getDefaultTranslate = (offset, rowHeight, items) => { const currentIndex = getDefaultIndex(items); return (offset - currentIndex) * rowHeight; }; /** * get max translate * @param offset * @param rowHeight * @returns {number} */ const getMax = (offset, rowHeight) => { return offset * rowHeight; }; /** * get min translate * @param offset * @param rowHeight * @param length * @returns {number} */ const getMin = (offset, rowHeight, length) => { return -(rowHeight * (length - offset - 1)); }; $.fn.scroll = function (options) { const defaults = $.extend({ items: [], // 數據 scrollable: '.weui-picker__content', // 滾動的元素 offset: 3, // 列表初始化時的偏移量(列表初始化時,選項是聚焦在中間的,經過offset強制往上挪3項,以達到初始選項是爲頂部的那項) rowHeight: 34, // 列表每一行的高度 onChange: $.noop, // onChange回調 temp: null, // translate的緩存 bodyHeight: 7 * 34 // picker的高度,用於輔助點擊滾動的計算 }, options); const items = defaults.items.map((item) => { return `<div class="weui-picker__item${item.disabled ? ' weui-picker__item_disabled' : ''}">${typeof item == 'object' ? item.label : item}</div>`; }).join(''); const $this = $(this); $this.find('.weui-picker__content').html(items); let $scrollable = $this.find(defaults.scrollable); // 可滾動的元素 let start; // 保存開始按下的位置 let end; // 保存結束時的位置 let startTime; // 開始觸摸的時間 let translate; // 緩存 translate const points = []; // 記錄移動點 const windowHeight = window.innerHeight; // 屏幕的高度 // 首次觸發選中事件 // 若是有緩存的選項,則用緩存的選項,不然使用中間值。 if(defaults.temp !== null && defaults.temp < defaults.items.length) { const index = defaults.temp; defaults.onChange.call(this, defaults.items[index], index); translate = (defaults.offset - index) * defaults.rowHeight; }else{ const index = getDefaultIndex(defaults.items); defaults.onChange.call(this, defaults.items[index], index); translate = getDefaultTranslate(defaults.offset, defaults.rowHeight, defaults.items); } //初始化的時候先根據上面代碼 計算出來的 初始化 translate 運動一次 setTranslate($scrollable, translate); const stop = (diff) => { //根據 計算出來的位移量diff 與 當前的偏移量translate 相加 translate += diff; // 移動到最接近的那一行 translate = Math.round(translate / defaults.rowHeight) * defaults.rowHeight; const max = getMax(defaults.offset, defaults.rowHeight); const min = getMin(defaults.offset, defaults.rowHeight, defaults.items.length); // 不要超過最大值或者最小值 if (translate > max) { translate = max; } if (translate < min) { translate = min; } // 若是是 disabled 的就跳過 let index = defaults.offset - translate / defaults.rowHeight; while (!!defaults.items[index] && defaults.items[index].disabled) { diff > 0 ? ++index : --index; } translate = (defaults.offset - index) * defaults.rowHeight; setTransition($scrollable, .3); setTranslate($scrollable, translate); // 觸發選擇事件 defaults.onChange.call(this, defaults.items[index], index); }; function _start(pageY){ start = pageY; startTime = +new Date(); } function _move(pageY){ end = pageY; const diff = end - start; setTransition($scrollable, 0); setTranslate($scrollable, (translate + diff)); startTime = +new Date(); points.push({time: startTime, y: end}); if (points.length > 40) { points.shift(); } } function _end(pageY){ if(!start) return; /** * 思路: * 0. touchstart 記錄按下的點和時間 * 1. touchmove 移動時記錄前 40個通過的點和時間 * 2. touchend 鬆開手時, 記錄該點和時間. 若是鬆開手時的時間, 距離上一次 move時的時間超過 100ms, 那麼認爲中止了, 不執行慣性滑動 * 若是間隔時間在 100ms 內, 查找 100ms 內最近的那個點, 和鬆開手時的那個點, 計算距離和時間差, 算出速度 * 速度乘以慣性滑動的時間, 例如 300ms, 計算出應該滑動的距離 */ const endTime = new Date().getTime(); const relativeY = windowHeight - (defaults.bodyHeight / 2); end = pageY; // 若是上次時間距離鬆開手的時間超過 100ms, 則中止了, 沒有慣性滑動 if (endTime - startTime > 100) { //若是end和start相差小於10,則視爲 if (Math.abs(end - start) > 10) { stop(end - start); } else { stop(relativeY - end); } } else { if (Math.abs(end - start) > 10) { const endPos = points.length - 1; let startPos = endPos; for (let i = endPos; i > 0 && startTime - points[i].time < 100; i--) { startPos = i; } if (startPos !== endPos) { const ep = points[endPos]; const sp = points[startPos]; const t = ep.time - sp.time; const s = ep.y - sp.y; const v = s / t; // 出手時的速度 const diff = v * 150 + (end - start); // 滑行 150ms,這裏直接影響「靈敏度」 stop(diff); } else { stop(0); } } else { stop(relativeY - end); } } start = null; } /** * 由於如今沒有移除匿名函數的方法,因此先暴力移除(offAll),而且改變$scrollable。 */ $scrollable = $this .offAll() .on('touchstart', function (evt) { _start(evt.changedTouches[0].pageY); }) .on('touchmove', function (evt) { _move(evt.changedTouches[0].pageY); evt.preventDefault(); }) .on('touchend', function (evt) { _end(evt.changedTouches[0].pageY); }) .find(defaults.scrollable); // 判斷是否支持touch事件 https://github.com/Modernizr/Modernizr/blob/master/feature-detects/touchevents.js const isSupportTouch = ('ontouchstart' in window) || window.DocumentTouch && document instanceof window.DocumentTouch; if(!isSupportTouch){ $this .on('mousedown', function(evt){ _start(evt.pageY); evt.stopPropagation(); evt.preventDefault(); }) .on('mousemove', function(evt){ if(!start) return; _move(evt.pageY); evt.stopPropagation(); evt.preventDefault(); }) .on('mouseup mouseleave', function(evt){ _end(evt.pageY); evt.stopPropagation(); evt.preventDefault(); }); } };
研究完了, 確定要想着怎麼使用起來。數據結構
可是咱們可能只想使用 picker 組件, 因此我這裏把 picker 單獨打包壓縮了一份放到github上, 抽取以後的picker.min.js比原來的weui.min.js少了一大半的體積。(weuiPicker項目地址)app
有須要的童鞋能夠自取, 也能夠根據weui的項目自行打包。dom
ps: 第一次寫, 有不合理的地方請你們多多指正 : )