Angular 自定义实现drag drop拖拽组件功能

来实现布局,首先想到的是直接使用angular的cdk @angular/cdk/-drop,尝试了一下,是很好用,在两个列表之间或自身的拖动排序上是很好的。但是我想实现的是类似于左侧可拖动的列表,右侧是布局区域,当拖动的时候源列表是不变的,拖动到目标布局区域的时候是进行复制的功能。很尴尬的是使用cdk的拖动功能的时候总是会将源列表的项移出列表,哪怕是设定了是复制的功能,但是只有在拖放结束的时候才会回到源列表。查了很久,没找到可解决的方法。那先把cdk放一边,本来就对拖动这一块不熟,那我们就从拖动的基本事件入手,来实现这功能。

 

准备工作

我们需要一个angular的项目环境,并且已经安装了Angular Material。然后创建一个drag-drop模块:

$ ng g m drag-drop

app-routing.mould.ts中创建一个路由加载这个模块:

const routes: Routes = [
    {
        path: 'drag-drop',
        loadChildren: () => import('./pages/drag-drop/drag-drop.module').then(m => m.DragDropModule),
        
    },
];
        
@NgModule({
    imports: [RouterModule.forRoot(routes, {relativeLinkResolution: 'legacy'})],
    exports: [RouterModule]
})
export class AppRoutingModule {
}

我们在drog-drap中创建一个index组件,作为页面承载,再创建draw-area,作为组件拖放的目标展示区域。

$ ng g c drag-drop/index
$ ng g c drag-drop/draw-area

然后再创建drag-drop模块的路由:

const router: Routes = [
    {
        path: 'index',
        component: IndexComponent,
    },
    {
        path: '',
        pathMatch: 'full',
        redirectTo: 'index',
    }
];

@NgModule({
    declarations: [
        IndexComponent,
        DrawAreaComponent
    ],
    imports: [
        CommonModule,
        RouterModule.forChild(router),
    ]
})
export class DragDropModule {
}

现在我们访问路由:xxx/drag-drop即可访问到这个页面,但现在我们的页面是空的,里面只有个xxx works

我们需要构建的应用应该类似于这样:

Angular 自定义实现drag drop拖拽组件功能

现在右侧的展示区域是有了,我们再创建左侧的组件列表,创建组件component-list

$ ng g c tool-list

tool-list组件中放入html和css:

<div class="tool-list">
    <ul>
        <li>
            <div class="label">文本</div>
            <button mat-raised-button color="primary">
                <mat-icon>drag_indicator</mat-icon>拖拽
            </button>
        </li>
    </ul>
</div>
.tool-list {
    border: 1px solid #333333;
    width: 200px;
    box-sizing: border-box;
    padding: 10px;
    border-radius: 4px;
    overflow: hidden;
    flex-basis: 200px;
    ul {
        margin: 0;
        padding: 0;
        list-style: none;

        li {
            padding: 10px 0;
            display: flex;
            justify-content: space-between;
            align-items: center;
            width: 100%;
            border-bottom: 1px solid #ccc;
            .label {
                font-size: 18px;
            }
        }
    }
}

然后在drag-drop/index/index.component.html中引用:

<app-header></app-header>
<div class="container">
    <app-tool-list></app-tool-list>
    <div class="draw-area">
        <app-draw-area></app-draw-area>
    </div>
</div>

我们现在多放几个组件,在tool-list.component.ts中修改:

export class ToolListComponent implements OnInit {
    componentList = [
        {label: '文本', type: 'text'},
        {label: '列表', type: 'list'},
        {label: '输入框', type: 'input'},
    ];
    constructor() {
    }
    ngOnInit(): void {
    }
}

tool-list.component.html

<div class="tool-list">
    <ul>
        <li *ngFor="let component of componentList">
            <div class="label">{{component.label}}</div>
            <button
                mat-raised-button
                color="primary"
            >
                <mat-icon>drag_indicator</mat-icon>
                拖拽
            </button>
        </li>
    </ul>
</div>

现在组件列表准备好了,我们再看下index组件,为了让页面好看点,我们稍微做下修饰:

index.component.html

<div class="container">
    <app-tool-list></app-tool-list>
    <div class="draw-area">
        <app-draw-area></app-draw-area>
    </div>
</div>

index.component.scss

.container {
    margin: 20px;
    padding: 20px;
    border: 1px solid #333;
    min-height: 300px;
    display: flex;
    justify-content: flex-start;
    .draw-area{
        width: 100%;
        margin-left: 20px;
        border: 1px solid #999999;
        padding: 20px;
    }
}

然后看下页面,现在是这个样子:

Angular 自定义实现drag drop拖拽组件功能

让组件可以拖动

页面基本上已经构造好了,但是我们该如何让它可以拖动?

MDN上有一篇介绍拖拽操作的文章:MDN 拖拽操作,里面详细介绍了拖拽的基本事件。

为了更好的组织代码,我们创建一个”拖组件”:drag.directive.ts

$ ng g d directives/drag

ps: 我们将所有指令放入directives文件夹,然后创建一个share.module.ts来处理功能的指令和组件,这非常好用:

