Wendell 👾

Avatar of Wendell Hu

Source Code Analysis - Angular CDK Drag Drop

March 11, 2019

What is drag drop? May I quote the description from the official website here:

The @angular/cdk/drag-drop module provides you with a way to easily and declaratively create drag-and-drop interfaces, with support for free dragging, sorting within a list, transferring items between lists, animations, touch devices, custom drag handles, previews, and placeholders, in addition to horizontal lists and locking along an axis.

For short, drag-drop helps you to build draggale components in a convenient and declarative way.

This article takes a deep look into its implementation by exploring four demos on the official website:


The simplest usage is to attach a directive called cdkDrag to an HTML element so it becomes draggable. Let take a look into the mechanism by exploring this directive’s life cycle and user interactions.

Stage 1: Setup

cdkDrag

The directive is implemented in file directives/drag.ts. When you attach it to an HTML element, it does the following things:

  1. Initialize a DragRef object. This object is the actual executioner of dragging logics. We would have a detailed discussion about it later.
  2. Deal with drag handles.
  3. Make itself a proxy of DragRef. In its constructor:
if (dragDrop) {
  this._dragRef = dragDrop.createDrag(element, config);
} else {
  this._dragRef = new DragRef(element, config, _document, _ngZone, viewportRuler, dragDropRegistry);
}
this._dragRef.data = this;
this._syncInputs(this._dragRef);
this._proxyEvents(this._dragRef);
  • In method _syncInputs, cdkDrag subscribes DragRef’s event beforeStarted, and sync the directive’s inputs to DragRef with some methods start with with.
  • And in method _proxyEvents, the directive subscribes DragRef’s events and emits them, like a proxy.

You may notice that there is a injected DragDrop. It’s a simple service to create DragRef. If you are interested you can find more about it here. In the directive’s AfterViewInit hook, the directive deals with drag handles:

class CdkDrag {
    ngAfterViewInit() {
    // We need to wait for the zone to stabilize, in order for the reference
    // element to be in the proper place in the DOM. This is mostly relevant
    // for draggable elements inside portals since they get stamped out in
    // their original DOM position and then they get transferred to the portal.
    this._ngZone.onStable.asObservable()
      .pipe(take(1), takeUntil(this._destroyed))
      .subscribe(() => {
        this._updateRootElement();
        // Listen for any newly-added handles.
        this._handles.changes.pipe(
          startWith(this._handles),
          // Sync the new handles with the DragRef.
          tap((handles: QueryList<CdkDragHandle>) => {
            const childHandleElements = handles
              .filter(handle => handle._parentDrag === this)
              .map(handle => handle.element);
            this._dragRef.withHandles(childHandleElements);
          }),
          // Listen if the state of any of the handles changes.
          switchMap((handles: QueryList<CdkDragHandle>) => {
            return merge(...handles.map(item => item._stateChanges));
          }),
          takeUntil(this._destroyed)
        ).subscribe(handleInstance => {
          // Enabled/disable the handle that changed in the DragRef.
          const dragRef = this._dragRef;
          const handle = handleInstance.element.nativeElement;
          handleInstance.disabled ? dragRef.disableHandle(handle) : dragRef.enableHandle(handle);
        });
      });
  }
}

When the DOM renderes the first time (startWith operator will ensure that) or handles change, subscribes the state change events of all handles who are targeting the current draggable element, and use DragRef’s methods to disable or enable them. We will talk about handle after we have a basic understanding of the main process. We jumped over the construction of DragRef earlier, now let’s take a good look at it.

DragRef

DragRef is implemented in drag-ref.ts. Its constructor is

class DragRef {
    constructor(
    element: ElementRef<HTMLElement> | HTMLElement,
    private _config: DragRefConfig,
    private _document: Document,
    private _ngZone: NgZone,
    private _viewportRuler: ViewportRuler,
    private _dragDropRegistry: DragDropRegistry<DragRef, DropListRef>) {
    this.withRootElement(element);
    _dragDropRegistry.registerDragItem(this);
  }
}

withRootElement method binds drag start event listener _pointerDown on the root element, in another word, the elements that cdkDrag attaches to. When user press mouse button or tap finger on the root element, _pointerDown is invoked to deal with this event. And after that, it register itself on DragDropRegistry.

