一个跟随鼠标点击行为的 Dialog 弹出框

6,336 阅读6分钟

灵感来自 Ant-Design-Ui ,个人觉得这种操作更加贴合交互使用,所以效仿这个交互方式自行封装了一个弹框组件。

这里我优先使用vue 2.x的写法去讲解,涉及到与vue 3.x不同代码实现的地方会做说明。因为逻辑是一样的,所以可以把这个操作交互用原生的功能封装上,代码地址在最后放上。

基本的弹框功能

先来看下一个基本Dialog功能的排版布局

<template>
    <div class="dialog" title="弹框整体-也是遮罩层">
        <div class="dialog-content" title="白底弹框盒子">
            <header title="内容头部">
                <h2 title="动态传进来的标题"></h2>
                <button title="关闭按钮"></button>
            </header>
            <div class="dialog-body" title="插槽占用区域-弹框内容主要区域">
                <slot></slot>
            </div>
            <footer title="弹框底部区域">
                <slot name="footer"></slot>
            </footer>
        <div>
    </div>
</template>
<style>
.dialog {
    display: flex;
    align-items: center; // 让中间内容部分垂直居中
    justify-content: center; // 让中间内容部分水平居中
    width: 100%;
    height: 100vh;
    position: fixed;
    top: 0;
    left: 0;
    background-color: rgba(0,0,0,0.5);
}
.dialog-content {
    border-radius: 2px; 
    box-shadow: 0px 1px 5px 0px rgba(0, 0, 0, 0.2), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.12); 
    background-color: #fff;
    overflow: hidden;
    display: flex;
    flex-direction: column; // 垂直排版
    max-height: 90vh; // 并设置最大高度
}
.dialog-body {
    flex: 1; // 自动弹性高度
    overflow: auto; // 超过限制的高度就出现滚动
}
</style>

基本布局好了,开始做一些动态的操作:比如说弹框出来的时候整体是渐隐的,那么可以在整体标签外部包一个<transition>标签,这样切换显示隐藏就有渐隐效果了;然后再加上个点击关闭事件操作,像这样:

<template>
    <transition name="fade">
        <div class="dialog" v-show="value" title="弹框整体-也是遮罩层" @click="onClose">
            <div class="dialog-content" title="白底弹框盒子">
                <header title="内容头部">
                    <h2 title="动态传进来的标题"></h2>
                    <button @click="onClose" ref="close-btn" title="关闭按钮"></button>
                </header>
                ...省略
            <div>
        </div>
    </transition>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";

/** 基础弹出框组件 */
@Component({
    name: "base-dialog"
})
export default class BaseDialog extends Vue {
    /** 双向绑定显示隐藏值 */
    @Prop({
        type: Boolean,
        default: false
    })
    value!: boolean;
    
    /** 是否可以通过点击遮罩层关闭`Dialog` */
    @Prop({
        type: Boolean,
        default: true
    })
    closeByMask!: boolean
    
    /** `Dialog`自身是否插入至`body`元素上。嵌套的`Dialog`必须指定该属性并赋值为`true` */
    @Prop({
        type: Boolean,
        default: false
    })
    appendToBody!: boolean;

    $refs!: {
        "close-btn": HTMLElement
    }
    
    onClose(e: MouseEvent) {
        // 上面不用 @click.stop 的原因是:因为出现嵌套的情况下,事件阻止冒泡时,
        // 会导致向上传递的事件都停止捕获,所以这里直接判断是否为遮罩层或者关闭按钮即可
        if ((e && e.target === this.$el && this.closeByMask) || (e && e.target === this.$refs["close-btn"])) {
            this.$emit("input", false);
            this.$emit("close");
        }
    }
    
    mounted() {
        if (this.appendToBody) {
            // 节点初始化之后移动至<body>处
            this.$el.remove();
            document.body.appendChild(this.$el);
        }
    }

    beforeDestroy() {
        this.appendToBody && this.$el.remove(); // 插入至body处的节点要单独移除
    }
}
</script>
<style>
...省略重复代码

.fade-enter-active, .fade-leave-active {
    transition: all .3s;
}
.fade-enter, .fade-leave-active {
    opacity: 0;
}
</style>

