popup位置自适应组件的实现思路与实践
需求分析:
组件可设置弹出位置(placement),支持top、bottom
气泡弹窗弹出位置计算,边界计算,支持设置边界范围
支持点击弹窗元素之外的区域,弹窗关闭
支持弹窗内容自定义
效果预览:
实现方案
实现自定义组件, 我们一般都会想到Vue.extend
,vue.extend
相当于一个扩展实例构造器,用于创建一个具有初始化选项的Vue子类,在实例化时可以进行扩展选项,最后使用$mount方法绑定在元素上。
先写一个简单的demo
编写index.vue文件
<template> <div v-if="visible"> <div ref="arrowDom" :style="{left: `${arrowLeft}px`}"></div> <div> <div>{{ txt }} </div> </div> </div> </template> <script> export default { name: 'Popover', props: { txt: { type: String, default: '' } }, data: () => ({ arrowLeft: 51, visible: false, positionStyle: { left: '15px', top: '69.5781px' } }), methods: { open() { this.visible = !this.visible; } } }; </script> <style> .popover { position: absolute; } </style> 复制代码
注意:目前组件接收一个txt参数,且弹窗的位置信息有css固定,js只是提供了显示逻辑。气泡弹窗针对body元素绝对定位
编写index.js。 点击按钮时,动态追加弹窗元素到body上
// index.js import Vue from 'vue'; // 导入刚才我们写的index.vue import DialogCompt from './index.vue'; let component; // 保证只存在一个组件实例 const createComponents = function() { if (!component) { const DialogConstructor = Vue.extend(DialogCompt); component = new DialogConstructor({ el: document.createElement('div') }); } return component; }; const preview = (options) => { component = createComponents(); document.body.appendChild(component.$el); component.txt = options.txt; component.open(); }; export default preview; 复制代码
项目中使用
import preview from './index'; methods: { // 组件调用 peview({ txt: '我是文字文字文字文字文字文字文字文字' }); } 复制代码
经过上面三个步骤,一个简单的弹窗就出来啦,效果预览:
处理相关的点击交互
点击弹窗本身,弹窗不消失
可以判断当前点击的对象是否在弹窗范围内,通过Node.contains这个方法
点击弹窗之外的元素,弹窗消失
一般做法就是在弹窗显示之后,给document添加一个click事件,现在我们来修改上面的index.vue
<script> methods: { documentEventHandler() { // 如果点击弹窗自身时,不触发隐藏逻辑 if (!this.$el.contains(evt.target)) { this.close(); } }, close() { this.visible = false; // 弹窗关闭后记得移除click事件 document.removeEventListener('click', this.documentEventHandler); }, open() { // ... this.$nextTick(() => { // 弹窗显示后,给document对象注册click事件 document.addEventListener('click', this.documentEventHandler); }); // ... } } </script> 复制代码
效果预览:
position计算
获取目标元素相关信息
这个需要在调用组件对象的时候,把点击对象传组件内部
showPop(event) { // 组件调用,把event传到组件内部,方便组件内部获取目标元素各种尺寸信息 review({ event, txt1: '我是文字文字文字文字文字文字文字文字' }); } 复制代码
获取点击对象的相关尺寸信息
通过传入的event对象,我们可以拿到当前的currentTarget: event.currentTarget; 然后使用Element.getBoundingClientRect()
这个方法获取currentTarget的left、right、top、bottom信息
// index.js const preview = (options) => { component = createComponents(); const event = options.event; event.stopPropagation(); const currentTarget = event.currentTarget; const { left, right, bottom, top } = currentTarget.getBoundingClientRect(); component.targetPosition = { left, right, bottom, top }; component.placement = options.placement; // other code }; 复制代码
计算弹窗的left、top值
根据上图标注,获取弹窗左上角的left、top值,思路如下:
1.获取目标元素距离四边距屏幕左、右、上、下侧的边距
2.按照弹窗水平对齐目标元素的思路,计算出left值,这里需要考虑左右边界问题
3.根据弹窗自身的高度、箭头高度、间隙高度及目标元素上边距距离屏幕顶部、目标元素下边距距离屏幕顶部的高度来计算弹窗的top值,这里也需要考虑上下边界问题
根据上面的思路,我们可以这么干: left = 目标元素距离屏幕右侧的距离 + 目标元素宽度 / 2 - 弹窗自身宽度 / 2
弹窗top值也是同样的道理: 显示在元素上方: top = 目标元素上边距距离屏幕顶部的距离 - 弹窗自身宽度 - 箭头高度 - 偏移量
显示在元素下方: top = 目标元素下边距距离屏幕顶部的距离 + 箭头高度 + 偏移量 下面我们来修改下index.vue,
props: { targetPosition: { type: Object, default: () => ({}) }, // 弹窗显示的位置, 默认现在是触发元素底部 placement: { type: String, default: 'bottom' }, // 弹窗距离屏幕左侧的最小距离 minLeft: { type: Number, default: 30 }, // 弹窗距离屏幕右侧的最小距离 minRight: { type: Number, default: 30 }, // 弹窗距离触发元素的间距, 默认8px offset: { type: Number, default: 8 } }, data: () => ({ arrowLeft: 0, positionStyle: { left: '-100%', top: '0' } }), method: { open() { // ... this.$nextTick(() => { // 开始计算弹窗显示的位置 this.calcPosition(); }); // ... }, calcPosition() { // 拿到弹窗的宽、高信息 const { width, height } = this.$el.getBoundingClientRect(); const targetWidth = this.targetPosition.right - this.targetPosition.left; // 计算弹窗距屏幕左边距的位移 let popLeft = this.targetPosition.left + targetWidth / 2 - width / 2; // 计算弹窗距屏幕右边距的最小位移 const maxLeft = document.body.clientWidth - width - this.minRight * window.rem / 72; if (popLeft < this.minLeft * window.rem / 72) { popLeft = this.minLeft * window.rem / 72; } else if (popLeft > maxLeft) { popLeft = maxLeft; } const arrowWidth = this.$refs.arrowDom.getBoundingClientRect().width; const arrowHeight = this.$refs.arrowDom.getBoundingClientRect().height; let popTop; if (this.placement === 'bottom') { // 显示在按钮底部 popTop = this.targetPosition.bottom + this.offset * window.rem / 72 + arrowHeight; } else { // 显示在按钮顶部 popTop = this.targetPosition.top - height - arrowHeight - this.offset * window.rem / 72; } // 箭头是针对弹窗定位的, 所以记得以弹窗的左上角或者右下角来计算位移 if (popLeft === this.minLeft * window.rem / 72) { this.arrowLeft = this.targetPosition.left - this.minLeft * window.rem / 72 + targetWidth / 2 - arrowWidth / 2; } else if (popLeft === maxLeft) { this.arrowLeft = this.targetPosition.left + targetWidth / 2 - popLeft - arrowWidth / 2; } else { this.arrowLeft = (popLeft + width - popLeft) / 2 - arrowWidth / 2; } this.positionStyle = { left: `${popLeft}px`, top: `${popTop}px` }; } } 复制代码
测试的时候发现,如果页面内容超出了一屏,页面发生滚动之后,点击按钮,弹窗的定位会出现偏移。这是为什么呢??? 噢!!!原来我们之前只是计算了点击元素距离视口顶部的距离,但是弹窗是针对整个body定位的, 所以弹窗的真实top值还需要把页面滚动的距离算上。 document.documentElement.scrollTop
就是我们要计算的,记得做下兼容,可参考。 那么此时元素的距离body顶部的距离应该是: 元素距离视口顶部的高度 + 容器滚动的高度
// index.js const scrollTop = document.documentElement.scrollTop || document.body.scrollTop; this.positionStyle = { left: `${popLeft}px`, top: `${popTop + scrollTop}px` }; 复制代码
现在滚动页面,点击按钮,预览弹窗位置显示正常啦。
编写支持自定义内容弹窗组件
上面的实现方式,只能实现固定模板内容,如果后期遇到其他气泡弹窗,但是内容不同的时候,这个组件就得修改才能用,所以还有一种实现方式:采用具名插槽
来实现popover框架,template、css部分可由调用者自由定义。 那么理想情况下,用户可以这么调用(假如我们的组件是:im-popover):
<im-popover v-model="visible"> <div><!--弹窗内容--></div> <button slot="trigger">触发元素</button> </im-popover> 复制代码
弹窗内容是默认插槽,按钮元素需要声明slot="trigger"
首先实现一个最基本的,我们点击trigger元素显示这个弹窗内容
<!--ImPopover.vue--> <template> <div @click="handleClick"> <!--弹窗主体部分--> <div v-show="visible" ref="popoverDom" :style="positionStyle"> <div ref="arrowDom" :style="{left: `${arrowLeft}px`}" ></div> <!--可自定义内容,默认插槽--> <slot></slot> </div> <!--触发弹窗元素--> <slot name="trigger"></slot> </div> </template> <style> .im-popover { z-index: 1000; position: absolute; max-width: 660px; &__arrow { // ... } &__box { /* 这里需要设置成行内元素 */ display: inline-block; } } </style> 复制代码
编写对应的script脚本
其实就是之前的index.js和index.vue的结合,不过需要把this.$el
改成this.$refs.popoverDom
。
<script> export default { name: 'ImPopover', props: { // ... }, methods: { open() { if (!this.visible) { // 把popover组件追加到body尾部 this.appendContainer(); // ... }) } else { this.close(); } }, appendContainer() { document.body.appendChild(this.$refs.popoverDom); }, handleClick(event) { // 阻止冒泡 event.stopPropagation(); this.open(); }, // ... } } </script> 复制代码
im-popover组件调用
<template> <im-popover placement="top"> <div> <img src="https://static-web.likeevideo.com/as/indigo-static/test/diamond.png" alt=""> <p>原始文字是人类用来紀錄特定事物、簡化圖像而成的書寫符號。 文字在发展早期都是图画形式的,有些是以形表意</p> </div> <div slot="trigger" class="app__button app__button--small">自定义弹窗内容</div> </im-popover> </template> <script> import ImPopover from './ImPopOver'; export default { name: 'App', components: { ImPopover } } </script> <style> .popover-content { width: 468px; padding: 12px; box-sizing: border-box; display: flex; align-items: center; flex-direction: column; .image { width: 148px; height: auto; vertical-align: top; } .txt { margin-top: 20px; font-size: 28px; color: #fff; text-align: center; } } </style> 复制代码
效果预览
总结
好了,组件的两种实现方法都讲完了,其实popover组件涉及的功能比较多,比如有弹窗触发方式的配置(例如click、hover)、弹窗内容是否支持滚动和点击、弹窗的显示与隐藏回调、弹窗动画等等功能。这个等到以后组件需要拓展时再考虑吧。 如果你看到这篇文章,希望对你了解popover组件开发原理有所帮助。
欢迎大家留言讨论,祝工作顺利、生活愉快!
我是bigo前端,下期见。
作者:bigo前端
链接:https://juejin.cn/post/7033985356525469709