vue 實(shí)現(xiàn)微信浮標(biāo)效果
微信的浮窗,大伙應(yīng)該都用過,當(dāng)我們正在閱讀一篇公眾號(hào)文章時(shí),突然需要處理微信消息,點(diǎn)擊浮窗,在微信上會(huì)有個(gè)浮標(biāo),點(diǎn)擊浮標(biāo)可以再次回到文章。
我們今天打算擼一個(gè)類似微信的浮標(biāo)組件,我們期望組件有以下功能
- 支持拖拽
- 支持左右吸附
- 支持頁面上下滑動(dòng)時(shí)隱藏
效果預(yù)覽
拖拽事件
浮標(biāo)的核心功能的就是拖拽,對(duì)鼠標(biāo)或移動(dòng)端的觸摸的事件來說,有三個(gè)階段,鼠標(biāo)或手指接觸到元素時(shí),鼠標(biāo)或手指在移動(dòng)的過程,鼠標(biāo)或手指離開元素。這個(gè)三個(gè)階段對(duì)應(yīng)的事件名稱如下:
mouse: { start: 'mousedown', move: 'mousemove', stop: 'mouseup' }, touch: { start: 'touchstart', move: 'touchmove', stop: 'touchend' }
元素定位
滑動(dòng)容器我們采用絕對(duì)定位,通過設(shè)置 top 和 left 屬性來改變?cè)氐奈恢?,那我們?cè)趺传@取到新的 top 和 left 呢?
我們先看下面這張圖
黃色區(qū)域是拖拽的元素,藍(lán)色的點(diǎn)就是鼠標(biāo)或手指觸摸的位置,在元素移動(dòng)的過程中,這些值也會(huì)隨著發(fā)生改變,那么我們只要計(jì)算出新的觸摸位置和最初觸摸位置的橫坐標(biāo)和豎坐標(biāo)的變化,就可以算出移動(dòng)后的 top left ,因?yàn)橥献У脑夭浑S著頁面滾動(dòng)而變化,所以我們采用 pageX pageY 這兩個(gè)值。用公式簡(jiǎn)單描述就是;
newTop = initTop + (currentPageY - initPageY) newLeft = initLeft + (currentPageX - initPageX)
拖拽區(qū)域
拖拽區(qū)域默認(rèn)是在拖拽元素的父級(jí)元素內(nèi),所以我們需要計(jì)算出父級(jí)元素的寬高。這里有一點(diǎn)需要注意,如果父級(jí)的寬高是由異步事件來改變的,那么獲取的時(shí)候就會(huì)不準(zhǔn)確,這種情況就需要改變下布局。
private getParentSize() { const style = window.getComputedStyle( this.$el.parentNode as Element, null ); return [ parseInt(style.getPropertyValue('width'), 10), parseInt(style.getPropertyValue('height'), 10) ]; }
拖拽的前中后
有了上面的基礎(chǔ),我們分析下拖拽的三個(gè)階段我們需要做哪些工作
- 觸摸元素,即開始拖拽,將當(dāng)前元素的 top left 和觸摸點(diǎn)的 pageX pageY 用對(duì)象存儲(chǔ)起來,然后監(jiān)聽移動(dòng)和結(jié)束事件
- 元素拖拽過程,計(jì)算當(dāng)前的 pageX pageY 與 初始的 pageX pageY 的差值,算出當(dāng)前的 top left ,更新元素的位置
- 拖拽結(jié)束,重置初始值
左右吸附
在手指離開后,若元素偏向某一側(cè),便吸附在該側(cè)的邊上,那么在拖拽事件結(jié)束后,根據(jù)元素的X軸中心的與父級(jí)元素的X軸中心點(diǎn)做比較,就可知道往左還是往右移動(dòng)
頁面上下滑動(dòng)時(shí)隱藏
使用 watch 監(jiān)聽父級(jí)容器的滑動(dòng)事件,獲取 scrollTop ,當(dāng) scrollTop 的值不在發(fā)生變化的時(shí)候,就說明頁面滑動(dòng)結(jié)束了,在變化前和結(jié)束時(shí)設(shè)置 left 即可。
若無法監(jiān)聽父級(jí)容器滑動(dòng)事件,那么可以將監(jiān)聽事件放到外層組件,將 scrollTop 傳入拖拽組件也是可以的。
代碼實(shí)現(xiàn)
組件用的是 ts 寫的,代碼略長(zhǎng),大伙可以先收藏在看
// draggable.vue <template> <div class="dra " :class="{'dra-tran':showtran}" :style="style" @mousedown="elementTouchDown" @touchstart="elementTouchDown"> <slot></slot> </div> </template> <script lang="ts"> import { Component, Prop, Vue, Watch } from 'vue-property-decorator'; import dom from './dom'; const events = { mouse: { start: 'mousedown', move: 'mousemove', stop: 'mouseup' }, touch: { start: 'touchstart', move: 'touchmove', stop: 'touchend' } }; const userSelectNone = { userSelect: 'none', MozUserSelect: 'none', WebkitUserSelect: 'none', MsUserSelect: 'none' }; const userSelectAuto = { userSelect: 'auto', MozUserSelect: 'auto', WebkitUserSelect: 'auto', MsUserSelect: 'auto' }; @Component({ name: 'draggable', }) export default class Draggable extends Vue { @Prop(Number) private width !: number; // 寬 @Prop(Number) private height !: number; // 高 @Prop({ type: Number, default: 0 }) private x!: number; //初始x @Prop({ type: Number, default: 0 }) private y!: number; //初始y @Prop({ type: Number, default: 0 }) private scrollTop!: number; // 初始 scrollTop @Prop({ type: Boolean,default:true}) private draggable !:boolean; // 是否開啟拖拽 @Prop({ type: Boolean,default:true}) private adsorb !:boolean; // 是否開啟吸附左右兩側(cè) @Prop({ type: Boolean,default:true}) private scrollHide !:boolean; // 是否開啟滑動(dòng)隱藏 private rawWidth: number = 0; private rawHeight: number = 0; private rawLeft: number = 0; private rawTop: number = 0; private top: number = 0; // 元素的 top private left: number = 0; // 元素的 left private parentWidth: number = 0; // 父級(jí)元素寬 private parentHeight: number = 0; // 父級(jí)元素高 private eventsFor = events.mouse; // 監(jiān)聽事件 private mouseClickPosition = { // 鼠標(biāo)點(diǎn)擊的當(dāng)前位置 mouseX: 0, mouseY: 0, left: 0, top: 0, }; private bounds = { minLeft: 0, maxLeft: 0, minTop: 0, maxTop: 0, }; private dragging: boolean = false; private showtran: boolean = false; private preScrollTop: number = 0; private parentScrollTop: number = 0; private mounted() { this.rawWidth = this.width; this.rawHeight = this.height; this.rawLeft = this.x; this.rawTop = this.y; this.left = this.x; this.top = this.y; [this.parentWidth, this.parentHeight] = this.getParentSize(); // 對(duì)邊界計(jì)算 this.bounds = this.calcDragLimits(); if(this.adsorb){ dom.addEvent(this.$el.parentNode,'scroll',this.listScorll) } } private listScorll(e:any){ this.parentScrollTop = e.target.scrollTop } private beforeDestroy(){ dom.removeEvent(document.documentElement, 'touchstart', this.elementTouchDown); dom.removeEvent(document.documentElement, 'mousedown', this.elementTouchDown); dom.removeEvent(document.documentElement, 'touchmove', this.move); dom.removeEvent(document.documentElement, 'mousemove', this.move); dom.removeEvent(document.documentElement, 'mouseup', this.handleUp); dom.removeEvent(document.documentElement, 'touchend', this.handleUp); } private getParentSize() { const style = window.getComputedStyle( this.$el.parentNode as Element, null ); return [ parseInt(style.getPropertyValue('width'), 10), parseInt(style.getPropertyValue('height'), 10) ]; } /** * 滑動(dòng)區(qū)域計(jì)算 */ private calcDragLimits() { return { minLeft: 0, maxLeft: Math.floor(this.parentWidth - this.width), minTop: 0, maxTop: Math.floor(this.parentHeight - this.height), }; } /** * 監(jiān)聽滑動(dòng)開始 */ private elementTouchDown(e: TouchEvent) { if(this.draggable){ this.eventsFor = events.touch; this.elementDown(e); } } private elementDown(e: TouchEvent | MouseEvent) { const target = e.target || e.srcElement; this.dragging = true; this.mouseClickPosition.left = this.left; this.mouseClickPosition.top = this.top; this.mouseClickPosition.mouseX = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].pageX : (e as MouseEvent).pageX; this.mouseClickPosition.mouseY = (e as TouchEvent).touches ? (e as TouchEvent).touches[0].pageY : (e as MouseEvent).pageY; // 監(jiān)聽移動(dòng)事件 結(jié)束事件 dom.addEvent(document.documentElement, this.eventsFor.move, this.move); dom.addEvent( document.documentElement, this.eventsFor.stop, this.handleUp ); } /** * 監(jiān)聽拖拽過程 */ private move(e: TouchEvent | MouseEvent) { if(this.dragging){ this.elementMove(e); } } private elementMove(e: TouchEvent | MouseEvent) { const mouseClickPosition = this.mouseClickPosition; const tmpDeltaX = mouseClickPosition.mouseX - ((e as TouchEvent).touches ? (e as TouchEvent).touches[0].pageX : (e as MouseEvent).pageX) || 0; const tmpDeltaY = mouseClickPosition.mouseY - ((e as TouchEvent).touches ? (e as TouchEvent).touches[0].pageY : (e as MouseEvent).pageY) || 0; if (!tmpDeltaX && !tmpDeltaY) return; this.rawTop = mouseClickPosition.top - tmpDeltaY; this.rawLeft = mouseClickPosition.left - tmpDeltaX; this.$emit('dragging', this.left, this.top); } /** * 監(jiān)聽滑動(dòng)結(jié)束 */ private handleUp(e: TouchEvent | MouseEvent) { this.rawTop = this.top; this.rawLeft = this.left; if (this.dragging) { this.dragging = false; this.$emit('dragstop', this.left, this.top); } // 左右吸附 if(this.adsorb){ this.showtran = true const middleWidth = this.parentWidth / 2; if((this.left + this.width/2) < middleWidth){ this.left = 0 }else{ this.left = this.bounds.maxLeft - 10 } setTimeout(() => { this.showtran = false }, 400); } this.resetBoundsAndMouseState(); } /** * 重置初始數(shù)據(jù) */ private resetBoundsAndMouseState() { this.mouseClickPosition = { mouseX: 0, mouseY: 0, left: 0, top: 0, }; } /** * 元素位置 */ private get style() { return { position: 'absolute', top: this.top + 'px', left: this.left + 'px', width: this.width + 'px', height: this.height + 'px', ...(this.dragging ? userSelectNone : userSelectAuto) }; } @Watch('rawTop') private rawTopChange(newTop: number) { const bounds = this.bounds; if (bounds.maxTop === 0) { this.top = newTop; return; } const left = this.left; const top = this.top; if (bounds.minTop !== null && newTop < bounds.minTop) { newTop = bounds.minTop; } else if (bounds.maxTop !== null && bounds.maxTop < newTop) { newTop = bounds.maxTop; } this.top = newTop; } @Watch('rawLeft') private rawLeftChange(newLeft: number) { const bounds = this.bounds; if (bounds.maxTop === 0) { this.left = newLeft; return; } const left = this.left; const top = this.top; if (bounds.minLeft !== null && newLeft < bounds.minLeft) { newLeft = bounds.minLeft; } else if (bounds.maxLeft !== null && bounds.maxLeft < newLeft) { newLeft = bounds.maxLeft; } this.left = newLeft; } @Watch('scrollTop') // 監(jiān)聽 props.scrollTop @Watch('parentScrollTop') // 監(jiān)聽父級(jí)組件 private scorllTopChange(newTop:number){ let timer = undefined; if(this.scrollHide){ clearTimeout(timer); this.showtran = true; this.preScrollTop = newTop; this.left = this.bounds.maxLeft + this.width - 10 timer = setTimeout(()=>{ if(this.preScrollTop === newTop ){ this.left = this.bounds.maxLeft - 10; setTimeout(()=>{ this.showtran = false; },300) } },200) } } } </script> <style lang="css" scoped> .dra { touch-action: none; } .dra-tran { transition: top .2s ease-out , left .2s ease-out; } </style>
// dom.ts export default { addEvent(el: any, event: string, handler: any) { if (!el) { return; } if (el.attachEvent) { el.attachEvent('on' + event, handler); } else if (el.addEventListener) { el.addEventListener(event, handler, true); } else { el['on' + event] = handler; } }, removeEvent(el: any, event: string, handler: any) { if (!el) { return; } if (el.detachEvent) { el.detachEvent('on' + event, handler); } else if (el.removeEventListener) { el.removeEventListener(event, handler, true); } else { el['on' + event] = null; } } };
總結(jié)
以上所述是小編給大家介紹的vue 實(shí)現(xiàn)微信浮標(biāo)效果,希望對(duì)大家有所幫助,如果大家有任何疑問歡迎給我留言,小編會(huì)及時(shí)回復(fù)大家的!
相關(guān)文章
vue實(shí)現(xiàn)websocket客服聊天功能
這篇文章主要為大家詳細(xì)介紹了vue實(shí)現(xiàn)websocket客服聊天功能,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-10-10vue3+ElementPlus使用lang="ts"報(bào)Unexpected?token錯(cuò)誤的解決
最近開發(fā)中遇到了些問題,跟大家分享下,這篇文章主要給大家介紹了關(guān)于vue3+ElementPlus使用lang="ts"報(bào)Unexpected?token錯(cuò)誤的解決辦法,需要的朋友可以參考下2023-01-01vue前端重構(gòu)computed及watch組件通信等實(shí)用技巧整理
這篇文章主要為大家介紹了vue前端重構(gòu)computed及watch組件通信等實(shí)用技巧整理,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-05-05vue實(shí)現(xiàn)純前端表格滾動(dòng)分頁加載
這篇文章主要為大家詳細(xì)介紹了vue實(shí)現(xiàn)純前端表格滾動(dòng)分頁加載,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-04-04vue將后臺(tái)數(shù)據(jù)時(shí)間戳轉(zhuǎn)換成日期格式
這篇文章主要為大家詳細(xì)介紹了vue將后臺(tái)數(shù)據(jù)時(shí)間戳轉(zhuǎn)換成日期格式,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-07-07