因为移动端iOS和安卓原生select样式和效果不一样,同一个控件在不一样系统上效果不一样。
因此决定制做一个跟iOS风格相似的,能够滚动,选择器插件。
以后看到了antd-mobile里面的picker插件符合咱们的要求,使用了一段时间感受其效果不错,隧查看源码,探究其制做过程。
可是antd-mobile是Typescript编写的,跟React相似,可是又不太同样。因此基本是关键问题查看其作参考,剩下的本身实现。css
通过查看和分析后 能够得出结论(以下图)html
该组件(Picker)大体分红3个部分react
children 触发组件弹出的部分,通常为List Item。其实就是该组件的this.props.children。git
mask 组件弹出以后的遮罩,点击遮罩组件消失,值不变(至关因而点击取消)。github
popup 组件弹出以后的内容,分红上下两个部分,其中下半部分是核心(antd-mobile中将其单独提出来 叫作PickerView)。算法
第3部分PickerView即为极为复杂,考虑到扩展性:
这里面的列数是可变的(最多不能超过5个);
每一列滚动结束 其后面的列对应的数组和默认值都要发生改变;
每一列都是支持滚动操做的(手势操做)。
组件化以后如图:数组
分析以后能够看出 第3部分是该组件的核心应该优先制做。antd
在作以前应该想好输入和输出。
该组件须要哪些参数,参数多少也决定了功能多少。
参照antd-mobile的文档 肯定参数以下:数据结构
data:组件的数据源 每列应该显示的数据的一个集合 有固定的数据结构app
col:组件应该显示的列数
value:默认显示的值 一个数组 每一项对应各个列的值
text:popup组件中间的提示文字
cancelText:取消按钮可自定义的文字 默认为取消
confirmText:肯定按钮自定义的文字 默认为肯定
cascade:是否级联 就是每一列的值变化 是否会影响其后面的列对应数组和值得变化 是否级联也会影响到数据源数据结果的不一样
onChange:点击肯定以后 组件值发生变化以后的回调
onPickerChange:每一列的值变化以后的回调
onCancel:取消以后的回调
参数肯定以后要肯定两个核心参数的数据结构
级联时候data的数据结构
const areaArray = [ {label: '北京市', value: '北京市', children: [ {label: '北京市', value: '北京市', children: [ {label: '朝阳区', value: '朝阳区'}, {label: '海淀区', value: '朝阳区'}, {label: '东城区', value: '朝阳区'}, {label: '西城区', value: '朝阳区'} ]} ]}, {label: '辽宁省', value: '辽宁省', children: [ {label: '沈阳市', value: '沈阳市', children: [ {label: '沈河区', value: '沈河区'}, {label: '浑南区', value: '浑南区'}, {label: '沈北新区', value: '沈北新区'}, ]}, {label: '本溪市', value: '本溪市', children: [ {label: '溪湖区', value: '溪湖区'}, {label: '东明区', value: '东明区'}, {label: '桓仁满族自治县', value: '桓仁满族自治县'}, ]} ]}, {label: '云南省', value: '云南省', children: [ {label: '昆明市', value: '昆明市', children:[ {label: '五华区', value: '五华区'}, {label: '官渡区', value: '官渡区'}, {label: '呈贡区', value: '呈贡区'}, ]} ]},];
对应value的数据结构:['辽宁省', '本溪市', '桓仁满族自治县’]
不级联的时候 data则为
const numberArray = [ [ {label: '一', value: '一'}, {label: '二', value: '二'}, {label: '三', value: '三'} ], [ {label: '1', value: '1'}, {label: '2', value: '2'}, {label: '3', value: '3'}, {label: '4', value: '4'} ], [ {label: '壹', value: '壹'}, {label: '貮', value: '貮'}, {label: '叁', value: '叁'} ] ];
此时value为:['一', '4', '貮’]
。
Picker组件的核心就是PickerView组件
PickerView组件里面每一个列功能比较集中,重用程度较高,故将其封装成PickerColumn组件。
PickerView主要的功能就是根据传给本身的props,整理出须要渲染几列PickerColumn,而且整理出PickerColumn须要的参数和回调。
PickerView起到在Picker和PickerColumn中的作数据转换和传递的功能。
这里要注意的几点:
PickerView是个非受控组件,初始化的时候,将props中的value存成本身的state,之后向外暴露本身的state。
在级联的状况下,每次PickerColumn的值变化的时候,都要给每一个Column计算他对应的data,这里用到了递归调用,这里的算法写的不是很完美(重点是handleValueChange, getColums, getColumnData, getNewValue这几个方法)。
PickerView的源码以下:
import React from 'react' import PickerColumn from './PickerColumn' // 选择器组件 class PickerView extends React.Component { static defaultProps = { col: 1, cascade: true }; static propTypes = { col: React.PropTypes.number, data: React.PropTypes.array, value: React.PropTypes.array, cascade: React.PropTypes.bool, onChange: React.PropTypes.func }; constructor (props) { super(props); this.state = { defaultSelectedValue: [] } } componentDidMount () { // picker view 当作一个非受控组件 let {value} = this.props; this.setState({ defaultSelectedValue: value }); } handleValueChange (newValue, index) { // 子组件column发生变化的回调函数 // 每次值发生变化 都要判断整个值数组的新值 let {defaultSelectedValue} = this.state; let {data, cascade, onChange} = this.props; let oldValue = defaultSelectedValue.slice(); oldValue[index] = newValue; if(cascade){ // 若是级联的状况下 const newState = this.getNewValue(data, oldValue, [], 0); this.setState({ defaultSelectedValue: newState }); // 若是有回调 if(onChange){ onChange(newState); } } else { // 不级联 单纯改对应数据 this.setState({ defaultSelectedValue: oldValue }); // 若是有回调 if(onChange){ onChange(oldValue); } } } getColumns () { let result = []; let {col, data, cascade} = this.props; let {defaultSelectedValue} = this.state; if(defaultSelectedValue.length == 0) return; let array; if(cascade){ array = this.getColumnsData(data, defaultSelectedValue, [], 0); } else { array = data; } for(let i = 0; i < col; i++){ result.push(<PickerColumn key={i} value={defaultSelectedValue[i]} data={array[i]} index={i} onValueChange={this.handleValueChange.bind(this)} />); } return result; } getColumnsData (tree, value, hasFind, deep) { // 遍历tree let has; let array = []; for(let i = 0; i < tree.length; i++){ array.push({label: tree[i].label, value: tree[i].value}); if(tree[i].value == value[deep]) { has = i; } } // 判断有没有找到 // 没找到return // 找到了 没有下一集 也return // 有下一级 则递归 if(has == undefined) return hasFind; hasFind.push(array); if(tree[has].children) { this.getColumnsData(tree[has].children, value, hasFind, deep+1); } return hasFind; } getNewValue (tree, oldValue, newValue, deep) { // 遍历tree let has; for(let i = 0; i < tree.length; i++){ if(tree[i].value == oldValue[deep]) { newValue.push(tree[i].value); has = i; } } if(has == undefined) { has = 0; newValue.push(tree[has].value); } if(tree[has].children) { this.getNewValue(tree[has].children, oldValue, newValue, deep+1); } return newValue; } render () { const columns = this.getColumns(); return ( <div className="zby-picker-view-box"> {columns} </div> ) } } export default PickerView
PickerColumn是PickerView的核心,其做用:
根据data生成选项列表
根据value 选中对应选项
识别滚动手势操做 用户在每一列自由滚动
滚动中止时候 识别当前选中的值 并反馈给PickerView
这里前两项都好作,关键是3 4两项
移动端手势操做以前一直使用的是Hammer.js。
可是在React中,并无太好的插件,github上有一我的封装的react-hammer插件,start到是不少(400+) 可是最近用起来老是报错。。。。
有人提问 却没人解决 因此也没敢选用
后来想引入Hammer.js本身进行封装 而后发现要封装的东西很多。。。。
最后看了Antd-mobile的源码 选用了何一鸣的zscroller插件
该插件能够说很好地知足了这里的须要 很不错 推荐
选好了插件以后 问题就简单了不少 PickerColumn也就没什么难度了
最后吐槽一句 这个zscroller是好,可是文档太少了。
import React from 'react' import ZScroller from 'zscroller' import classNames from 'classnames' // picker-view 中的列 class PickerColumn extends React.Component { static propTypes = { index: React.PropTypes.number, data: React.PropTypes.array, value: React.PropTypes.string, onValueChange: React.PropTypes.func }; componentDidMount () { // 绑定事件 this.bindScrollEvent(); // 列表滚到对应位置 this.scrollToPosition(); } componentDidUpdate() { this.zscroller.reflow(); this.scrollToPosition(); } componentWillUnmount() { this.zscroller.destroy(); } bindScrollEvent () { // 绑定滚动的事件 const content = this.refs.content; // getBoundingClientRect js原生方法 this.itemHeight = this.refs.indicator.getBoundingClientRect().height; // 最后仍是用了何一鸣的zscroll插件 // 可是这个插件并无太多的文档介绍 gg // 插件demo地址:http://yiminghe.me/zscroller/examples/demo.html let t = this; this.zscroller = new ZScroller(content, { scrollbars: false, scrollingX: false, snapping: true, // 滚动结束以后 滑动对应的位置 penetrationDeceleration: .1, minVelocityToKeepDecelerating: 0.5, scrollingComplete () { // 滚动结束 回调 t.scrollingComplete(); } }); // 设置每一个格子的高度 这样滚动结束 自动滚到对应格子上 // 单位必须是px 因此要动态取一下 this.zscroller.scroller.setSnapSize(0, this.itemHeight); } scrollingComplete () { // 滚动结束 判断当前选中值 const { top } = this.zscroller.scroller.getValues(); const {data, value, index, onValueChange} = this.props; let currentIndex = top / this.itemHeight; const floor = Math.floor(currentIndex); if (currentIndex - floor > 0.5) { currentIndex = floor + 1; } else { currentIndex = floor; } const selectedValue = data[currentIndex].value; if(selectedValue != value){ // 值发生变化 通知父组件 onValueChange(selectedValue, index); } } scrollToPosition () { // 滚动到选中的位置 let {data, value} = this.props; data.map((item)=>{ if(item.value == value){ this.selectByIndex(); return; } }); for(let i = 0; i < data.length; i++){ if(data[i].value == value){ this.selectByIndex(i); return; } } this.selectByIndex(0); } selectByIndex (index) { // 滚动到index对应的位置 let top = this.itemHeight * index; this.zscroller.scroller.scrollTo(0, top); } getCols () { // 根据value 和 index 获取到对应的data let {data, value, index} = this.props; let result = []; for(let i = 0; i < data.length; i++){ result.push(<div key={index + "-" + i} className={classNames(['zby-picker-view-col', {'selected': data[i].value == value}])}>{data[i].label}</div>); } return result; } render () { let cols = this.getCols(); return ( <div className="zby-picker-view-item"> <div className="zby-picker-view-list"> <div className="zby-picker-view-window"></div> <div className="zby-picker-view-indicator" ref="indicator"></div> <div className="zby-picker-view-content" ref="content"> {cols} </div> </div> </div> ) } } export default PickerColumn;
这里还有一点要注意,就是CSS
Column有个遮罩,遮罩的上半部分和下半部分有个白色白透明效果。
这个是照抄antd-mobile实现的,两个高度通常的渐变,做为上半部分和下班部分的background来实现,中间则是透明的。
到此PickerView制做完成,Picker插件的核心也就完成了。
剩下的Picker功能就是很常规的业务了
1.自定义文案的显示
2.popup和mask的显示和隐藏
3.数据的传递回调函数
这里有一点:考虑到页面若是有大量的Picker组件,会产生不少,隐藏的popup和mask,并且每一个PickerColumn都要初始化zscroller性能不是很好。因此当没有点击picker的时候mask和popup都是不输出在页面内的;
可是这样就形成了一个问题:mask和popup显示和隐藏的时候比较突兀,加了一个iOS上常见的淡入淡出和滑入滑出动画。因此写了个setTimeout来等动画完成以后,显示和隐藏。不知道有没有什么更好的方法实现这类动画效果。
import React from 'react' import classNames from 'classnames' import PickerView from './PickerView' import Touchable from 'rc-touchable' // 选择器组件 class Picker extends React.Component { static defaultProps = { col: 1, cancelText: "取消", confirmText: "肯定", cascade: true }; static propTypes = { col: React.PropTypes.number, data: React.PropTypes.array, value: React.PropTypes.array, cancelText: React.PropTypes.string, title: React.PropTypes.string, confirmText: React.PropTypes.string, cascade: React.PropTypes.bool, onChange: React.PropTypes.func, onCancel: React.PropTypes.func }; constructor (props) { super(props); this.state = { defaultValue: undefined, selectedValue: undefined, animation: "out", show: false } } componentDidMount () { // picker 当作一个非受控组件 let {value} = this.props; this.setState({ defaultValue: value, selectedValue: value }); } handleClickOpen (e) { if(e) e.preventDefault(); this.setState({ show: true }); let t = this; let timer = setTimeout(()=>{ t.setState({ animation: "in" }); clearTimeout(timer); }, 0); } handleClickClose (e) { if(e) e.preventDefault(); this.setState({ animation: "out" }); let t = this; let timer = setTimeout(()=>{ t.setState({ show: false }); clearTimeout(timer); }, 300); } handlePickerViewChange (newValue) { let {onPickerChange} = this.props; this.setState({ defaultValue: newValue }); if(onPickerChange){ onPickerChange(newValue); } } handleCancel () { const {defaultValue} = this.state; const {onCancel} = this.props; this.handleClickClose(); this.setState({ selectedValue: defaultValue }); if(onCancel){ onCancel(); } } handleConfirm () { // 点击确认以后的回调 const {defaultValue} = this.state; this.handleClickClose(); if (this.props.onChange) this.props.onChange(defaultValue); } getPopupDOM () { const {show, animation} = this.state; const {cancelText, title, confirmText} = this.props; const pickerViewDOM = this.getPickerView(); if(show){ return <div> <Touchable onPress={this.handleCancel.bind(this)}> <div className={classNames(['zby-picker-popup-mask', {'hide': animation == "out"}])}></div> </Touchable> <div className={classNames(['zby-picker-popup-wrap', {'popup': animation == "in"}])}> <div className="zby-picker-popup-header"> <Touchable onPress={this.handleCancel.bind(this)}> <span className="zby-picker-popup-item zby-header-left">{cancelText}</span> </Touchable> <span className="zby-picker-popup-item zby-header-title">{title}</span> <Touchable onPress={this.handleConfirm.bind(this)}> <span className="zby-picker-popup-item zby-header-right">{confirmText}</span> </Touchable> </div> <div className="zby-picker-popup-body"> {pickerViewDOM} </div> </div> </div> } } getPickerView () { const {col, data, cascade} = this.props; const {defaultValue, show} = this.state; if(defaultValue != undefined && show){ return <PickerView col={col} data={data} value={defaultValue} cascade={cascade} onChange={this.handlePickerViewChange.bind(this)}> </PickerView>; } } render () { const popupDOM = this.getPopupDOM(); return ( <div className="zby-picker-box"> {popupDOM} <Touchable onPress={this.handleClickOpen.bind(this)}> {this.props.children} </Touchable> </div> ) } } export default Picker
Picker到这就结束了,还能够添加一些功能,好比禁止选择的项等。
样式上Column没有作到iOS那种滚轮效果(Column看起来像个圆形的轮子同样)这个css能够后期加上
知道原理了,能够尝试着本身实现日期选择器datepicker。