实现vue面板分割Split组件

2,589 阅读1分钟

概述

面板分割组件,实际项目中很少用到,但是还是有些组件库提供了,例如vue-design,因为涉及到元素的拖拽,我感觉蛮有意思的,这里也按照vue-design这个组件的用法,按照自己的思路实现下这个组件。具体怎么用这个组件,大家可以看vue-design中的Split组件

最终效果

动画.gif

实现原理结构分析

  • 首先Split有水平拖拽和垂直拖拽两个方向,因此分为两个模式(horizontal和vertical),因此我们其中的内容我们可以定义四个插槽(left,right,top,bottom)
  • 其次需要掌握对远程js拖拽的使用和理解。可以参考我之前的写的文章原生js的拖拽
  • 其次需要对一些常用尺寸计算的了解,比如(clientWidht,offsetTop,scrollWidth)等。
  • 改变拖拽面板中的元素尺寸位置,本质是在拖拽中动态计算对应元素的定位距离,因此各个位置的插槽需要先进行定位,然后推拽中动态设置。

实现

目录结构

image.png

Split.vue

<template>
  <div class="g-split" ref="gSplit">
    <!-- 水平方向 -->
    <div class="horizontal" v-if="showHorizontal">
      <div class="left-panel position" :style="horizontalLeftPanel">
        <slot name="left"></slot>
      </div>
      <div
        class="horizontal-trigger-panel position"
        :style="horizontaltriggerPanel"
        ref="horizontalTriggerPanel"
      >
        <!-- 触发拖动的元素可以是默认的,当用户提供了,使用用户的 -->
        <slot name="trigger" v-if="$slots.trigger"></slot>
        <div class="trigger-content-default-wrap" v-else>
          <div class="trigger-content">
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
          </div>
        </div>
      </div>
      <div class="right-panel position" :style="horizontalRightPanel">
        <slot name="right"></slot>
      </div>
    </div>
    <!-- 垂直方向 -->
    <div class="vertical" v-if="showVertical">
      <div class="top-panel position" :style="verticalTopPanel">
        <slot name="top"></slot>
      </div>
      <div
        class="vertical-trigger-panel position"
        :style="verticaltriggerPanel"
        ref="verticalTriggerPanel"
      >
       <!-- 触发拖动的元素可以是默认的,当用户提供了,使用用户的 -->
        <slot name="trigger" v-if="$slots.trigger"></slot>
        <div class="trigger-content-default-wrap" v-else>
          <div class="trigger-content">
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
            <i class="trigger-bar"></i>
          </div>
        </div>
      </div>
      <div class="bottom-panel position" :style="verticalBottomPanel">
        <slot name="bottom"></slot>
      </div>
    </div>
  </div>
</template>