$ ng g m share

然后将指令的声明放入share模块中:

import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {DragDirective} from './core/directives/drag.directive';

@NgModule({
    declarations: [
        DragDirective,
    ],
    imports: [
        CommonModule,
    ],
    exports: [
        DragDirective,
    ]
})
export class ShareModule {
}

在需要的使用这些指令的地方导入share.module.ts即可。

然后看drag.directive.ts指令,它需要做哪些事情?

  • 设定该dom是可拖动的:element.setAttribute('draggable', true)
  • 监听拖动事件,当拖动开始时dragstart设置文本,以及拖动占位符。
  • 拖动完成事件dragend,用来做重置工作。

上代码:

import {Directive, ElementRef, HostListener, Input} from '@angular/core';
import {getDpi} from '../../utils/common';

@Directive({
    selector: '[appDrag]'
})
export class DragDirective {
    @Input() componentType: string = null;
    // 拖动占位符
    @Input() dragPlaceholder: HTMLCanvasElement;
    el: ElementRef;

    canvasConfig = {
        width: 100,
        height: 100,
    };


    constructor(el: ElementRef) {
        this.el = el;
        this.el.nativeElement.setAttribute('draggable', true);
        this.el.nativeElement.style.cursor = 'move';
    }

    @HostListener('dragstart', ['$event'])
    dragstart(e) {
        const dt = e.dataTransfer;
        dt.effectAllowed = 'copy';
        dt.setData('text/plain', this.componentType);

        this.dragWidthCustomerImage(e);
    }

    // 拖拽完成
    @HostListener('dragend', ['$event'])
    dragend(e) {
        this.resetCanvas(this.dragPlaceholder);
    }

    dragWidthCustomerImage(event) {
        this.drawCanvas(this.dragPlaceholder, this.componentType);
        event.dataTransfer.setDragImage(this.dragPlaceholder, 25, 25);
    }

    drawCanvas(canvasEl: HTMLCanvasElement, text: string) {
        const ratio = getDpi();
        canvasEl.style.width = this.canvasConfig.width + 'px';
        canvasEl.style.height = this.canvasConfig.height + 'px';

        canvasEl.width = this.canvasConfig.width * ratio;
        canvasEl.height = this.canvasConfig.height * ratio;
        const context = canvasEl.getContext('2d');
        context.clearRect(0, 0, canvasEl.width, canvasEl.height);
        context.fillStyle = '#999';
        context.fillRect(0, 0, canvasEl.width, canvasEl.height);
        // 放置文字
        context.fillStyle = '#fff';
        const fontSize = 14;
        context.font = `${fontSize * ratio}px Arial`;
        context.fillText(text, 0, canvasEl.height / 2);
    }

    resetCanvas(canvasEl) {
        canvasEl.style.width = '0px';
        canvasEl.style.height = '0px';
        canvasEl.width = 0;
        canvasEl.height = 0;
    }
}

我们这里拖动的时候后设定的占位符是一个canvas,而为了适配高分屏,这里增加了一个获得dpi的函数,我放入了src/app/utils/common.ts文件中:

export function getDpi() {
    return window.devicePixelRatio || 1;
}

关于canvas高清屏的处理可以参考我的这篇文章:canvas 适配高清屏

然后我们在tool-list.component.html文件中应用这个指令:

<div class="tool-list">
    <ul>
        <li *ngFor="let component of componentList">
            <div class="label">{{component.label}}</div>
            <button
                mat-raised-button
                color="primary"
                appDrag
                componentType="{{component.type}}"
                [dragPlaceholder]="dragPlaceholderCanvas"
            >
                <mat-icon>drag_indicator</mat-icon>
                拖拽
            </button>
        </li>
    </ul>

</div>
<!--拖动占位符-->
<canvas height="50" width="100" id="drag-placeholder" #dragPlaceholderCanvas></canvas>

注意:我们这里为了不频繁的创建拖动占位的canvas,直接放在了组件内部,然后使用css隐藏这个canvas即可。

然后尝试拖动组件:

Angular 自定义实现drag drop拖拽组件功能

OK,达到预期。

放置区域的处理

我们可以先按简单粗暴的来,我们需要页面上有一个区域是”可拖放组件“,然后当我拖放组件到这个区域后,就从上到下变成了:”可拖放组件“ + ”xx组件“ + ”可拖放组件“,表示在该组件的前后都可以插入拖放的组件。

如果该区域是“可拖放组件”区域,那么标记typeempty,如果是组件那么就是组件的类型。

拖动组件过来的时候,我们要知道它拖动到哪个区域了,所以我们需要给每个组件标记下唯一的id。

首先我们调整draw-area组件。

draw-area.component.html:

<div class="container">
    <ng-container *ngFor="let component of componentList">
        <ng-container *ngIf="component.type === 'empty'">
            <ng-container *ngTemplateOutlet="dropArea; context: {component: component}"></ng-container>
        </ng-container>
        <div class="component" *ngIf="component.type !== 'empty'">
            <p>{{component.type}}</p>
        </div>
    </ng-container>
</div>