这样就实现组件的渐隐过渡效果了,一些props的基本配置,例如:widthtitle等就不展开说了,根据场景设置即可。然后再来看下现阶段效果:

Tab-1642386582186.gif

处理定位层级问题

都知道弹出层可能有多个,不管是不是处于同一个节点层级,都是按代码书写顺序的来排列组件显示的层级;所以得处理这个定位层级的问题。个人理解十分简单,这个层级可以设为一个共用的唯一变量,每个组件调用时或者实例化的时候都累加,这样下个调用的组件就会处于上一个的位置,直接看代码:

<template>
    <transition name="fade">
        <div class="dialog" v-show="value" @click="onClose" :style="{ 'z-index': zIndex }">
            ...省略
        </div>
    </transition>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";

/** 全局定位层级,每使用一个组件累加一次 */
let zIndex = 1000;

/** 基础弹出框组件 */
@Component({
    name: "base-dialog"
})
export default class BaseDialog extends Vue {
    zIndex = zIndex;
    
    created() {
        zIndex++;
    }
}

交互动画实现

  1. 上面步骤已经实现了基本的一个弹框功能操作,接下来要处理的就是中间content部分的动画;这里先说一下动画的过程:

因为动画的轨迹是从scale(0) translate3d(动态X, 动态Y, 0)scale(1) translate3d(0, 0, 0)(这里使用translate3d可以触发GPU硬件加速,动画更流畅),所以可以先定义一个过渡结束的css-class

.opened {
    transform: translate3d(0,0,0) scale(1) !important;
}

然后再通过js动态设置开始时的左右平移位置,最后再给content这个节点加上.opened即可。

  1. js处理点击坐标的逻辑,首先要获取到点击的位置,可以通过document.addEventListener("click", fn)来处理,fn(ev: MouseEvent)中的ev.pageYev.pageX就是当前鼠标点击的屏幕相对位置。获取到鼠标位置后,再换算一下中心点,也就是content的平移位置,看代码:
<template>
    <transition name="fade">
        <div class="dialog" v-show="value" @click="onClose" :style="{ 'z-index': zIndex }">
            <div
                :class="['dialog-content', { 'moving': contentMove }, { 'opened': contentShow }]"
                :style="{ 'transform': `translate3d(${contentX}, ${contentY}, 0) scale(0)` }"
            >
                ...省略
            <div>
        </div>
    </transition>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";

const isFirefox = navigator.userAgent.toLocaleLowerCase().indexOf("firefox") > 0;

@Component({
    name: "base-dialog"
})
export default class BaseDialog extends Vue {
    
    ...省略重复代码
    
    /** 内容盒子`x`轴偏移位置 */
    private contentX = "0";

    /** 内容盒子`y`轴偏移位置 */
    private contentY = "0";
    
    /** 因为需要动态设置偏移位置,所以设置完位置之后单独控制该节点切换动画 */
    private contentShow = false;

    /** 内容盒子过渡动画 */
    private contentMove = false;
    
    @Watch("value")
    onValue(val: boolean) {
        this.timer && clearTimeout(this.timer);
        if (!val) {
            this.contentShow = false;
        }
    }

    private timer!: NodeJS.Timeout;
    
    /**  
     * 设置内容区域位置
     * @param e 鼠标事件
     */
    private setContentPosition(e: MouseEvent) {
        // 只有在外部点击,且关闭的情况下才会记录坐标
        if (!this.value || this.contentShow || this.$el.contains(e.target as HTMLElement)) return;
        this.contentMove = false;
        const { clientWidth, clientHeight } = this.$el;
        const centerX = clientWidth / 2;
        const centerY = clientHeight / 2;
        const pageY = e.pageY - centerY;
        const pageX = e.pageX - centerX;
        this.contentX = `${pageX / clientWidth * 100}vw`;
        this.contentY = `${pageY / clientHeight * 100}vh`;
        // css3动画生命周期结束后再设置过渡动画
        this.timer = setTimeout(() => {
            this.contentMove = true;
            this.contentShow = true;
        }, isFirefox ? 100 : 0); // firefox上 有 bug,需要延迟 100 毫秒
    }
    