<script>
// 注意:页面偏移量单位统一使用百分比计算
export default {
  props: {
    // v-model绑定的值,初始的偏移量
    value: {
      type: [String, Number],
      default: 0.5,
    },
    // 上下面板还是左右面板,默认为上下格式
    mode: {
      type: String,
      validator(value) {
        return ["horizontal", "vertical"].includes(value);
      },
      default: "horizontal",
    },
    // 拖拽占盒子最小比例
    min: {
      type: [String, Number],
      default: 0.1,
    },
    // 拖拽占盒子最大比例
    max: {
      type: [String, Number],
      default: 0.9,
    },
  },
  data() {
    return {
      // 左侧偏移量,默认为初始最小值
      left: this.value * 1,
      // 上面偏移量,默认为初始最小值
      top: this.value * 1,
      // split面板的宽度
      gSplitWidth: 0,
      // solt面板的高度
      gSplitHeight: 0,
      // 水平拖拽部分的长条的宽度
      horizontalTriggerPanelWidht: 0,
      // 垂直拖拽部分的长条的高度
      verticalTriggerPanelHeight: 0,
    };
  },
  computed: {
    // 是否显示左右拖动面板
    showHorizontal() {
      return this.mode == "horizontal";
    },
    // 是否显示上下拖动面板
    showVertical() {
      return this.mode == "vertical";
    },
    // 左侧面板偏移量
    horizontalLeftPanel() {
      return {
        left: 0,
        right: (1 - this.left) * 100 + "%",
      };
    },
    // 右侧面板偏移量
    horizontalRightPanel() {
      // 主意:要加上中间的trigger内容区的宽度
      return {
        left:
          (this.left + this.horizontalTriggerPanelWidht / this.gSplitWidth) *
            100 +
          "%",
      };
    },
    // 左右面板中间trigger拖拽部分的偏移量
    horizontaltriggerPanel() {
      return {
        left: this.left * 100 + "%",
      };
    },

    // 上面面板偏移量
    verticalTopPanel() {
      return {
        top: 0,
        bottom: (1 - this.top) * 100 + "%",
      };
    },
    // 下面面板偏移量
    verticalBottomPanel() {
      return {
        top:
          (this.top + this.verticalTriggerPanelHeight / this.gSplitHeight) *
            100 +
          "%",
      };
    },
    // 上下面板中间trgger拖拽部分偏移量
    verticaltriggerPanel() {
      return {
        top: this.top * 100 + "%",
      };
    },
  },
  mounted() {
    this.bindEvent();
    this.initDom();
  },
  methods: {
    // 初始化部分dom元素的尺寸
    initDom() {
      this.gSplitWidth = this.$refs.gSplit.clientWidth;
      this.gSplitHeight = this.$refs.gSplit.clientHeight;
      this.mode == "horizontal"
        ? (this.horizontalTriggerPanelWidht =
            this.$refs.horizontalTriggerPanel.clientWidth)
        : "";
      this.mode == "vertical"
        ? (this.verticalTriggerPanelHeight =
            this.$refs.verticalTriggerPanel.clientHeight)
        : "";
    },
    bindEvent() {
      // 根据mode来决定绑定那种类型的事件
      this.mode == "horizontal" ? this.bindHorizontalTriggerPanelEvent() : null;
      this.mode == "vertical" ? this.bindVerticalTriggerPanelEvent() : null;
    },
    // 禁用页面文字选中函数
    preventSelectedOnMouseMove(e) {
      e.preventDefault();
    },
    // 水平拖拽处理函数
    bindHorizontalTriggerPanelEvent() {
      this.resolveMouseFn("horizontal", this.$refs.horizontalTriggerPanel);
    },
    // 处置垂直面板拖拽函数
    bindVerticalTriggerPanelEvent() {
      this.resolveMouseFn("vertical", this.$refs.verticalTriggerPanel);
    },
    // 处理拖拽逻辑,水平和垂直的逻辑合在一起
    resolveMouseFn(type = "horizontal", element) {
      const mousedown = (e) => {
        // 禁止页面文字的选中,避免在拖拽过成功出现文字被选中的行为
        document.addEventListener(
          "selectstart",
          this.preventSelectedOnMouseMove
        );
        // 发布开始拖拽事件
        this.$emit("on-move-start", e);
        // 获取鼠标点击的位置距离元素边缘的距离
        const pos = type == "horizontal" ? "left" : "top";
        const distance =
          type == "horizontal"
            ? e.clientX - element.offsetLeft
            : e.clientY - element.offsetTop;
        const mousemove = (e) => {
          // 发布开始拖拽中事件
          this.$emit("on-moving", e);
          const gSplitSize =
            type == "horizontal"
              ? this.$refs.gSplit.clientWidth
              : this.$refs.gSplit.clientHeight;
          this[pos] =
            (type == "horizontal"
              ? e.clientX - distance
              : e.clientY - distance) / gSplitSize;
          // 控制范围
          if (this[pos] < this.min) {
            this[pos] = this.min;
          }
          if (this[pos] > 1 - this.min) {
            this[pos] = 1 - this.min;
          }
          return false;
        };
        const mouseup = () => {
          // 发布开始拖拽结束事件
          this.$emit("on-move-end", e);
          // 释放按下和滑动处理函数以及禁用选中的处理函数
          document.removeEventListener("mousemove", mousemove);
          document.removeEventListener("mouseup", mouseup);
          document.removeEventListener(
            "selectstart",
            this.preventSelectedOnMouseMove
          );
          return false;
        };
        document.addEventListener("mousemove", mousemove);
        document.addEventListener("mouseup", mouseup);
        return false;
      };
      element.addEventListener("mousedown", mousedown);
    },
  },
};
</script>

