vue 實現微信浮標

微信的浮窗,大夥應該都用過,當咱們正在閱讀一篇公衆號文章時,忽然須要處理微信消息,點擊浮窗,在微信上會有個浮標,點擊浮標能夠再次回到文章。javascript

咱們今天打算擼一個相似微信的浮標組件,咱們指望組件有如下功能css

  1. 支持拖拽
  2. 支持左右吸附
  3. 支持頁面上下滑動時隱藏

效果預覽vue

drag.gif

拖拽事件

浮標的核心功能的就是拖拽,對鼠標或移動端的觸摸的事件來講,有三個階段,鼠標或手指接觸到元素時,鼠標或手指在移動的過程,鼠標或手指離開元素。這個三個階段對應的事件名稱以下:java

mouse: {
    start: 'mousedown',
    move: 'mousemove',
    stop: 'mouseup'
},
touch: {
    start: 'touchstart',
    move: 'touchmove',
    stop: 'touchend'
}
複製代碼

元素定位

滑動容器咱們採用絕對定位,經過設置 topleft 屬性來改變元素的位置,那咱們怎麼獲取到新的 topleft 呢?typescript

咱們先看下面這張圖微信

position

黃色區域是拖拽的元素,藍色的點就是鼠標或手指觸摸的位置,在元素移動的過程當中,這些值也會隨着發生改變,那麼咱們只要計算出新的觸摸位置和最初觸摸位置的橫座標和豎座標的變化,就能夠算出移動後的 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)
    ];

}
複製代碼

拖拽的前中後

有了上面的基礎,咱們分析下拖拽的三個階段咱們須要作哪些工做

  1. 觸摸元素,即開始拖拽,將當前元素的 top left 和觸摸點的 pageX pageY用對象存儲起來,而後監聽移動和結束事件
  2. 元素拖拽過程,計算當前的 pageX pageY 與 初始的 pageX pageY 的差值,算出當前的 top left ,更新元素的位置
  3. 拖拽結束,重置初始值

左右吸附

在手指離開後,若元素偏向某一側,便吸附在該側的邊上,那麼在拖拽事件結束後,根據元素的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 屬性

小結

後續將繼續優化拖拽組件的細節。

相關文章
相關標籤/搜索