DragDropRegistry

DragDropRegistry is a service provided in root and injected to cdkDrag. It’s responsible for listening to mousemove or touchmove events and make sure only one DragRef could respond to these events at one time. As for the registerDragItem, when it’s invoked, it registers a DragRef as a drag instance and make sure that touchmove events are ‘defaultPrevented’ when there’s item actually getting dragged. So far, cdk has been prepared to interact with users’ dragging gestures. So let’s go to the next stage.

Stage 2: Start Dragging

When user press the mouse on the root element, a mousedown or touchdown event is emitted and _pointerDown method of DragRef as event handler is triggered. Let’s consider the simplest situation and assume there’s no handle. By dispatching a beforeStart event, as we mentioned earlier, all inputs are synced. After that, a drag sequence is initialized by _initializeDragSequance. As its name suggests, _initializeDragSequence make everything ready for dragging and receiving move events. Let’s ignore the codes that dealing with dragging delay on mobile devices and focus on the main process.

// Cache the previous transform amount only after the first drag sequence, because
// we don't want our own transforms to stack on top of each other.
if (this._initialTransform == null) {
  this._initialTransform = this._rootElement.style.transform || '';
}

First, it caches the initial transform. After dragging stops, we would set transform to the root element and also put the initial transform back.

this._pointerMoveSubscription = this._dragDropRegistry.pointerMove.subscribe(this._pointerMove);
this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe(this._pointerUp);

Then it subscribes move and up events from the registry. Other things are setting properties related to dragging status, like

  • _hasStartedDragging, _hasMoved. They explain for themselves.
  • _pickupPositionInElement. The pointer’s position against the root container’s top-left corner as origin.
  • _pointerDirectionDelta. A vector telling how the pointer moves. At last, it calls startDragging of the registry. Like registerDragItem, startDragging registers move and up handles and selectstart preventer when it’s necessary and registers them outside of zone.js out of performance consideration.

Stage 3: Dragging

The drag&drop system is now ready for dragging. Assume that user drags the root component, pointerMove emits an event and _pointerMove of DragRef gets invoked. First it would check if the dragging distance has exceeded the threshold. If so, mark _hasStartedDragging as true. After that, it would calcualte the correct transform and assign it to the root element.

const constrainedPointerPosition = this._getConstrainedPointerPosition(event);

We would come back to this line later when we talk about boundary. Let’s have a look at how the trasnform is calculated first. For now, just take constrainedPointerPosition as the pointer’s position.

const activeTransform = this._activeTransform;
activeTransform.x =
    constrainedPointerPosition.x - this._pickupPositionOnPage.x + this._passiveTransform.x;
activeTransform.y =
    constrainedPointerPosition.y - this._pickupPositionOnPage.y + this._passiveTransform.y;
const transform = getTransform(activeTransform.x, activeTransform.y);
// Preserve the previous `transform` value, if there was one. Note that we apply our own
// transform before the user's, because things like rotation can affect which direction
// the element will be translated towards.
this._rootElement.style.transform = this._initialTransform ?
    transform + ' ' + this._initialTransform  : transform;

Let’s clarify some variables here. activeTransform means during the current dragging, how many pixels has the root element moved along the x and y axises from its original position. passiveTransform means how many pixels has the root element moved from its original position to the position where we started the current dragging. And obviously, pickUpPositionOnPage means the position of the pointer when we started the current dragging. And it’s obvious too that after the current dragging completes, activatedTransform would be assigned to passiveTransform. Knowing what the variables means, we now can understand how activatedTransform is calculated now. After the calculation, we set the transform to the root element’s style. Remember that initialTransform was cached? It would be appended to activatedTransform. > It’s a funny fact the you can add two values to a CSS property!

Stage 4: Stop Dragging

Now the root element should be draggable. Let’s talk about how to make it stoppable. When user releases the mouse button or picks up his finger, _pointerUp method will be invoked. Of course, it unsubscribes move and up events handles, and clear global listeners in DragDropService. And it accumulate active transform to passive transform. So the next the root element get transformed, it would have a correct start point.

I don’t know why this._dragDropRegistry.stopDragging(this); appears at least twice… Bingo! That’s the whole process of dragging. Let’s talk about some advanced features.

Other

