微信的浮窗,大夥應該都用過,當咱們正在閱讀一篇公衆號文章時,忽然須要處理微信消息,點擊浮窗,在微信上會有個浮標,點擊浮標能夠再次回到文章。javascript
咱們今天打算擼一個相似微信的浮標組件,咱們指望組件有如下功能css
效果預覽vue
浮標的核心功能的就是拖拽,對鼠標或移動端的觸摸的事件來講,有三個階段,鼠標或手指接觸到元素時,鼠標或手指在移動的過程,鼠標或手指離開元素。這個三個階段對應的事件名稱以下:java
mouse: {
start: 'mousedown',
move: 'mousemove',
stop: 'mouseup'
},
touch: {
start: 'touchstart',
move: 'touchmove',
stop: 'touchend'
}
複製代碼
滑動容器咱們採用絕對定位,經過設置 top
和 left
屬性來改變元素的位置,那咱們怎麼獲取到新的 top
和 left
呢?typescript
咱們先看下面這張圖微信
黃色區域是拖拽的元素,藍色的點就是鼠標或手指觸摸的位置,在元素移動的過程當中,這些值也會隨着發生改變,那麼咱們只要計算出新的觸摸位置和最初觸摸位置的橫座標和豎座標的變化,就能夠算出移動後的 top left
,由於拖拽的元素不隨着頁面滾動而變化,因此咱們採用 pageX pageY
這兩個值。用公式簡單描述就是;dom
newTop = initTop + (currentPageY - initPageY)異步
newLeft = initLeft + (currentPageX - initPageX)ide
拖拽區域默認是在拖拽元素的父級元素內,因此咱們須要計算出父級元素的寬高。這裏有一點須要注意,若是父級的寬高是由異步事件來改變的,那麼獲取的時候就會不許確,這種狀況就須要改變下佈局。佈局
private getParentSize() {
const style = window.getComputedStyle(
this.$el.parentNode as Element,
null
);
return [
parseInt(style.getPropertyValue('width'), 10),
parseInt(style.getPropertyValue('height'), 10)
];
}
複製代碼
有了上面的基礎,咱們分析下拖拽的三個階段咱們須要作哪些工做
top left
和觸摸點的 pageX pageY
用對象存儲起來,而後監聽移動和結束事件pageX pageY
與 初始的 pageX pageY
的差值,算出當前的 top left
,更新元素的位置在手指離開後,若元素偏向某一側,便吸附在該側的邊上,那麼在拖拽事件結束後,根據元素的X軸中心的與父級元素的X軸中心點作比較,就可知道往左仍是往右移動
使用 watch
監聽父級容器的滑動事件,獲取 scrollTop
,當 scrollTop
的值不在發生變化的時候,就說明頁面滑動結束了,在變化前和結束時設置 left
便可。
若沒法監聽父級容器滑動事件,那麼能夠將監聽事件放到外層組件,將 scrollTop
傳入拖拽組件也是能夠的。
組件用的是 ts 寫的,代碼略長,大夥能夠先收藏在看
// 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; // 是否開啓吸附左右兩側
@Prop({ type: Boolean,default:true}) private scrollHide !:boolean; // 是否開啓滑動隱藏
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; // 父級元素寬
private parentHeight: number = 0; // 父級元素高
private eventsFor = events.mouse; // 監聽事件
private mouseClickPosition = { // 鼠標點擊的當前位置
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();
// 對邊界計算
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)
];
}
/**
* 滑動區域計算
*/
private calcDragLimits() {
return {
minLeft: 0,
maxLeft: Math.floor(this.parentWidth - this.width),
minTop: 0,
maxTop: Math.floor(this.parentHeight - this.height),
};
}
/**
* 監聽滑動開始
*/
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;
// 監聽移動事件 結束事件
dom.addEvent(document.documentElement, this.eventsFor.move, this.move);
dom.addEvent(
document.documentElement,
this.eventsFor.stop,
this.handleUp
);
}
/**
* 監聽拖拽過程
*/
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);
}
/**
* 監聽滑動結束
*/
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();
}
/**
* 重置初始數據
*/
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') // 監聽 props.scrollTop
@Watch('parentScrollTop') // 監聽父級組件
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;
}
}
};
複製代碼
在監聽滾動事件的時候建議使用 @scroll.passive
,同時滑動的元素上須要設置 overflow: auto
屬性
後續將繼續優化拖拽組件的細節。