0%

Angular 拖拽组件布局

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

准备工作

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

1
$ ng g m drag-drop

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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,作为组件拖放的目标展示区域。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

1
$ ng g c tool-list

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

1
2
3
4
5
6
7
8
9
10
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
.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中引用:

1
2
3
4
5
6
7
<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中修改:

1
2
3
4
5
6
7
8
9
10
11
export class ToolListComponent implements OnInit {
componentList = [
{label: '文本', type: 'text'},
{label: '列表', type: 'list'},
{label: '输入框', type: 'input'},
];
constructor() {
}
ngOnInit(): void {
}
}

tool-list.component.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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

1
2
3
4
5
6
<div class="container">
<app-tool-list></app-tool-list>
<div class="draw-area">
<app-draw-area></app-draw-area>
</div>
</div>

index.component.scss

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.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

1
$ ng g d directives/drag

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

1
$ ng g m share

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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,用来做重置工作。

上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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文件中:

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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

1
$ ng g d directives/drop

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
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

1
2
3
4
5
6
7
export interface DropDataInterface {
component: string;
currentAreaInfo: {
id: string;
type: string;
};
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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来接收这个拖拽传过来的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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 drop

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


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

码字辛苦,打赏个咖啡☕️可好?💘