less than 1 minute minute read

How to Build HTML Drag and Drop in Angular

The HTML Drag and Drop API gives developers a clean interface to integrate draggable and droppable elements. This article will cover how to integrate that API into directives using Angular. Specifically, we will be creating two directives, draggable and droppable, which both communicate using a common service. Before diving into the post, it's assumed that you have some familiarity with Angular (2+) using TypeScript.

Curtis morte
Head of Development & Co-Founder
Share:

Within this post, the "draggable directive" is commonly referred to as a "draggable element" and the "droppable directive" is commonly referred to as a "droppable zone". To provide clarity, when draggable or droppable is not suffixed with "directive", this is referencing the directive in action. Before starting, you can do the following first:

EDIT - 11/29/2018: We never made any reference to using ngZone for sake of simplicity. However, using ngZone can definitely help to increase performance.

Draggable Directive

Defines a draggable element that can be moved to a droppable zone. The primary purpose of this directive is to pass data to the drag service (covered later in the article). The directive is fairly simple which includes data binding (using the @Input decorator), lifecycle hooks, and the Renderer2 API (an abstraction for UI rendering manipulations). Now let's actually look at the code that is used to make the directive. If you're already familiar with Angular, then reading the code might be all the information you need. For those that are less familiar with angular, follow the comments within the code and read the list after the code block.

import {Directive, ElementRef, Input, OnDestroy, OnInit, Renderer2} from '@angular/core';
import {DragService} from './drag.service';

// 1
@Directive({
  selector: '[appDraggable]'
})

export class DraggableDirective implements OnInit, OnDestroy {

  // Events
  private onDragStart: Function;
  private onDragEnd: Function;

  // Options for the directive
  private options: DraggableOptions;

  // 2
  @Input()
  set appDraggable(options: DraggableOptions) {
    if (options) {
      this.options = options;
    }
  }

  constructor(private elementRef: ElementRef
    , private renderer: Renderer2
    , private dragService: DragService) {
    // 3
    this.renderer.setProperty(this.elementRef.nativeElement, 'draggable', true);
    this.renderer.addClass(this.elementRef.nativeElement, 'app-draggable');
  }

  // 4
  ngOnInit() {
    this.addDragEvents();
  }

  // 5
  ngOnDestroy() {
    // Remove events
    this.onDragStart();
    this.onDragEnd();
  }

  /**
   * @desc responsible for adding the drag events to the directive
   * @note transfers drag data using the Drag and Drop API (Browser)
   * @note known CSS issue where a draggable element cursor cant be set while dragging in Chrome
   */
  // 6
  private addDragEvents(): void {
    // 7
    this.onDragStart = this.renderer.listen(
      this.elementRef.nativeElement
      , 'dragstart'
      , (event: DragEvent): void => {
        this.dragService.startDrag(this.options.zones);
        // Transfer the data using Drag and Drop API (Browser)
        event.dataTransfer
          .setData('Text'
            , JSON.stringify(this.options.data));
      });

    // 8
    this.onDragEnd = this.renderer.listen(
      this.elementRef.nativeElement
      , 'dragend'
      , (event: DragEvent): void => {
        this.dragService.removeHighLightedAvailableZones();
      });
  }
}

// 9
export interface DraggableOptions {
  zones?: Array<string>;
  data?: any;
}
  1. Defines an attribute directive using the @Directive decorator. This allows us to make any HTML element a draggable directive by adding the input property "[appDraggable]".
  2. Defines what to do with the value of input property appDraggable using Typescript's mutator syntax for setters.
  3. Defines logic that is used when the draggable element is initialized. We use the Renderer2 API to add the attribute draggable with a value of true (draggable="true"). The draggable attribute will trigger the browser's native HTML Drag and Drop API. We also use the Renderer2 API to add a class to the directive element.
  4. Defines the lifecycle hook for what to do when the directive has been initialized.
  5. Defines the lifecycle hook for what to do when the directive is destroyed.
  6. Defines the event listeners for 'dragstart' and 'dragend'. We are using the Renderer2 API to add the events, and not the @HostListener decorator. This allows us to destroy an event listener.
  7. The 'dragstart' event does two things. First, the event will set the zones which the draggable element can be dropped into, using the drag service. Secondly, the event will transfer data which can be read by a 'drop' event in the droppable directive. The data that the event transfers is set by the input property binding value below:
    <div [appDraggable]={data: '', zone: ''}>
     <span>I can be dragged to a droppable zone.</span>
    </div>.
  8. The 'dragend' event removes styling when a draggable element is not dropped into a droppable element.
  9. Defines the signature for the draggable options. This is useful because it allows you to import "DraggableOptions" to define signatures in other components/directives.

Droppable Directive

