前言
由于最近做的一个移动端项目需要使用到类似 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 }
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 };
1.3 抽取picker
研究完了, 肯定要想着怎么使用起来。
但是我们可能只想使用 picker 组件, 所以我这里把 picker 单独打包压缩了一份放到github上, 抽取之后的picker.min.js比原来的weui.min.js少了一大半的体积。(weuiPicker项目地址)
有需要的童鞋可以自取, 也可以根据weui的项目自行打包。
ps: 第一次写, 有不合理的地方请大家多多指正 : )