    mounted() {
        ...省略重复代码
        document.addEventListener("click", this.setContentPosition);
    }

    beforeDestroy() {
        ...省略重复代码
        document.removeEventListener("click", this.setContentPosition);
        this.timer && clearTimeout(this.timer);
    }
}
</script>
<style>
...省略重复代码

.dialog-content.opened {
    transform: translate3d(0,0,0) scale(1) !important;
}
.dialog-content.moving {
    transition: 0.3s all;
}
</style>

详解一下setContentPosition方法逻辑

偏移坐标的计算:因为点击事件的坐标是从屏幕左上角开始计算的,而我们布局为永远居中屏幕,所以屏幕中心的基础坐标值为当前铺满整个屏幕的遮罩,也就是this.$el的宽高除以一半;最后点击的坐标减去中心点即为偏移的位置,最后算出百分百比。这里我使用vw/vh作为最终单位的原因是可以随着屏幕动态变小变大不影响动画偏移位置。

document监听事件传播顺序setContentPosition方法里面if (!this.value...之所以加个反转是因为点击先触发了页面内部,然后再往上冒泡到document,所以里面应该是取反

延迟设置过渡动画样式:css也是有生命周期的,所以设置完坐标的之前不可以有过渡动画,不然会从中间开始移动到点击位置,所以设置完坐标并且在生命周期结束(setTimeout)之后再设置过渡动画即可。

到这里整个功能就完成了。

与 Vue 3.x 差异处理

vue3 中,是不能够直接将当前组件节点移动到某个位置去的,像这样:

el.remove();
document.body.appendChild(el);

直接操作会使插槽的节点会失去响应式并且报错,所以需要在组件外部包一个<teleport>标签,指定插入到某个位置,通过设置是否禁用来开启插入到body中即可,像这样:

    <teleport to="body" :disabled="!appendToBody">
        <transition name="fade">
            <div class="dialog" v-show="value" @click="onClose" :style="{ 'z-index': zIndex }">
                ...省略
            <div>
        </transition>
    </teleport>

注意!!! 使用<teleport>插入至body时,该组件是永远不会销毁的,所以得手动去判断,然后处理对应逻辑。

交互逻辑应用在原生功能封装上

代码地址

效果展示

dialog.gif

操作和上面一样,先通过全局绑定点击事件,然后记录下来,然后设置在元素的css变量中去,最后让元素激活动画并显示即可,总共就三步:

  1. 定义点击坐标记录操作,这里在addEventListener第三个参数一定要传true,不然记录的坐标是触发显示show()之后,顺序就反了。
/** 点击记录坐标 */
const clickSize = {
  x: "0vw",
  y: "0vh"
}
// 添加点击事件,并记录每次点击坐标
document.addEventListener("click", function(e) {
  const { innerWidth, innerHeight } = window;
  const centerX = innerWidth / 2;
  const centerY = innerHeight / 2;
  const pageY = e.clientY - centerY;
  const pageX = e.clientX - centerX;
  clickSize.x = `${pageX / innerWidth * 100}vw`;
  clickSize.y = `${pageY / innerHeight * 100}vh`;
}, true);
  1. 设置点击位置到元素中,show就是显示弹框的方法
/**
 * 输出节点
 * @param {object} option
 * @param {string=} option.title 弹框标题,传`""`则不显示标题,默认为`"提示"`(可传html)
 * @param {string} option.content 提示内容(可传html)
 * @param {() => void=} option.confirm 确认回调
 * @param {string=} option.confirmText 确认按钮文字,默认为`"确认"`
 * @param {() => void=} option.cancel 取消回调
 * @param {string=} option.cancelText 取消按钮文字,不传则没有取消操作
 */
function show(option) {
  const el = document.createElement("section");
  el.className = className.mask;
  el.style.zIndex = zIndex;
  zIndex++;
  // 设置起始偏移位置
  el.style.setProperty("--x", clickSize.x);
  el.style.setProperty("--y", clickSize.y);
  // 设置完之后还原坐标位置
  clickSize.x = "0vw";
  clickSize.y = "0vh";
  
  ...more
}
  1. 处理节点动画

共分为两种动画类型:出场过渡animation、离开过渡transition;变量的设置则对应vue中动画钩子。