效果图
需求
由于移动端iOS和安卓原生select样式和效果不同,同一个控件在不同系统上效果不同。
所以决定制作一个跟iOS风格类似的,可以滚动,选择器插件。
之后看到了antd-mobile里面的picker插件符合我们的要求,使用了一段时间感觉其效果不错,隧查看源码,探究其制作过程。
但是antd-mobile是Typescript编写的,跟React类似,但是又不太一样。所以基本是关键问题查看其做参考,剩下的自己实现。
Step1 组件分析
经过查看和分析后 可以得出结论(如下图)
该组件(Picker)大致分成3个部分
children 触发组件弹出的部分,一般为List Item。其实就是该组件的this.props.children。
mask 组件弹出之后的遮罩,点击遮罩组件消失,值不变(相当于是点击取消)。
popup 组件弹出之后的内容,分成上下两个部分,其中下半部分是核心(antd-mobile中将其单独提出来 叫做PickerView)。
第3部分PickerView即为极为复杂,考虑到扩展性:
这里面的列数是可变的(最多不能超过5个);
每一列滚动结束 其后面的列对应的数组和默认值都要发生改变;
每一列都是支持滚动操作的(手势操作)。
组件化之后如图:
分析之后可以看出 第3部分是该组件的核心应该优先制作。
Step 2 使用方法确定
在做之前应该想好输入和输出。
该组件需要哪些参数,参数多少也决定了功能多少。
参照antd-mobile的文档 确定参数如下:
data:组件的数据源 每列应该显示的数据的一个集合 有固定的数据结构
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', '貮’]
。
Step 3 PickerView制作
Picker组件的核心就是PickerView组件
PickerView组件里面每个列功能比较集中,重用程度较高,故将其封装成PickerColumn组件。
Step 3-1 PickerView搭建
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
Step 3-2 PickerColumn封装
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插件的核心也就完成了。
Step 4 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。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。