用angular来实现拖拽布局,首先想到的是直接使用angular的cdk @angular/cdk/drag-drop
,尝试了一下,是很好用,在两个列表之间或自身的拖动排序上是很好的。但是我想实现的是类似于左侧可拖动的组件列表,右侧是布局区域,当拖动的时候源列表是不变的,拖动到目标布局区域的时候是进行复制的功能。很尴尬的是使用cdk的拖动功能的时候总是会将源列表的项移出列表,哪怕是设定了是复制的功能,但是只有在拖放结束的时候才会回到源列表。查了很久,没找到可解决的方法。那先把cdk放一边,本来就对拖动这一块不熟,那我们就从拖动的基本事件入手,来实现这功能。
准备工作
我们需要一个angular的项目环境,并且已经安装了Angular Material
。然后创建一个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
。
我们需要构建的应用应该类似于这样:
现在右侧的展示区域是有了,我们再创建左侧的组件列表,创建组件component-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; } }
|
然后看下页面,现在是这个样子:
让组件可以拖动
页面基本上已经构造好了,但是我们该如何让它可以拖动?
MDN上有一篇介绍拖拽操作的文章:MDN 拖拽操作,里面详细介绍了拖拽的基本事件。
为了更好的组织代码,我们创建一个”拖组件”:drag.directive.ts
1
| $ ng g d directives/drag
|
ps: 我们将所有指令放入directives
文件夹,然后创建一个share.module.ts
来处理功能的指令和组件,这非常好用:
然后将指令的声明放入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即可。
然后尝试拖动组件:
OK,达到预期。
放置区域的处理
我们可以先按简单粗暴的来,我们需要页面上有一个区域是”可拖放组件“,然后当我拖放组件到这个区域后,就从上到下变成了:”可拖放组件“ + ”xx组件“ + ”可拖放组件“,表示在该组件的前后都可以插入拖放的组件。
如果该区域是“可拖放组件”区域,那么标记type
为empty
,如果是组件那么就是组件的类型。
拖动组件过来的时候,我们要知道它拖动到哪个区域了,所以我们需要给每个组件标记下唯一的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() { } }
|
目前的样子是这样的:
我们创建“放”的指令: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) { 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'}, ); } }
|
我们看下运行的情况:
Ok,还是可以的,基本的拖拽满足了
完成了基本的拖拽,这只是第一步,我们的组件也是简化为一个文字,后续还是有很多探索的地方的,比如动态加载组件,构造组件编辑的ui等等。