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

2

前言

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

组件分析

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

clipboard.png

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

参数 说明 是否必填
value 输入值
onChange change事件
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 值,一切井然有序。这值得我们在项目中写业务组件时借鉴。

你可能感兴趣的

载入中...