<style lang="less">
.g-split {
  height: 100%;
  overflow: hidden;
  .position {
    position: absolute;
  }
  .horizontal {
    position: relative;
    height: 100%;
    .left-panel {
      height: 100%;
    }
    .right-panel {
      height: 100%;
    }
    .horizontal-trigger-panel {
      cursor: col-resize;
      height: 100%;
      .trigger-content-default-wrap {
        background-color: #f8f8f9;
        height: 100%;
        position: relative;
        width: 7px;
        .trigger-content {
          position: absolute;
          top: 50%;
          transform: translateY(-50%);
          .trigger-bar {
            width: 7px;
            height: 1px;
            display: block;
            background: rgba(23, 35, 61, 0.25);
            margin-top: 3px;
          }
        }
      }
    }
  }
  .vertical {
    position: relative;
    height: 100%;
    .top-panel {
      width: 100%;
    }
    .bottom-panel {
      width: 100%;
    }
    .vertical-trigger-panel {
      width: 100%;
      .trigger-content-default-wrap {
        width: 100%;
        position: relative;
        height: 7px;
        cursor: row-resize;
        background-color: #f8f8f9;
        .trigger-content {
          position: absolute;
          left: 50%;
          top: 0;
          transform: translateX(-50%);
          height: 100%;
          .trigger-bar {
            width: 1px;
            height: 100%;
            display: inline-block;
            background: rgba(23, 35, 61, 0.25);
            margin-left: 3px;
            vertical-align: top;
          }
        }
      }
    }
  }
}
</style>

index.js

import Split from "./Split";
export { Split };

使用

App.vue

<template>
  <div class="app">
    <div class="aline">
      <div class="item">
        <h2>水平拖拽</h2>
        <div class="demo-split">
          <Split v-model="split1">
            <div slot="left" class="demo-split-pane">Left Pane</div>
            <div slot="right" class="demo-split-pane">Right Pane</div>
          </Split>
        </div>
      </div>
      <div class="item">
        <h2>垂直拖拽</h2>
        <div class="demo-split">
          <Split v-model="split2" mode="vertical">
            <div slot="top" class="demo-split-pane">Top Pane</div>
            <div slot="bottom" class="demo-split-pane">
              Bottom Pane
            </div>
          </Split>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { Split } from "./components/Split/index";
export default {
  components: {
    Split,
  },
  data() {
    return {
      split1: 0.1,
      split2: 0.21,
    };
  },
};
</script>

<style lang="less">
* {
  margin: 0;
  padding: 0;
}
.app {
  padding: 20px;
  button {
    padding: 10px;
    background-color: #008c8c;
    color: #fff;
    margin: 20px 0;
  }
  .container {
    .operate {
      text-align: center;
    }
    .aline {
      width: 50%;
    }
    h2 {
      font-weight: bold;
      font-size: 20px;
    }
    .aline {
      &:nth-child(1) {
        margin-right: 20px;
      }
    }
    display: flex;
    justify-content: space-between;
  }
}
.aline {
  display: flex;
}
.demo-split {
  height: 200px;
  border: 1px solid #dcdee2;
  margin: 20px;
}
.item {
  width: 50%;
  h2 {
    font-weight: bold;
    text-align: center;
  }
}
.demo-split-pane {
  padding: 10px;
}
</style>