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 属性

小结

后续将继续优化拖拽组件的细节。

相关文章
相关标签/搜索