• WeUI Picker组件 源代码分析


    前言

    由于最近做的一个移动端项目需要使用到类似 WeUI Picker组件 的选择效果,  所以在这里来分析下 WeUI Picker 的实现逻辑。(weui.js项目地址)

    之前也做过类似的组件, 是基于iscroll实现的。单列滑动的效果还可以。至于多列联动,数据结构整的太乱了, 不太好扩展。

    项目结构

    大家通过上面 weui.js 的项目地址去下载到本地, 打开之后找到 src 下面的 picker 就是我们今天要学习的 picker 组件的代码了。

    其中picker.js 和 scroll.js 就是我们主要研究的对象。

    picker.js

    在 picker.js 中有两个方法,picker 和 datePicker。其中 picker 是核心, datePicker 就是将日期数据整理好之后再去调用 picker

    以下是不包含 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项目地址)

    有需要的童鞋可以自取, 也可以根据weui的项目自行打包。

    ps: 第一次写, 有不合理的地方请大家多多指正 : )

  • 相关阅读:
    Cookie用法
    (转)大型网站架构系列:负载均衡详解(3)
    (转)大型网站架构系列:负载均衡详解(2)
    (转)大型网站架构系列:负载均衡详解(1)
    大型网站架构系列:消息队列
    ASP.NET MVC之从控制器传递数据到视图四种方式
    数据库总结
    C#数字千分位问题
    SQLServer分页存储过程
    查找出‘数据库中的值能匹配输入条件的后几位’的所有记录
  • 原文地址:https://www.cnblogs.com/haha1212/p/8393243.html
Copyright © 2020-2023  润新知