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

先来看看最终效果

Tab-1642148416551.gif

代码地址:vue 2.x | vue 3.x

预览地址:example

灵感来自 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.timer && clearTimeout(this.timer);
        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);
    }
}
</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也是有生命周期的,所以设置完坐标的之前不可以有过渡动画,不然会从中间开始移动到点击位置,所以设置完坐标并且在生命周期结束之后再设置过渡动画即可。

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

与 Vue 3.x 差异处理

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

el.remove();
document.body.appendChild(el);
复制代码

直接操作会使插槽的节点会失去响应式并且报错,所以需要在组件外部包一个<teleport>标签,指定插入到某个位置,但是这样写就比较冗余,相同的代码要写两遍,像这样:

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

没有 vue2 那样来得直接干脆,后面我试着用jsx的方式来绕开模板的写法,发现<transition>jsx中表现的行为和模板标签的行为不一致,具体看代码注释dialog.tsx。所以目前 vue3 还是采用单文件模板的写法(就是不够 vue2 优雅)。

猜你喜欢

转载自juejin.im/post/7054088327376404488