WeUI Picker組件 源代碼分析

 

前言

因爲最近作的一個移動端項目須要使用到相似 WeUI Picker組件 的選擇效果,  因此在這裏來分析下 WeUI Picker 的實現邏輯。(weui.js項目地址)css

以前也作過相似的組件, 是基於iscroll實現的。單列滑動的效果還能夠。至於多列聯動,數據結構整的太亂了, 不太好擴展。html

項目結構

你們經過上面 weui.js 的項目地址去下載到本地, 打開以後找到 src 下面的 picker 就是咱們今天要學習的 picker 組件的代碼了。git

其中picker.js 和 scroll.js 就是咱們主要研究的對象。github

picker.js

在 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

原本想給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 組件, 因此我這裏把 picker 單獨打包壓縮了一份放到github上,  抽取以後的picker.min.js比原來的weui.min.js少了一大半的體積。(weuiPicker項目地址)app

有須要的童鞋能夠自取, 也能夠根據weui的項目自行打包。dom

 

ps: 第一次寫, 有不合理的地方請你們多多指正 : )

相關文章
相關標籤/搜索