When we are reading an article on an official account, we suddenly need to process a wechat message. Click the float window, and there will be a buoy on wechat. Click the buoy to return to the article.

We are going to build a buoy component similar to wechat today. We expect the component to have the following functions

  1. Support drag and drop
  2. Support left and right adsorption
  3. Support page sliding up and down hide

Results the preview

Drag and drop event

The core function of the buoy is drag and drop. For the event of the touch of the mouse or mobile end, there are three stages: when the mouse or finger touches the element, the mouse or finger moves, and the mouse or finger leaves the element. The event names for the three phases are as follows:

mouse: {
    start: 'mousedown'.move: 'mousemove'.stop: 'mouseup'
},
touch: {
    start: 'touchstart'.move: 'touchmove'.stop: 'touchend'
}
Copy the code

Element localization

The sliding container uses absolute positioning, changing the position of elements by setting the top and left attributes. How do we get the new top and left attributes?

Let’s start with the picture below

The yellow area is the element dragged, and the blue point is the position of the mouse or finger touch. These values will also change as the element moves, so we can calculate the top left after the move as long as we calculate the change of the abscissa and vertical coordinates of the new touch position and the original touch position. Since dragged elements do not change as the page scrolls, we use the values pageX pageY. The simple formula for this is;

newTop = initTop + (currentPageY – initPageY)

newLeft = initLeft + (currentPageX – initPageX)

Drag and drop zones

The drag area is inside the parent element of the drag element by default, so we need to calculate the width and height of the parent element. One thing to note here is that if the width and height of the parent are changed by an asynchronous event, the retrieval will be inaccurate, in which case the layout will need to be changed.

private getParentSize() {
    const style = window.getComputedStyle(
        this.$el.parentNode as Element,
        null
    );

    return [
        parseInt(style.getPropertyValue('width'), 10),
        parseInt(style.getPropertyValue('height'), 10)]; }Copy the code

Drag front, middle and back

With that in mind, let’s analyze what we need to do in the three stages of drag

  1. Touch elements, that is, start dragging and dropping the current elementtop leftAnd touch pointspageX pageYStore it as an object and then listen for movement and end events
  2. Element drag and drop procedure to calculate the currentpageX pageYWith the initialpageX pageYThe difference between, and calculate the currenttop left, updates the position of the element
  3. Drag ends, reset the initial value

About the adsorption

After the finger leaves, if the element is tilted to a certain side, it will stick to the side. Then, after the dragging event is over, it can know whether to move left or right by comparing the X-axis center of the element with the X-axis center of the parent element

The page slides up and down to hide

Use watch to listen to the sliding event of the parent container and obtain scrollTop. When the value of scrollTop does not change, it means that the page sliding is finished. Set left before and after the change.

If you can’t listen for the parent container slide event, you can put the listener event on the outer component, or you can pass the scrollTop into the drag component.

Code implementation

Component is written with TS, the code is slightly longer, we can first collect in the look

// 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>
Copy the code
// 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; }}};Copy the code

Hit the pit

When listening for scroll events, @scroll. Passive is recommended, and overflow: Auto should be set on sliding elements

summary

We will continue to refine the details of drag and drop components.