The droppable directive defines a zone which a draggable element can be dropped onto. The primary purpose of the directive is to handle drag events< which determine if the draggable element can be added to the droppable zone. A shared service is used to compare the droppable element with the list of zones a draggable element can move too. This directive is a little more involved than the draggable directive, but still relatively simple. As before, let's look at the code that is used to make the directive. Again, if you're already familiar with Angular, then reading the code might be all the information you need. For those that are less familiar with angular, follow the comments within the code and read the list after the code block.

import {Directive, ElementRef, EventEmitter, Input,
OnDestroy, OnInit, Output, Renderer2} from '@angular/core';
import {DragService} from './drag.service';

// 1
@Directive({
  selector: '[appDroppable]'
})

export class DroppableDirective implements OnInit, OnDestroy {
  private onDragEnter: Function;
  private onDragLeave: Function;
  private onDragOver: Function;
  private onDrop: Function;

  public options: DroppableOptions = {
    zone: 'appZone'
  };

  // Allow options input by using [appDroppable]='{}'
  // 2
  @Input()
  set appDroppable(options: DroppableOptions) {
    if (options) {
      this.options = options;
    }
  }

  // Drop Event Emitter
  @Output() public onDroppableComplete: EventEmitter<DroppableEventObject> = new EventEmitter();

  // 3
  constructor(private elementRef: ElementRef
    , private renderer: Renderer2
    , private dragService: DragService) {
    this.renderer.addClass(this.elementRef.nativeElement, 'app-droppable');
  }

  // 4
  ngOnInit() {
    // Add available zone
    // This exposes the zone to the service so a draggable element can update it
    this.dragService.addAvailableZone(this.options.zone, {
      begin: () => {
        this.renderer.addClass(this.elementRef.nativeElement, 'js-app-droppable--target');
      },
      end: () => {
        this.renderer.removeClass(this.elementRef.nativeElement, 'js-app-droppable--target');
      }
    });
    this.addOnDragEvents();
  }

  // 5
  ngOnDestroy() {
    // Remove zone
    this.dragService.removeAvailableZone(this.options.zone);

    // Remove events
    this.onDragEnter();
    this.onDragLeave();
    this.onDragOver();
    this.onDrop();
  }

  /**
   * @desc responsible for adding the drag events
   */
  // 6
  private addOnDragEvents(): void {
    // Drag Enter
    this.onDragEnter = this.renderer.listen(
      this.elementRef.nativeElement
      , 'dragenter'
      , (event: DragEvent): void => {
        this.handleDragEnter(event);
      });
    this.onDragLeave = this.renderer.listen(
      this.elementRef.nativeElement
      , 'dragleave'
      , (event: DragEvent): void => {
        this.handleDragLeave(event);
      });
    // Drag Over
    this.onDragOver = this.renderer.listen(
      this.elementRef.nativeElement
      , 'dragover'
      , (event: DragEvent): void => {
        this.handleDragOver(event);
      });
    // Drag Drop
    this.onDrop = this.renderer.listen(
      this.elementRef.nativeElement
      , 'drop'
      , (event: DragEvent): void => {
        this.handleDrop(event);
      });
  }

  /**
   * @desc responsible for handling the dragenter event
   * @param event
   */
  // 7
  private handleDragEnter(event: DragEvent): void {
    if (this.dragService.accepts(this.options.zone)) {
      // Prevent default to allow drop
      event.preventDefault();
      // Add styling
      this.renderer.addClass(event.target, 'js-app-droppable--zone');
    }
  }

  /**
   * @desc responsible for handling the dragleave event
   * @param event
   */
  // 8
  private handleDragLeave(event: DragEvent): void {
    if (this.dragService.accepts(this.options.zone)) {
      // Remove styling
      this.renderer.removeClass(event.target, 'js-app-droppable--zone');
    }
  }

  /**
   * @desc responsible for handling the dragOver event
   * @param event
   */
  // 9
  private handleDragOver(event: DragEvent): void {
    if (this.dragService.accepts(this.options.zone)) {
      // Prevent default to allow drop
      event.preventDefault();
    }
  }

  /**
   * @desc responsible for handling the drop event
   * @param event
   */
  // 10
  private handleDrop(event: DragEvent): void {
    // Remove styling
    this.dragService.removeHighLightedAvailableZones();
    this.renderer.removeClass(event.target, 'js-app-droppable--zone');
    // Emit successful event
    const data = JSON.parse(event.dataTransfer.getData('Text'));
    this.onDroppableComplete.emit({
      data: data,
      zone: this.options.data
    });
  }
}

// 11
export interface DroppableOptions {
  data?: any;
  zone?: string;
}

