原生JavaScript实现可滑动排序的Tab标签页

需求

最近做一个 Tab 标签页,要求支持拖拽滑动后重新排序的功能,是一个强交互的控件,最常见的场景是 Excel 表格下方的工作表标签页按钮,就是支持拖动排序的。

思路

首先,要实现拖动效果,我们在 Tab 上用pointerdownpointermovepointerup来监听鼠标或者触摸事件。

再者,拖动一个 Tab 标签时,实时更新这个 Tab 的横向位置,我们设定当前滑动 Tab 的中间位置为基准线,一旦这个基准线触碰到两边 Tab 的边线,就认为需要与两边的 Tab 切换位置了。

细节上,在点击 Tab 时,我们复制出当前 Tab 放到另一层和 Tab 容器同样位置的 DOM 里,作为拖动实时显示位置的元素,然后将源 Tab 隐藏掉但保持占位,这样整体 Tab 容器不会受用户交互的影响,会更清晰。而在实时拖动之前,我们还要提前将所有 Tab 的位置、宽度、边线位置等信息都记录下来,这样在实时拖动的时候,直接将基准线位置和已存储的 Tab 位置信息进行比对即能判断出是否需要交换位置。

代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Sortable Tabs</title>
    <style>
      * {
        margin: 0;
        padding: 0;
      }

      .container {
        display: flex;
        justify-content: center;
        align-items: center;

        height: 100vh;
      }

      .tabs {
        border: 1px solid #d3d3d3;
        user-select: none;
      }

      .tab {
        display: inline-block;
        border: 1px solid gray;
        padding: 10px 20px;

        touch-action: none;
      }

      .tab:hover {
        cursor: pointer;
      }

      .boxContainer {
        position: fixed;
        display: none;
      }

      .box {
        position: relative;
      }

      .active {
        background-color: #b2b2b2;
      }
    </style>
  </head>

  <body>
    <div class="container">
      <div class="tabs"><div class="tab active" style="width: 50px;">Tab1</div><div class="tab" style="width: 100px;">Tab2</div><div class="tab">Tab3</div><div class="tab">Tab4</div><div class="tab">Tab5</div></div>

      <div class="boxContainer"></div>
    </div>

    <script>
      let tabContainer = document.querySelector(".tabs");
      let tabs = document.querySelectorAll(".tab");
      let boxContainer = document.querySelector(".boxContainer");

      let tabsPos = [];
      let boxMiddle = 0;

      collecPos();

      Array.from(tabs).forEach((tab, i) => {
        let x = 0;

        let moveX;

        tab.ondragstart = () => false;

        tab.onpointerdown = (event) => {
          event.preventDefault(); // prevent selection start (browser action)

          x = event.clientX - tab.getBoundingClientRect().left;

          tab.setPointerCapture(event.pointerId);

          createMoveBox(tabContainer, tab);

          tab.onpointermove = (event) => {
            const moveXDiff = event.clientX - moveX;
            moveX = event.clientX;

            let newLeft =
              event.clientX - x - tabContainer.getBoundingClientRect().left;

            // if the pointer is out of slider => adjust left to be within the boundaries
            if (newLeft < 0) {
              newLeft = 0;
            }
            let rightEdge = tabContainer.offsetWidth - tab.offsetWidth;
            if (newLeft > rightEdge) {
              newLeft = rightEdge;
            }

            const box = boxContainer.querySelector(".tab");
            box.style.left = newLeft + "px";

            checkSwitch(tab, moveXDiff, box);
          };

          tab.onpointerup = (event) => {
            tab.onpointermove = null;
            tab.onpointerup = null;

            boxContainer.style.display = "none";

            tab.style.visibility = "visible";

            reOrderTabs();
          };
        };
      });

      function collecPos() {
        tabsPos = [];
        const listPos = Array.from(tabs).map((tab, i) => {
          const left = tab.getBoundingClientRect().left;

          return {
            pos: left,
            i: i,
            tabRect: tab.getBoundingClientRect(),
          };
        });

        listPos.sort((left, right) => left.pos - right.pos);

        listPos.reduce((left, right, i) => {
          const posInfos = {
            pos: right.pos,
            leftEle: {
              i: left.i,
              left: left.tabRect.left,
              width: left.tabRect.width,
              transformX: 0,
            },
            rightEle: {
              i: right.i,
              left: right.tabRect.left,
              width: right.tabRect.width,
              transformX: 0,
            },
          };
          tabsPos.push(posInfos);
          return right;
        });

      }

      function checkSwitch(tab, moveXDiff, box) {
        boxMiddle = box.getBoundingClientRect().left + box.offsetWidth / 2;

        // 右滑时
        if (moveXDiff > 0) {
          for (let index = tabsPos.length - 1; index >= 0; index--) {
            const posInfo = tabsPos[index];
            if (boxMiddle - 5 > posInfo.pos) {
              let tabLeft = tabs[posInfo.leftEle.i];
              let tabRight = tabs[posInfo.rightEle.i];

              const tabRectLeft = tab.getBoundingClientRect().left;
              const otherRectLeft =
                tabRectLeft === posInfo.leftEle.left
                  ? posInfo.rightEle.left
                  : posInfo.leftEle.left;

              // 必须当前tab在左边才支持切换
              if (tabRectLeft < otherRectLeft) {

                changeTransform(posInfo);
              }

              return;
            }
          }
        }

        // 左滑时
        else if (moveXDiff < 0) {
          for (let index = 0; index <= tabsPos.length - 1; index++) {
            const posInfo = tabsPos[index];
            if (boxMiddle + 5 < posInfo.pos) {
              let tabLeft = tabs[posInfo.leftEle.i];
              let tabRight = tabs[posInfo.rightEle.i];

              const tabRectLeft = tab.getBoundingClientRect().left;
              const otherRectLeft =
                tabRectLeft === posInfo.leftEle.left
                  ? posInfo.rightEle.left
                  : posInfo.leftEle.left;

              // 必须当前tab在右边才支持切换
              if (tabRectLeft > otherRectLeft) {

                changeTransform(posInfo);
              }

              return;
            }
          }
        }
      }

      function changeTransform(posInfo) {
        for (let index = 0; index < tabsPos.length; index++) {
          const element = tabsPos[index];

          let newLeftEle;
          let newRightEle;
          // 交换位置
          if (element.pos === posInfo.pos) {
            const pos = posInfo.leftEle.left + posInfo.rightEle.width;

            newLeftEle = {
              i: posInfo.rightEle.i,
              width: posInfo.rightEle.width,
              left: posInfo.leftEle.left,
              transformX: posInfo.rightEle.transformX - posInfo.leftEle.width,
            };

            newRightEle = {
              i: posInfo.leftEle.i,
              width: posInfo.leftEle.width,
              left: pos,
              transformX: posInfo.leftEle.transformX + posInfo.rightEle.width,
            };

            posInfo = {
              pos: pos,
              leftEle: newLeftEle,
              rightEle: newRightEle,
            };

            tabsPos[index] = Object.assign(element, posInfo);

            // 更新前一个位置tab
            if (index !== 0) {
              tabsPos[index - 1].rightEle = newLeftEle;
            }

            // 更新后一个位置tab
            if (index !== tabsPos.length - 1) {
              tabsPos[index + 1].leftEle = newRightEle;
            }

            // 修改位置
            tabs[
              posInfo.leftEle.i
            ].style.transform = `translateX(${posInfo.leftEle.transformX}px)`;
            tabs[
              posInfo.rightEle.i
            ].style.transform = `translateX(${posInfo.rightEle.transformX}px)`;

            return;
          }
        }
      }

      function createMoveBox(tabContainer, tab) {
        boxContainer.style.display = "block";
        const tabContainerRect = tabContainer.getBoundingClientRect();
        const tabRect = tab.getBoundingClientRect();
        boxContainer.style.left = tabContainerRect.left + "px";
        boxContainer.style.top = tabContainerRect.top + "px";
        boxContainer.style.width = tabContainerRect.width + "px";
        boxContainer.style.height = tabContainerRect.height + "px";

        setActive(tab);

        const tabClone = tab.cloneNode(true);
        // tabClone.style.width = tab.offsetWidth + 'px';
        tabClone.style.left = tabRect.left - tabContainerRect.left + "px";
        tabClone.classList.add("box");

        boxContainer.innerHTML = "";
        boxContainer.appendChild(tabClone);

        tab.style.visibility = "hidden";
      }

      function reOrderTabs() {
        tabsPos.forEach((tabPos, index) => {
          const tab = tabs[tabPos.leftEle.i];
          tab.style.transform = "translateX(0px)";
          tabContainer.appendChild(tab);

          if (index === tabsPos.length - 1) {
            const tabLast = tabs[tabPos.rightEle.i];
            tabLast.style.transform = "translateX(0px)";
            tabContainer.appendChild(tabLast);
          }
        });

        collecPos();
      }

      function setActive(tab) {
        tab.classList.add("active");
        tabs.forEach((tabEle) => {
          if (tabEle !== tab) {
            tabEle.classList.remove("active");
          }
        });
      }
    </script>
  </body>
</html>

总结

上面就是小编总结的支持滑动切换位置的 Tab 标签页案例,一般情况下没有问题,但是测试的时候发现,如果滑动太快,还是会出现 BUG,小编时间太赶,暂时没发现问题在哪,知道如何修复的请告知我,非常感谢。还有这个 DEMO 其实还不完善,最好还能支持 Tab 溢出的情况下也能支持滚动,这个有兴趣的朋友可以尝试下。

评论