<ng-template #dropArea let-componentInfo="component">
    <div class="drop-area">
        <p>可拖放组件</p>
    </div>
</ng-template>

draw-area.component.scss

.drop-area {
    box-sizing: border-box;
    width: 100%;
    height: 40px;
    border: 1px dashed #ccc;
    display: flex;
    justify-content: center;
    align-items: center;

    p {
        margin: 0;
        padding: 0;
    }
}

.drop-area:-moz-drag-over {
    border: 1px solid black;
}

.container {
    .component {
        padding: 20px;
        p {
            margin: 0;
            padding: 0;
            text-align: center;
            font-size: 18px;
        }
    }
}

draw-area.component.ts

import {AfterViewInit, Component, OnInit} from '@angular/core';
import guid from '../../../utils/uuid';

@Component({
    selector: 'app-draw-area',
    templateUrl: './draw-area.component.html',
    styleUrls: ['./draw-area.component.scss']
})
export class DrawAreaComponent implements OnInit, AfterViewInit {
    componentList: { id: string, type: string }[] = [];

    ngOnInit(): void {
        this.componentList.push({
            id: guid(),
            type: 'empty',
        });
    }

    ngAfterViewInit() {
    }
}

目前的样子是这样的:

Angular 自定义实现drag drop拖拽组件功能

我们创建“放”的指令:drag.directive.ts

$ ng g d directives/drop

然后在里面处理相关事件:

import {Directive, ElementRef, EventEmitter, HostListener, Input, Output} from '@angular/core';
import {DropDataInterface} from '../interfaces/drag.interface';

@Directive({
    selector: '[appDrop]'
})
export class DropDirective {
    el: ElementRef;
    @Input() dropData: { id: string, type: string };
    @Output() dropEvent: EventEmitter<DropDataInterface> = new EventEmitter();

    constructor(el: ElementRef) {
        this.el = el;
    }

    @HostListener('dragover', ['$event'])
    dragOver(e) {
        e.preventDefault();
        return false;
    }

    // 拖拽离开
    @HostListener('dragleave', ['$event'])
    dragleave(e) {
        e.target.style.background = '#fff';
        e.target.style.borderStyle = 'dashed';
    }

    // 拖拽完成
    @HostListener('dragend', ['$event'])
    dragend(e) {
    }

    @HostListener('drop', ['$event'])
    drop(e) {
        e.preventDefault();
        const text = e.dataTransfer.getData('text');
        this.dropEvent.emit({
            component: text,
            currentAreaInfo: this.dropData,
        });
        // 拖拽事件完成
        e.target.style.background = '#fff';
    }

    @HostListener('dragenter', ['$event'])
    dragenter(e) {
        e.target.style.background = '#ccc';
    }
}

我们这里定义了dropDataInterface

export interface DropDataInterface {
    component: string;
    currentAreaInfo: {
        id: string;
        type: string;
    };
}

在指令上定义了拖拽完成后就将对应数据透传出去。然后再修改draw-area组件的draw-area.component.html

<div class="container">
    <ng-container *ngFor="let component of componentList">
        <ng-container *ngIf="component.type === 'empty'">
            <ng-container *ngTemplateOutlet="dropArea; context: {component: component}"></ng-container>
        </ng-container>
        <div class="component" *ngIf="component.type !== 'empty'">
            <p>{{component.type}}</p>
        </div>
    </ng-container>
</div>

<ng-template #dropArea let-componentInfo="component">
    <div class="drop-area" appDrop (dropEvent)="getDropEvent($event)" [dropData]="componentInfo">
        <p>可拖放组件</p>
    </div>
</ng-template>

然后修改draw-area.component.ts来接收这个拖拽传过来的数据:

import {AfterViewInit, Component, OnInit} from '@angular/core';
import {DropDataInterface} from '../../../core/interfaces/drag.interface';
import guid from '../../../utils/uuid';

@Component({
    selector: 'app-draw-area',
    templateUrl: './draw-area.component.html',
    styleUrls: ['./draw-area.component.scss']
})
export class DrawAreaComponent implements OnInit, AfterViewInit {
    componentList: { id: string, type: string }[] = [];

    ngOnInit(): void {
        this.componentList.push({
            id: guid(),
            type: 'empty',
        });
    }

    ngAfterViewInit() {
    }

    getDropEvent(data: DropDataInterface) {
        // 首先根据id找到索引
        const idx = this.componentList.findIndex(item => item.id === data.currentAreaInfo.id);
        // 保证前后都有可插入区域
        this.componentList.splice(idx + 1, 0,
            {id: guid(), type: data.component},
            {id: guid(), type: 'empty'},
        );
    }
}

我们看下运行的情况:

Angular 自定义实现drag drop拖拽组件功能

Ok,还是可以的,基本的拖拽满足了


完成了基本的拖拽,这只是第一步,我们的组件也是简化为一个文字,后续还是有很多探索的地方的,比如动态加载组件,构造组件编辑的ui等等。

本文转载自: Tony YYJ ,版权归原作者所有,本博客仅以学习目的的传播渠道,不作版权和内容观点阐述,转载时根据场景需要有所改动。