React实现滑动选择插件仿Antd-mobile Picker

效果图

需求

由于移动端iOS和安卓原生select样式和效果不同,同一个控件在不同系统上效果不同。
所以决定制作一个跟iOS风格类似的,可以滚动,选择器插件。
之后看到了antd-mobile里面的picker插件符合我们的要求,使用了一段时间感觉其效果不错,隧查看源码,探究其制作过程。
但是antd-mobile是Typescript编写的,跟React类似,但是又不太一样。所以基本是关键问题查看其做参考,剩下的自己实现。

Step1 组件分析

经过查看和分析后 可以得出结论(如下图)

该组件(Picker)大致分成3个部分

  1. children 触发组件弹出的部分,一般为List Item。其实就是该组件的this.props.children。

  2. mask 组件弹出之后的遮罩,点击遮罩组件消失,值不变(相当于是点击取消)。

  3. popup 组件弹出之后的内容,分成上下两个部分,其中下半部分是核心(antd-mobile中将其单独提出来 叫做PickerView)。

第3部分PickerView即为极为复杂,考虑到扩展性:
这里面的列数是可变的(最多不能超过5个);
每一列滚动结束 其后面的列对应的数组和默认值都要发生改变;
每一列都是支持滚动操作的(手势操作)。
组件化之后如图:

分析之后可以看出 第3部分是该组件的核心应该优先制作。

Step 2 使用方法确定

在做之前应该想好输入和输出。
该组件需要哪些参数,参数多少也决定了功能多少。
参照antd-mobile的文档 确定参数如下:

  1. data:组件的数据源 每列应该显示的数据的一个集合 有固定的数据结构

  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: '北京市',children: [
            {label: '朝阳区',value: '朝阳区'},{label: '海淀区',{label: '东城区',{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,deep+1);
        }

        return hasFind;
    }
    getNewValue (tree,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,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,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,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,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: "确定",cancelText: React.PropTypes.string,title: React.PropTypes.string,confirmText: React.PropTypes.string,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,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

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


react 中的高阶组件主要是对于 hooks 之前的类组件来说的,如果组件之中有复用的代码,需要重新创建一个父类,父类中存储公共代码,返回子类,同时把公用属性...
我们上一节了解了组件的更新机制,但是只是停留在表层上,例如我们的 setState 函数式同步执行的,我们的事件处理直接绑定在了 dom 元素上,这些都跟 re...
我们上一节了解了 react 的虚拟 dom 的格式,如何把虚拟 dom 转为真实 dom 进行挂载。其实函数是组件和类组件也是在这个基础上包裹了一层,一个是调...
react 本身提供了克隆组件的方法,但是平时开发中可能很少使用,可能是不了解。我公司的项目就没有使用,但是在很多三方库中都有使用。本小节我们来学习下如果使用该...
mobx 是一个简单可扩展的状态管理库,中文官网链接。小编在接触 react 就一直使用 mobx 库,上手简单不复杂。
我们在平常的开发中不可避免的会有很多列表渲染逻辑,在 pc 端可以使用分页进行渲染数限制,在移动端可以使用下拉加载更多。但是对于大量的列表渲染,特别像有实时数据...
本小节开始前,我们先答复下一个同学的问题。上一小节发布后,有小伙伴后台来信问到:‘小编你只讲了类组件中怎么使用 ref,那在函数式组件中怎么使用呢?’。确实我们...
上一小节我们了解了固定高度的滚动列表实现,因为是固定高度所以容器总高度和每个元素的 size、offset 很容易得到,这种场景也适合我们常见的大部分场景,例如...
上一小节我们处理了 setState 的批量更新机制,但是我们有两个遗漏点,一个是源码中的 setState 可以传入函数,同时 setState 可以传入第二...
我们知道 react 进行页面渲染或者刷新的时候,会从根节点到子节点全部执行一遍,即使子组件中没有状态的改变,也会执行。这就造成了性能不必要的浪费。之前我们了解...
在平时工作中的某些场景下,你可能想在整个组件树中传递数据,但却不想手动地通过 props 属性在每一层传递属性,contextAPI 应用而生。
楼主最近入职新单位了,恰好新单位使用的技术栈是 react,因为之前一直进行的是 vue2/vue3 和小程序开发,对于这些技术栈实现机制也有一些了解,最少面试...
我们上一节了了解了函数式组件和类组件的处理方式,本质就是处理基于 babel 处理后的 type 类型,最后还是要处理虚拟 dom。本小节我们学习下组件的更新机...
前面几节我们学习了解了 react 的渲染机制和生命周期,本节我们正式进入基本面试必考的核心地带 -- diff 算法,了解如何优化和复用 dom 操作的,还有...
我们在之前已经学习过 react 生命周期,但是在 16 版本中 will 类的生命周期进行了废除,虽然依然可以用,但是需要加上 UNSAFE 开头,表示是不安...
上一小节我们学习了 react 中类组件的优化方式,对于 hooks 为主流的函数式编程,react 也提供了优化方式 memo 方法,本小节我们来了解下它的用...
开源不易,感谢你的支持,❤ star me if you like concent ^_^
hel-micro,模块联邦sdk化,免构建、热更新、工具链无关的微模块方案 ,欢迎关注与了解
本文主题围绕concent的setup和react的五把钩子来展开,既然提到了setup就离不开composition api这个关键词,准确的说setup是由...
ReactsetState的执行是异步还是同步官方文档是这么说的setState()doesnotalwaysimmediatelyupdatethecomponent.Itmaybatchordefertheupdateuntillater.Thismakesreadingthis.staterightaftercallingsetState()apotentialpitfall.Instead,usecom