手把手教你写一个滑动条组件

前言

大家经常使用的滑动条组件如下图所示
clipboard.png
下面我教大家如何自己写一个滑动条组件。

组件分析

在写组件的第一步,我们先做一个组件的拆分,思考一下一个滑动条锁必备的基本要素是什么。
从图上我们可以看出,一个滑动条分为左右两个部分:左边一个 Range 组件,右边是一个 input输入框。Range组件又可以细分为 Container 组件(整体长度)和 Track 组件(进度条部分,在 Container 组件内部,children 传进去)还有一个 Point 组件(鼠标点的那个点)。
组件设计如下图所示

clipboard.png

看完组件的设计,我们可以考虑下组件需要传入什么参数:

参数说明是否必填
value输入值
onChangechange事件
range选择范围
max最大范围
min最小范围
step步长
withInput是否带输入框
disabled禁用
className自定义额外类名
width宽度
prefix自定义前缀

开始开发

Slider.js

主要代码

export default class Slider extends (PureComponent || Component) {
  static propTypes = {
    className: PropTypes.string,
    prefix: PropTypes.string,
    max: PropTypes.number,
    min: PropTypes.number,
    value: PropTypes.oneOfType([
      PropTypes.number,
      PropTypes.arrayOf(PropTypes.number),
    ]).isRequired,
    disabled: PropTypes.bool,
    range: PropTypes.bool,
    step: PropTypes.number,
    withInput: PropTypes.bool,
    onChange: PropTypes.func,
    width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
  };

  static defaultProps = {
    min: 0,
    max: 100,
    step: 1,
    prefix: 'zent',
    disabled: false,
    withInput: true,
    range: false,
    value: 0,
  };

  constructor(props) {
    super(props);
  }

  onChange = value => {
    const { range, onChange } = this.props;
    value = range
      ? value.map(v => Number(v)).sort((a, b) => a - b)
      : Number(value);
    onChange && onChange(value);
  };

  render() {
    const { withInput, className, width, ...restProps } = this.props;
    const wrapClass = classNames(
      `${restProps.prefix}-slider`,
      { [`${restProps.prefix}-slider-disabled`]: restProps.disabled },
      className
    );
    return (
      <div className={wrapClass} style={getWidth(width)}>
        <Range {...restProps} onChange={this.onChange} />
        {withInput &&
           (
            <InputField onChange={this.onChange} {...restProps} />
          )}
      </div>
    );
  }
}

主要逻辑和上文讲的一样,组件主要构成是一个 Range 和 一个 Input,我们主要看下 Range 的实现

export default class Range extends (PureComponent || Component) {
  clientWidth = null;

  getClientWidth = () => {
    if (this.clientWidth === null) {
      this.handleResize();
    }
    return this.clientWidth;
  };

  handleResize = () => {
    const $root = ReactDOM.findDOMNode(this);
    this.clientWidth = $root.clientWidth;
  };

  render() {
    const { value, ...restProps } = this.props;
    const warpClass = cx(`${restProps.prefix}-slider-main`, {
      [`${restProps.prefix}-slider-main-with-marks`]: marks,
    });
    return (
      <div className={warpClass}>
        <Container
          getClientWidth={this.getClientWidth}
          {...restProps}
          value={value}
        >
          <Track {...restProps} value={value} />
        </Container>
        <Point
          dots={dots}
          marks={marks}
          getClientWidth={this.getClientWidth}
          {...restProps}
          value={value}
        />
        <WindowEventHandler eventName="resize" callback={this.handleResize} />
      </div>
    );
  }
}

Range 组件里的 Point 就是滑动条鼠标可以拖动的小点, container 是滑动条主要部分,传进去的 track组件,是滑动条的有效部分。这里有一个WindowEventHandler 组件,这个组件的目的是在组件mount的时候给 window 绑定了一个 {eventName} 事件,然后unmount的时候停止监听 {eventName} 事件。更加优雅的实现在这篇文章中有介绍,如何在react组件中监听事件

我们看下 Container 组件内部实现, 其实很简单,只需要做两件事
1.处理点击滑动条事件
2.渲染 Track 组件

export default class Container extends (PureComponent || Component) {
  handleClick = e => {
    const {
      getClientWidth,
      dots,
      range,
      value,
      onChange,
      max,
      min,
      step,
    } = this.props;
    let newValue;

     let pointValue =
        (e.clientX - e.currentTarget.getBoundingClientRect().left) /
        getClientWidth();
      pointValue = getValue(pointValue, max, min);
      pointValue = toFixed(pointValue, step);
      newValue = pointValue;
      if (range) {
        newValue = getClosest(value, pointValue);
      }
      onChange && onChange(newValue);
    
  };

