WeUI Picker组件 源代码分析

 

前言

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

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

 

1.项目结构

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

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

1.1 picker.js

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

以下是不包含 datePicker 的 picker 注释代码

  1 import $ from '../util/util';//dom选择器, 在balajs上面又添加了处理dom的方法
  2 import cron from './cron';//应用对应的日期规则,生成picker需要的数据格式
  3 import './scroll';//滑动核心
  4 import * as util from './util';//提供了一个获取数据嵌套深度的方法depthOf
  5 import pickerTpl from './picker.html';//picker组件的html模版
  6 import groupTpl from './group.html';//具体的每个滑动列表的html模版
  7 
  8 /**
  9  * 处理输入数据的每一项的结构成为 { label: item, value: item } 结构
 10  */
 11 function Result(item) {
 12     if(typeof item != 'object'){
 13         item = {
 14             label: item,
 15             value: item
 16         };
 17     }
 18     $.extend(this, item);
 19 }
 20 Result.prototype.toString = function () {
 21     return this.value;
 22 };
 23 Result.prototype.valueOf = function () {
 24     return this.value;
 25 };
 26 
 27 let _sington; // 单例模式, 创建完成后为当前实例, 关闭的时候设置为false
 28 let temp = {}; // temp 储存上一次滑动的位置
 29 
 30 function picker() {
 31     if (_sington) return _sington;//保证同时只能存在一个picker对象
 32 
 33     // 动态获取最后一个参数作为配置项
 34     const options = arguments[arguments.length - 1];
 35     // 扩展传入的配置项到默认值
 36     const defaults = $.extend({
 37         id: 'default',
 38         className: '',
 39         container: 'body',
 40         onChange: $.noop,
 41         onConfirm: $.noop,
 42         onClose: $.noop
 43     }, options);
 44 
 45     // 数据处理
 46     let items;
 47     let isMulti = false; // 是否多列的类型
 48     // 当参数大于2的时候说明是多列
 49     if (arguments.length > 2) {
 50         let i = 0;
 51         items = [];
 52         while (i < arguments.length - 1) {
 53             items.push(arguments[i++]);
 54         }
 55         isMulti = true;
 56     } else {
 57         items = arguments[0];
 58     }
 59 
 60     // 获取缓存
 61     temp[defaults.id] = temp[defaults.id] || [];
 62     // 选择结果, 会当作回调方法onChange的参数
 63     const result = [];
 64     // 根据id获取当前picker实例 选中的值的缓存, 所以声明实例的时候id要唯一
 65     const lineTemp = temp[defaults.id];
 66     // 根据模版和defaults渲染出dom,这里只渲染了一个className
 67     const $picker = $($.render(pickerTpl, defaults));
 68     // depth:数据结构的深度, 多列的时候就是列数, 单列的时候是嵌套的数据的深度。
 69     // groups:具体的滑动的列的html
 70     let depth = options.depth || (isMulti ? items.length : util.depthOf(items[0])), groups = '';
 71 
 72     // 显示与隐藏的方法
 73     function show(){
 74         //将渲染好的pciker插入到 设置的container中, 此时每一列的内容都还没有添加进去
 75         $(defaults.container).append($picker);
 76 
 77         // 这里获取一下计算后的样式,强制触发渲染. fix IOS10下闪现的问题
 78         $.getStyle($picker[0], 'transform');
 79 
 80         // 展示组件
 81         $picker.find('.weui-mask').addClass('weui-animate-fade-in');
 82         $picker.find('.weui-picker').addClass('weui-animate-slide-up');
 83     }
 84     function _hide(callback){
 85         _hide = $.noop; // 防止二次调用导致报错
 86 
 87         // 隐藏组件
 88         $picker.find('.weui-mask').addClass('weui-animate-fade-out');
 89         $picker.find('.weui-picker')
 90             .addClass('weui-animate-slide-down')
 91             .on('animationend webkitAnimationEnd', function () {
 92                 //动画结束后将picker移除, _sington设置为false, 执行onClose回掉, 执行hide函数传入的回掉。
 93                 $picker.remove();
 94                 _sington = false;
 95                 defaults.onClose();
 96                 callback && callback();
 97             });
 98     }
 99     function hide(callback){ _hide(callback); }
100 
101     /**
102      * 初始化滚动的方法
103      * level: 第几列或者嵌套的时候第几层
104      * items: level对应的列的全部数据
105      */
106     function scroll(items, level) {
107         if (lineTemp[level] === undefined && defaults.defaultValue && defaults.defaultValue[level] !== undefined) {
108             // 没有缓存选项,而且存在defaultValue
109             const defaultVal = defaults.defaultValue[level];
110             let index = 0, len = items.length;
111 
112             // 取得默认值在items这一列中的index位置
113             if(typeof items[index] == 'object'){
114                 for (; index < len; ++index) {
115                     if (defaultVal == items[index].value) break;
116                 }
117             }else{
118                 for (; index < len; ++index) {
119                     if (defaultVal == items[index]) break;
120                 }
121             }
122 
123             // 缓存当前实例的第level层的选中项的index
124             if (index < len) {
125                 lineTemp[level] = index;
126             } else {
127                 console.warn('Picker has not match defaultValue: ' + defaultVal);
128             }
129         }
130         // 寻找到第level层对应的weui-picker__group容器进行 scroll 对应的事件的绑定
131         // scroll的具体实现放在scroll.js之中
132         /**
133          * items: level对应的列的全部数据
134          * temp: level选中项的索引
135          */
136         $picker.find('.weui-picker__group').eq(level).scroll({
137             items: items,
138             temp: lineTemp[level],
139             onChange: function (item, index) {
140                 //为当前的result赋值。把对应的第level层选中的值放到result中
141                 if (item) {
142                     result[level] = new Result(item);
143                 } else {
144                     result[level] = null;
145                 }
146                 //更新当前实例的第level层的选中项的索引
147                 lineTemp[level] = index;
148 
149                 if (isMulti) {
150                     // 多列的情况, 每一列都有选中的值的时候才会触发onChange回掉事件
151                     if(result.length == depth){
152                         defaults.onChange(result);
153                     }
154                 } else {
155                     /**
156                      * @子列表处理
157                      * 1. 在没有子列表,或者值列表的数组长度为0时,隐藏掉子列表。
158                      * 2. 滑动之后发现重新有子列表时,再次显示子列表。
159                      *
160                      * @回调处理
161                      * 1. 因为滑动实际上是一层一层传递的:父列表滚动完成之后,会call子列表的onChange,从而带动子列表的滑动。
162                      * 2. 所以,使用者的传进来onChange回调应该在最后一个子列表滑动时再call
163                      */
164                     if (item.children && item.children.length > 0) {
165                         $picker.find('.weui-picker__group').eq(level + 1).show();
166                         !isMulti && scroll(item.children, level + 1); // 不是多列的情况下才继续处理children
167                     } else {
168                         //如果子列表test不通过,子孙列表都隐藏。
169                         const $items = $picker.find('.weui-picker__group');
170                         $items.forEach((ele, index) => {
171                             if (index > level) {
172                                 $(ele).hide();
173                             }
174                         });
175 
176                         result.splice(level + 1);
177 
178                         defaults.onChange(result);
179                     }
180                 }
181             },
182             onConfirm: defaults.onConfirm
183         });
184     }
185 
186     // 根据depth添加对应的的滑动容器个数
187     let _depth = depth;
188     while (_depth--) {
189         groups += groupTpl;
190     }
191     // 滑动容器添加到picker组件后展示出来
192     $picker.find('.weui-picker__bd').html(groups);
193     show();
194 
195     // 展示出picker组件后根据是否是多列采用, 采用不同的机制处理
196     // 具体都是调用 scroll 处理每一列的元素的渲染和滚动绑定
197     if (isMulti) {
198         items.forEach((item, index) => {
199             scroll(item, index);
200         });
201     } else {
202         scroll(items, 0);
203     }
204 
205     // 给picker 绑定对应的取消和确认事件
206     $picker
207         .on('click', '.weui-mask', function () { hide(); })
208         .on('click', '.weui-picker__action', function () { hide(); })
209         .on('click', '#weui-picker-confirm', function () {
210             defaults.onConfirm(result);
211         });
212 
213     // picker的dom元素赋值给到_sington并且绑定hide函数后返回
214     _sington = $picker[0];
215     _sington.hide = hide;
216     return _sington;
217 }
View Code

 