Here is something we haven’t talked about in the stages earlier but still important.

Boundary

When we were talking about position in stage 3, we ignore _getConstrainedPointerPosition method. Now let’s talk about boundary mechanism. When dragging starts, a beforeStarted event is emitted and withBoundaryElement is invoked. getClosestMatchingAncestor use an CSS selector to query the closest ancestor node as the boundary element. When drag sequence is initialized, the element’s rect is set to this._boundaryRect.

if (this._boundaryElement) {
  this._boundaryRect = this._boundaryElement.getBoundingClientRect();
}

And in _getConstrainedPointerPosition, the pointer’s position is mutate to ensure that the root element is always inside of the boundary element.

const point = this._getPointerPositionOnPage(event);
// ...
if (this._boundaryRect) {
  const {x: pickupX, y: pickupY} = this._pickupPositionInElement;
  const boundaryRect = this._boundaryRect;
  const previewRect = this._previewRect!;
  const minY = boundaryRect.top + pickupY;
  const maxY = boundaryRect.bottom - (previewRect.height - pickupY);
  const minX = boundaryRect.left + pickupX;
  const maxX = boundaryRect.right - (previewRect.width - pickupX);
  point.x = clamp(point.x, minX, maxX);
  point.y = clamp(point.y, minY, maxY);
}

Why we calculates the boundary rect outside of _getConstrainedPointerPosition? Because .getBoundingClientRect causes pages to reflow thus is performance expensive.

Handle

Earlier we mentioned that cdkDrag deals with handle. When handles change, it invokes withHandles.

tap((handles: QueryList<CdkDragHandle>) => {
  const childHandleElements = handles
    .filter(handle => handle._parentDrag === this)
    .map(handle => handle.element);
  this._dragRef.withHandles(childHandleElements);
}),

How does the filter work? Take a look into DragHandle’s constructor:

constructor(
  public element: ElementRef<HTMLElement>,
  @Inject(CDK_DRAG_PARENT) @Optional() parentDrag?: any) {
  this._parentDrag = parentDrag;
  toggleNativeDragInteractions(element.nativeElement, false);
}

And in cdkDrag’s meta:

@Directive({
  selector: '[cdkDrag]',
  exportAs: 'cdkDrag',
  host: {
    'class': 'cdk-drag',
    '[class.cdk-drag-dragging]': '_dragRef.isDragging()',
  },
  providers: [{provide: CDK_DRAG_PARENT, useExisting: CdkDrag}]
})

You can see that cdkDrag is provided to its children as CDK_DRAG_PARENT. As for withHandles, it just register these handles’ HTML element. In _pointerDown, instead of the root element, handles’ element would be investigated to seen if a dragging sequence should be initialized.

if (this._handles.length) {
  const targetHandle = this._handles.find(handle => {
    const target = event.target;
    return !!target &amp;&amp; (target === handle || handle.contains(target as HTMLElement));
  });
  if (targetHandle &amp;&amp; !this._disabledHandles.has(targetHandle) &amp;&amp; !this.disabled) {
    this._initializeDragSequence(targetHandle, event);
  }
} else if (!this.disabled) {
  this._initializeDragSequence(this._rootElement, event);
}

contains is an API that could determine whether a param is the callee’s child element. How do we know if a handle is disabled or not? Remember at the beginning of _pointerDown we emit a event? In its subscriber , this line handleInstance.disabled ? dragRef.disableHandle(handle) : dragRef.enableHandle(handle); would call DragRef’s methods to enable or disable them.

Axis lock

Since we know how to ensure the root element to be within the boundary element, it’s simple to understand how to lock axis, just reassign x or y.

if (this.lockAxis === 'x' || dropContainerLock === 'x') {
  point.y = this._pickupPositionOnPage.y;
} else if (this.lockAxis === 'y' || dropContainerLock === 'y') {
  point.x = this._pickupPositionOnPage.x;
}

Conclusion

This article gives a detailed explanation of how drag&drop works without a container, and how handles, axis locking and boundary work. cdkDrag is attached to an HTML element which you want it to be draggable but DragRef is the main executor of logics. DragDropRegister is responsible for dispatching move events and ensuring only one element is draggable at a time.


Wendell Hu

Written by Wendell Hu
Follow me on Twitter