React實現滑動選擇插件(仿Antd-mobile Picker)

效果圖

clipboard.png

需求

因爲移動端iOS和安卓原生select樣式和效果不一樣,同一個控件在不一樣系統上效果不一樣。
因此決定製做一個跟iOS風格相似的,能夠滾動,選擇器插件。
以後看到了antd-mobile裏面的picker插件符合咱們的要求,使用了一段時間感受其效果不錯,隧查看源碼,探究其製做過程。
可是antd-mobile是Typescript編寫的,跟React相似,可是又不太同樣。因此基本是關鍵問題查看其作參考,剩下的本身實現。css

Step1 組件分析

通過查看和分析後 能夠得出結論(以下圖)html

clipboard.png

該組件(Picker)大體分紅3個部分react

  1. children 觸發組件彈出的部分,通常爲List Item。其實就是該組件的this.props.children。git

  2. mask 組件彈出以後的遮罩,點擊遮罩組件消失,值不變(至關因而點擊取消)。github

  3. popup 組件彈出以後的內容,分紅上下兩個部分,其中下半部分是核心(antd-mobile中將其單獨提出來 叫作PickerView)。算法

第3部分PickerView即爲極爲複雜,考慮到擴展性:
這裏面的列數是可變的(最多不能超過5個);
每一列滾動結束 其後面的列對應的數組和默認值都要發生改變;
每一列都是支持滾動操做的(手勢操做)。
組件化以後如圖:數組

clipboard.png

分析以後能夠看出 第3部分是該組件的核心應該優先製做。antd

Step 2 使用方法肯定

在作以前應該想好輸入和輸出。
該組件須要哪些參數,參數多少也決定了功能多少。
參照antd-mobile的文檔 肯定參數以下:數據結構

  1. data:組件的數據源 每列應該顯示的數據的一個集合 有固定的數據結構app

  2. col:組件應該顯示的列數

  3. value:默認顯示的值 一個數組 每一項對應各個列的值

  4. text:popup組件中間的提示文字

  5. cancelText:取消按鈕可自定義的文字 默認爲取消

  6. confirmText:肯定按鈕自定義的文字 默認爲肯定

  7. cascade:是否級聯 就是每一列的值變化 是否會影響其後面的列對應數組和值得變化 是否級聯也會影響到數據源數據結果的不一樣

  8. onChange:點擊肯定以後 組件值發生變化以後的回調

  9. onPickerChange:每一列的值變化以後的回調

  10. 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中的作數據轉換和傳遞的功能。
這裏要注意的幾點:

  1. PickerView是個非受控組件,初始化的時候,將props中的value存成本身的state,之後向外暴露本身的state。

  2. 在級聯的狀況下,每次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的核心,其做用:

  1. 根據data生成選項列表

  2. 根據value 選中對應選項

  3. 識別滾動手勢操做 用戶在每一列自由滾動

  4. 滾動中止時候 識別當前選中的值 並反饋給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。

最後項目源碼Antd-Mobile

相關文章
相關標籤/搜索