1.2 scroll.js

本来想给scroll.js写点注释的, 后来发现人家注释已经写的很好了,  OTZ。

  1 import $ from '../util/util';
  2 
  3 /**
  4  * set transition
  5  * @param $target
  6  * @param time
  7  */
  8 const setTransition = ($target, time) => {
  9     return $target.css({
 10         '-webkit-transition': `all ${time}s`,
 11         'transition': `all ${time}s`
 12     });
 13 };
 14 
 15 
 16 /**
 17  * set translate
 18  */
 19 const setTranslate = ($target, diff) => {
 20     return $target.css({
 21         '-webkit-transform': `translate3d(0, ${diff}px, 0)`,
 22         'transform': `translate3d(0, ${diff}px, 0)`
 23     });
 24 };
 25 
 26 /**
 27  * @desc get index of middle item
 28  * @param items
 29  * @returns {number}
 30  */
 31 const getDefaultIndex = (items) => {
 32     let current = Math.floor(items.length / 2);
 33     let count = 0;
 34     while (!!items[current] && items[current].disabled) {
 35         current = ++current % items.length;
 36         count++;
 37 
 38         if (count > items.length) {
 39             throw new Error('No selectable item.');
 40         }
 41     }
 42 
 43     return current;
 44 };
 45 
 46 const getDefaultTranslate = (offset, rowHeight, items) => {
 47     const currentIndex = getDefaultIndex(items);
 48 
 49     return (offset - currentIndex) * rowHeight;
 50 };
 51 
 52 /**
 53  * get max translate
 54  * @param offset
 55  * @param rowHeight
 56  * @returns {number}
 57  */
 58 const getMax = (offset, rowHeight) => {
 59     return offset * rowHeight;
 60 };
 61 
 62 /**
 63  * get min translate
 64  * @param offset
 65  * @param rowHeight
 66  * @param length
 67  * @returns {number}
 68  */
 69 const getMin = (offset, rowHeight, length) => {
 70     return -(rowHeight * (length - offset - 1));
 71 };
 72 
 73 $.fn.scroll = function (options) {
 74     const defaults = $.extend({
 75         items: [],                                  // 数据
 76         scrollable: '.weui-picker__content',        // 滚动的元素
 77         offset: 3,                                  // 列表初始化时的偏移量(列表初始化时,选项是聚焦在中间的,通过offset强制往上挪3项,以达到初始选项是为顶部的那项)
 78         rowHeight: 34,                              // 列表每一行的高度
 79         onChange: $.noop,                           // onChange回调
 80         temp: null,                                 // translate的缓存
 81         bodyHeight: 7 * 34                          // picker的高度,用于辅助点击滚动的计算
 82     }, options);
 83     const items = defaults.items.map((item) => {
 84         return `<div class="weui-picker__item${item.disabled ? ' weui-picker__item_disabled' : ''}">${typeof item == 'object' ? item.label : item}</div>`;
 85     }).join('');
 86     const $this = $(this);
 87 
 88     $this.find('.weui-picker__content').html(items);
 89 
 90     let $scrollable = $this.find(defaults.scrollable);        // 可滚动的元素
 91     let start;                                                  // 保存开始按下的位置
 92     let end;                                                    // 保存结束时的位置
 93     let startTime;                                              // 开始触摸的时间
 94     let translate;                                              // 缓存 translate
 95     const points = [];                                          // 记录移动点
 96     const windowHeight = window.innerHeight;                    // 屏幕的高度
 97 
 98     // 首次触发选中事件
 99     // 如果有缓存的选项,则用缓存的选项,否则使用中间值。
100     if(defaults.temp !== null && defaults.temp < defaults.items.length) {
101         const index = defaults.temp;
102         defaults.onChange.call(this, defaults.items[index], index);
103         translate = (defaults.offset - index) * defaults.rowHeight;
104     }else{
105         const index = getDefaultIndex(defaults.items);
106         defaults.onChange.call(this, defaults.items[index], index);
107         translate = getDefaultTranslate(defaults.offset, defaults.rowHeight, defaults.items);
108     }
109 
110     //初始化的时候先根据上面代码 计算出来的 初始化 translate 运动一次
111     setTranslate($scrollable, translate);
112 
113     const stop = (diff) => {
114         //根据 计算出来的位移量diff 与 当前的偏移量translate 相加
115         translate += diff;
116 
117         // 移动到最接近的那一行
118         translate = Math.round(translate / defaults.rowHeight) * defaults.rowHeight;
119         const max = getMax(defaults.offset, defaults.rowHeight);
120         const min = getMin(defaults.offset, defaults.rowHeight, defaults.items.length);
121         // 不要超过最大值或者最小值
122         if (translate > max) {
123             translate = max;
124         }
125         if (translate < min) {
126             translate = min;
127         }
128 
129         // 如果是 disabled 的就跳过
130         let index = defaults.offset - translate / defaults.rowHeight;
131         while (!!defaults.items[index] && defaults.items[index].disabled) {
132             diff > 0 ? ++index : --index;
133         }
134         translate = (defaults.offset - index) * defaults.rowHeight;
135         setTransition($scrollable, .3);
136         setTranslate($scrollable, translate);
137 
138         // 触发选择事件
139         defaults.onChange.call(this, defaults.items[index], index);
140     };
141 
142     function _start(pageY){
143         start = pageY;
144         startTime = +new Date();
145     }
146     function _move(pageY){
147         end = pageY;
148         const diff = end - start;
149 
150         setTransition($scrollable, 0);
151         setTranslate($scrollable, (translate + diff));
152         startTime = +new Date();
153         points.push({time: startTime, y: end});
154         if (points.length > 40) {
155             points.shift();
156         }
157     }
158     function _end(pageY){
159         if(!start) return;
160 
161         /**
162          * 思路:
163          * 0. touchstart 记录按下的点和时间
164          * 1. touchmove 移动时记录前 40个经过的点和时间
165          * 2. touchend 松开手时, 记录该点和时间. 如果松开手时的时间, 距离上一次 move时的时间超过 100ms, 那么认为停止了, 不执行惯性滑动
166          *    如果间隔时间在 100ms 内, 查找 100ms 内最近的那个点, 和松开手时的那个点, 计算距离和时间差, 算出速度
167          *    速度乘以惯性滑动的时间, 例如 300ms, 计算出应该滑动的距离
168          */
169         const endTime = new Date().getTime();
170         const relativeY = windowHeight - (defaults.bodyHeight / 2);
171         end = pageY;
172 
173         // 如果上次时间距离松开手的时间超过 100ms, 则停止了, 没有惯性滑动
174         if (endTime - startTime > 100) {
175             //如果end和start相差小于10,则视为
176             if (Math.abs(end - start) > 10) {
177                 stop(end - start);
178             } else {
179                 stop(relativeY - end);
180             }
181         } else {
182             if (Math.abs(end - start) > 10) {
183                 const endPos = points.length - 1;
184                 let startPos = endPos;
185                 for (let i = endPos; i > 0 && startTime - points[i].time < 100; i--) {
186                     startPos = i;
187                 }
188 
189                 if (startPos !== endPos) {
190                     const ep = points[endPos];
191                     const sp = points[startPos];
192                     const t = ep.time - sp.time;
193                     const s = ep.y - sp.y;
194                     const v = s / t; // 出手时的速度
195                     const diff = v * 150 + (end - start); // 滑行 150ms,这里直接影响“灵敏度”
196                     stop(diff);
197                 }
198                 else {
199                     stop(0);
200                 }
201             } else {
202                 stop(relativeY - end);
203             }
204         }
205 
206         start = null;
207     }
208 
209     /**
210      * 因为现在没有移除匿名函数的方法,所以先暴力移除(offAll),并且改变$scrollable。
211      */
212     $scrollable = $this
213         .offAll()
214         .on('touchstart', function (evt) {
215             _start(evt.changedTouches[0].pageY);
216         })
217         .on('touchmove', function (evt) {
218             _move(evt.changedTouches[0].pageY);
219             evt.preventDefault();
220         })
221         .on('touchend', function (evt) {
222             _end(evt.changedTouches[0].pageY);
223         })
224         .find(defaults.scrollable);
225 
226     // 判断是否支持touch事件 https://github.com/Modernizr/Modernizr/blob/master/feature-detects/touchevents.js
227     const isSupportTouch = ('ontouchstart' in window) || window.DocumentTouch && document instanceof window.DocumentTouch;
228     if(!isSupportTouch){
229         $this
230             .on('mousedown', function(evt){
231                 _start(evt.pageY);
232                 evt.stopPropagation();
233                 evt.preventDefault();
234             })
235             .on('mousemove', function(evt){
236                 if(!start) return;
237 
238                 _move(evt.pageY);
239                 evt.stopPropagation();
240                 evt.preventDefault();
241             })
242             .on('mouseup mouseleave', function(evt){
243                 _end(evt.pageY);
244                 evt.stopPropagation();
245                 evt.preventDefault();
246             });
247 
248     }
249 };
View Code

 

1.3 抽取picker

研究完了, 肯定要想着怎么使用起来。

但是我们可能只想使用 picker 组件, 所以我这里把 picker 单独打包压缩了一份放到github上,  抽取之后的picker.min.js比原来的weui.min.js少了一大半的体积。(weuiPicker项目地址)

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

 

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

转载于:https://www.cnblogs.com/haha1212/p/8393243.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值