// Droppable Event Object
export interface DroppableEventObject {
  data: any;
  zone: any;
}
  1. Defines an attribute directive using the @Directive decorator. This allows us to make any HTML element a droppable directive by adding the input property "[appDroppable]".
  2. Defines what to do with the value of input property appDroppable using Typescript's mutator syntax for setters.
  3. Defines logic that is used when the droppable element is instantiated. We use the Renderer2 API to add a class to the directive element.
  4. Lifecycle hook logic to run when the directive has been initialized.
  5. Lifecycle hook logic to run when the directive has been destroyed.
  6. Defines the event listeners for 'dragenter', 'dragleave', 'dragover' and 'drop'.
  7. The 'dragenter' event will add a class to the droppable element indicating that a draggable element can be placed in the zone. It's important to note that we prevent the default action from happening if a draggable element can be placed into the zone. This is critical because it allows for the drag and drop action to possible.
  8. The 'dragleave' event will remove a class from the droppable element indicating that a draggable element is no longer placed over the zone.
  9. The 'dragover' event doesn't do anything specifically, but is here for completeness for those that may need more flexibility. This could potentially be useful to determine the exact position a draggable element should be placed at. Again, it's important to note that we prevent the default action from happening for the same reason as the previous list item.
  10. The 'drop' event is responsible for emitting event data to let the parent component know that a draggable element has moved. The event data consists of the zone which the draggable element was dropped into, and the data which was set by the 'dragstart' event of the draggable directive.
  11. Defines the signature for the droppable options. This is useful because it allows you to import "DroppableOptions" to define signatures in other components/directives.

Drag Service

The Drag Service is responsible for allowing communication between the draggable and droppable directives. The service is specified in the providers array of the application's app module definition. This means that the same instance of the service is provided to both the draggable and droppable directive, so data can be persisted. Again, if you're already familiar with Angular, then reading the code might be all the information you need. Continuing on, Draggable components use the service to do two things:

import {Injectable} from '@angular/core';

@Injectable()
export class DragService {
  private zoneIDs: Array<string>;
  private availableZones: any = {};

  /**
   * @desc responsible for storing the draggable elements
   * zone target.
   * @param {Array<string>} zoneIDs - the zoneIDs
   */
  public startDrag(zoneIDs: Array<string>) {
    this.zoneIDs = zoneIDs;
    this.highLightAvailableZones();
  }

  /**
   * @desc responsible for matching the droppable element
   * with a draggable element
   * @param {string} zoneID - the zone ID to search for
   */
  public accepts(zoneID: string): boolean {
    return (this.zoneIDs.indexOf(zoneID) > -1);
  }

  /**
   * @desc responsible for removing highlighted available zones
   * that a draggable element can be added too.
   */
  public removeHighLightedAvailableZones(): void {
    this.zoneIDs.forEach((zone: string) => {
      this.availableZones[zone].end();
    });
  }

  /**
   * @desc responsible for adding an available zone
   * @param {{ begin: Function, end: Function }} zoneID - zone key from DroppableOptions
   * @param {string} obj - reference to a start and stop object
   */
  public addAvailableZone(zoneID: string, obj: { begin: Function, end: Function }): void {
    this.availableZones[zoneID] = obj;
  }

  /**
   * @desc responsible for removing an available zone
   * @param {string} zoneID - the zone ID to search for
   */
  public removeAvailableZone(zoneID: string): void {
    delete this.availableZones[zoneID];
  }

  /**
   * @desc responsible for highlighting available zones
   * that a draggable element can be added too.
   */
  private highLightAvailableZones(): void {
    this.zoneIDs.forEach((zone: string) => {
      this.availableZones[zone].begin();
    });
  }
}
  1. Specify which zones the draggable element can be dropped into by using the startDrag() method.
  2. To remove the highlighted styling of a zone using the removeHighLightedAvailableZones() method.

Droppable components use the service to do three things:

  1. Add their zone, with access to control the styling of zone, to the service.
  2. Determine if drag events accept their zone as a valid drop target.
  3. Remove their zone from the service when destroyed.

The last bullet is important because the drag service is provided to all components which include it. Thus, when a zone is destroyed, it needs to de-register itself from the service.

Summary: How to Build HTML Drag and Drop in Angular

Wrapping things up, the important consideration is that communication is handled via a shared service that is provided by the application component, or which ever component that you need to provide the service. The Draggable and Droppable directives are a means of transporting data from one "state" to another based on your application requirements. If you need some help with your Angular project, don’t hesitate to get in touch with us. for Solutions Architecture. And as always, you can tweet @ThreeVentures with your Angular questions or thoughts.

Share:

Contact Us

Get senior leadership and vision for your next big project

General Inquiries

Monday - Friday
8:00AM - 04:00PM
+1 (916) 507-0003

Offices Locations

3V HQ
950 Reserve Drive #130
Roseville, California 95678
3V Texas
6735 Salt Cedar Way
Building 1, Suite 300
Frisco, Texas 75034