极简方案实现一个 Sortable 列表排序组件

1,149 阅读1分钟

拖拽排序是一个业务开发过程中很常见的需求,而仅仅为了实现 pc 上纵向列表拖拽排序,引入一个庞大的拖拽组件库,了解一堆看起来就很复杂的 api ,不禁让人生出一种杀鸡焉用牛刀的怀疑…

简单点的方法有没有?有~

组件接口设计

Sortable 将是一个受控的排序组件,调用方提供 data、handleChange 和单项 render 方法:

  const initialData = Array.from({ length: 20 }).map((_, index) => ({
    id: index,
    text: `item ${index}`
  }));
  const [data, setData] = useState(initialData);

  const handleChange = value => {
    setData(value);
  };

<Sortable
    data={data}
    handleChange={handleChange}
    render={item => (
      <div>
        <a href="" style={{ color: "#333", textDecoration: "none" }}>
          {item.text}
        </a>
      </div>
    )}
  />

组件实现

初始状态设计: dragging 表示拖动状态,draggingIndex 表示当前拖动的 item 在列表中的 index ,startPageY 是开始拖动时的位置,

state = { dragging: false, draggingIndex: -1, startPageY: 0 };

Map data,mouseDown 绑定在每个 item 上:

<div className=“sortable-list”>
{this.props.data.map((item, i) => (
    <div
      className="sortable-item"
      key={i}
      onMouseDown={event => this.handleMounseDown(event, i)}
    >
      {this.props.render(item)}
    </div>
  ))}
</div>

onMouseDown 时,记录初始状态:

handleMounseDown = (event, index) => {
    this.setState({
      dragging: true,
      startPageY: event.pageY,
      draggingIndex: index
    });
  };

很明显,接下来我需要监听 onMouseMove 和 mouseUp 事件,判断我拖动的条目拖动的方向,以及停下的位置。一个重要的问题来了,onMouseMove 和 mouseUp 应该绑定在什么地方?document 吗? 整个 List 吗?

我在 list 上方加了一个透明的 mask ,当有拖拽行为的时候把 mask 渲染出来,这样做的好处是,拖动时鼠标不会响应其它区域的元素 :

<div className=“sortable-list”>….</div>
{this.state.dragging && (
  <div
    className="sortable-mask"
    onMouseMove={this.handleMouseMove}
    onMouseUp={this.handleMouseUp}
  />
)}

onMouseMove 需要判断当前拖动的方向,及在这个方向上的拖动位移。当拖动「一定距离」时,交换相邻的两个 item ,为了让拖动体验更流畅,我把「一定距离」规定为 item 行高的一半。为了计算行高,我给 item 加了个 ref:

draggingItemRef = React.createRef();
…
{this.props.data.map((item, i) => (
    <div
      ref={i === this.state.draggingIndex ? this.draggingItemRef : null}
      className="sortable-item"
      key={i}
      onMouseDown={evt => this.handleMounseDown(evt, i)}
    >
      {this.props.render(item)}
    </div>
))}


handleMouseMove = evt => {
    const lineHeight = this.draggingItemRef.current.getBoundingClientRect()
      .height;

    let offset = evt.pageY - this.state.startPageY;
    const draggingIndex = this.state.draggingIndex;
    if (offset > lineHeight / 2 && draggingIndex < this.props.data.length - 1) {
      // move down
      this.setState({
        draggingIndex: draggingIndex + 1,
        startPageY: this.state.startPageY + lineHeight
      });
      this.props.handleChange(
        move(this.props.data, draggingIndex, draggingIndex + 1)
      );
    } else if (offset < -lineHeight / 2 && draggingIndex > 0) {
      // move up
      this.setState({
        draggingIndex: draggingIndex - 1,
        startPageY: this.state.startPageY - lineHeight
      });
      this.props.handleChange(
        move(this.props.data, draggingIndex, draggingIndex - 1)
      );
    }
};

mouseUp 就简单了,还原 state 到拖拽前就好了:

handleMouseUp = () => {
    this.setState({ dragging: false, startPageY: 0, draggingIndex: -1 });
};

看看效果:

被拖拽的 item 应该有个位移,或者透明等和其它 item 不同的样式,我用 transform 来实现,那么需要新增一个 state —— offsetPageY, mouseMove 时也应该及时更新 offsetPageY:

state = {
    dragging: false,
    draggingIndex: -1,
    startPageY: 0,
    offsetPageY: 0
};

handleMouseMove = evt => {
    const lineHeight = this.draggingItemRef.current.getBoundingClientRect()
      .height;

    let offset = evt.pageY - this.state.startPageY;
    const draggingIndex = this.state.draggingIndex;
    if (offset > lineHeight / 2 && draggingIndex < this.props.data.length - 1) {
      // move down
      offset -= lineHeight;
      this.setState({
        draggingIndex: draggingIndex + 1,
        startPageY: this.state.startPageY + lineHeight
      });
      this.props.handleChange(
        move(this.props.data, draggingIndex, draggingIndex + 1)
      );
    } else if (offset < -lineHeight / 2 && draggingIndex > 0) {
      // move up
      offset += lineHeight;
      this.setState({
        draggingIndex: draggingIndex - 1,
        startPageY: this.state.startPageY - lineHeight
      });
      this.props.handleChange(
        move(this.props.data, draggingIndex, draggingIndex - 1)
      );
    }
    this.setState({ offsetPageY: offset });
};

渲染时获取样式:

getDraggingStyle(index) {
    if (index !== this.state.draggingIndex) return {};
    return {
      backgroundColor: "#eee",
      transform: `translate(20px, ${this.state.offsetPageY}px)`,
      opacity: 0.5
    };
}

{this.props.data.map((item, i) => (
    <div
      ref={i === this.state.draggingIndex ? this.draggingItemRef : null}
      className="sortable-item"
      key={i}
      onMouseDown={evt => this.handleMounseDown(evt, i)}
      style={this.getDraggingStyle(i)}
    >
      {this.props.render(item)}
    </div>
))}

再看看效果:

移动端

mouseDown -> touchStart ; mouseMove -> touchMove; mouseUp -> touchEnd ,同时 evt 也需要改为 evt.touches[0]

完整代码:simpleSortable - CodeSandbox