因爲移動端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。