  render() {
    const { disabled, prefix } = this.props;
    return (
      <div
        className={`${prefix}-slider-container`}
        onClick={!disabled ? this.handleClick : noop}
      >
        {this.props.children}
      </div>
    );
  }
}

Track 组件也很简单,其实就是根据传入的参数,计算出 left 值和有效滑动条长度

export default class Track extends (PureComponent || Component) {
  getLeft = () => {
    const { range, value, max, min } = this.props;
    return range ? getLeft(value[0], max, min) : 0;
  };

  getWidth = () => {
    const { max, min, range, value } = this.props;
    return range
      ? (value[1] - value[0]) * 100 / (max - min)
      : getLeft(value, max, min);
  };

  render() {
    const { disabled, prefix } = this.props;
    return (
      <div
        style={{ width: `${this.getWidth()}%`, left: `${this.getLeft()}%` }}
        className={calssNames(
          { [`${prefix}-slider-track-disabled`]: disabled },
          `${prefix}-slider-track`
        )}
      />
    );
  }
}

Point 组件则着重处理了拖动状态的变化,以及拖动边界的处理, 代码比较简单易读

export default class Points extends (PureComponent || Component) {
  constructor(props) {
    super(props);
    const { range, value } = props;
    this.state = {
      visibility: false,
      conf: range ? { start: value[0], end: value[1] } : { simple: value },
    };
  }

  getLeft = point => {
    const { max, min } = this.props;
    return getLeft(point, max, min);
  };

  isLeftButton = e => {
    e = e || window.event;
    const btnCode = e.button;
    return btnCode === 0;
  };

  handleMouseDown = (type, evt) => {
    evt.preventDefault();
    if (this.isLeftButton(evt)) {
      this.left = evt.clientX;
      this.setState({ type, visibility: true });
      let { value } = this.props;

      if (type === 'start') {
        value = value[0];
      } else if (type === 'end') {
        value = value[1];
      }
      this.value = value;
      return false;
    }
  };

  getAbsMinInArray = (array, point) => {
    const abs = array.map(item => Math.abs(point - item));
    let lowest = 0;
    for (let i = 1; i < abs.length; i++) {
      if (abs[i] < abs[lowest]) {
        lowest = i;
      }
    }
    return array[lowest];
  };

  left = null;

  handleMouseMove = evt => {
    const left = this.left;
    if (left !== null) {
      evt.preventDefault();
      const { type } = this.state;
      const {
        max,
        min,
        onChange,
        getClientWidth,
        step,
        dots,
        marks,
        range,
      } = this.props;
      let newValue = (evt.clientX - left) / getClientWidth();
      newValue = (max - min) * newValue;
      newValue = Number(this.value) + Number(newValue);
      if (dots) {
        newValue = this.getAbsMinInArray(keys(marks), newValue);
      } else {
        newValue = Math.round(newValue / step) * step;
      }
      newValue = toFixed(newValue, step);
      newValue = checkValueInRange(newValue, max, min);
      let { conf } = this.state;
      conf[type] = newValue;
      this.setState({ conf });
      onChange && onChange(range ? [conf.start, conf.end] : newValue);
    }
  };

  handleMouseUp = () => {
    this.left = null;
    this.setState({ visibility: false });
  };

  componentWillReceiveProps(props) {
    const { range, value } = props;
    if (this.left === null) {
      this.setState({
        conf: range ? { start: value[0], end: value[1] } : { simple: value },
      });
    }
  }

  render() {
    const { visibility, type, conf } = this.state;
    const { disabled, prefix } = this.props;
    return (
      <div className={`${prefix}-slider-points`}>
        {map(conf, (value, index) => (
          <ToolTips
            prefix={prefix}
            key={index}
            content={value}
            visibility={index === type && visibility}
            left={this.getLeft(value)}
          >
            <span
              onMouseDown={
                !disabled ? this.handleMouseDown.bind(this, index) : noop
              }
              className={classNames(
                { [`${prefix}-slider-point-disabled`]: disabled },
                `${prefix}-slider-point`
              )}
            />
          </ToolTips>
        ))}
        {!disabled && (
          <WindowEventHandler
            eventName="mousemove"
            callback={this.handleMouseMove}
          />
        )}
        {!disabled && (
          <WindowEventHandler
            eventName="mouseup"
            callback={this.handleMouseUp}
          />
        )}
      </div>
    );
  }
}

结语

以上代码采样自 zent,从组件的设计可以看出,组件的设计采用了单一指责原则,把一个滑动条拆分为 Range 和 Input,Range 有拆分为 Point、 Container、 Track 三个子组件,每个组件互不干扰,做自己组件的事情,状态都在组件内部维护,状态改变统一触发根组件 onchange 事件通过 props 改变其他受影响的组件,例如点击 Container 改变了value的同时触发了 onchange 改变了 Points 的 left 值,一切井然有序。这值得我们在项目中写业务组件时借鉴。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值