Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at CFUNITED 2009 (Lansdowne, VA) with: Katie Maher
Ben Nadel at CFUNITED 2009 (Lansdowne, VA) with: Katie Maher

Thinking About Draggability Within A Unidirectional Data Flow In Angular 2 RC 1

By Ben Nadel on

CAUTION: This is more of a "thinking out loud" post than an informative one.

To be honest, I'm not so good with "draggable" behavior in JavaScript. Historically, this is one of those feature implementations that I've just deferred to jQuery UI or some other 3rd-party library. But, many of those libraries operate under the assumption that they [the libraries] are the ones updating the DOM (Document Object Model). In Angular 2, however, with the philosophy of a unidirectional data flow, some of these assumptions can no longer be made. It would seem that any draggable library would have to "emit" some sort of "move" event; then, it would be up to the calling context to decide how - if at all - that move event should be translated into a mutation of the current DOM state. I wanted to experiment with what that might look like in Angular 2 RC 1.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

The concept of "draggable" is vast and multi-faceted. It brings up a ton of questions that I don't know how to answer philosophically, let alone technically. Ghosting; placeholders; interplay with the change detection digest; interplay with other directives; mid-drag cancelation; preventing default mouse behaviors - frankly, it's enough to make my brain bleed a little bit. So, for this exploration, I am focusing entirely on the point of DOM mutation.

Given a "dumb" component that is absolutely positioned on the page, the initial position of said component has to be provided by the defining context. Presumably, this is defined through the view-model; which means that changes to the position of said component have to be driven by changes to the same view-model.

If we created a directive that powered a draggable behavior, this directive wouldn't have access to that same view-model. Which means that the draggable directive wouldn't be able to update the view-model as a means to change the position of the target component. And, given the concept of a unidirectional data flow, the draggable directive shouldn't be able to directly change the position of the target DOM element, otherwise it would be working against the one-way data flow of the view-model.

All together, I think the appropriate conclusion is that the draggable directive can only calculate the desired DOM mutation; but, that it has to defer to the calling context to apply said mutation to the DOM. In Angular 2, this can be done with directive "outputs" and EventEmitters; the draggable directive can "emit" changes and then the calling context can channel those changes into view-model updates.

To explore this architecture, I put together a demo in which a component - Chip - is positioned absolutely on the page. This component is drag-enabled via a "DraggableDirective"; but, the position of the Chip component is confined by the business logic of the defining context (ie, the root component).

In the following root component, the "[dragEnabled]" directive is attached to the Chip component. Notice that we are also binding to a (positionChange) event. This is the event that the draggable directive is emitting.

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  •  
  • // Import the application components and services.
  • import { ChipComponent } from "./chip.component";
  • import { DraggableDirective } from "./draggable.directive";
  • import { IPosition } from "./draggable.directive";
  •  
  • @Component({
  • selector: "my-app",
  • directives: [ ChipComponent, DraggableDirective ],
  • template:
  • `
  • <chip
  • [chipX]="chipPosition.left"
  • [chipY]="chipPosition.top"
  • [style.left.px]="chipPosition.left"
  • [style.top.px]="chipPosition.top"
  •  
  • dragEnabled
  • (positionChange)="handlePositionChange( $event )">
  • </chip>
  • `
  • })
  • export class AppComponent {
  •  
  • // I hold the position coordinates for the chip.
  • public chipPosition: IPosition;
  •  
  •  
  • // I initialize the component.
  • constructor() {
  •  
  • this.chipPosition = {
  • left: 400,
  • top: 200
  • };
  •  
  • }
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I handle position changes emitted by the dragEnabled directive.
  • public handlePositionChange( newPosition: IPosition ) : void {
  •  
  • // Box the chip into a fixed horizontal and vertical area.
  • this.chipPosition.left = AppComponent.getBoundedValue( 100, 600, newPosition.left );
  • this.chipPosition.top = AppComponent.getBoundedValue( 100, 400, newPosition.top );
  •  
  • }
  •  
  •  
  • // ---
  • // STATIC METHODS.
  • // ---
  •  
  •  
  • // I return the given input that is bound within the given min / max range.
  • static getBoundedValue( min: number, max: number, input: number ) : number {
  •  
  • if ( input < min ) return( min );
  • if ( input > max ) return( max );
  •  
  • return( input );
  •  
  • }
  •  
  • }

When the draggable directive emits the (positionChange) event, we are translating the desired coordinates into bounded coordinates on the page. These coordinates are then used to update the view-model, which is what drives the actual position of the native DOM element.

If we wanted to, we could completely ignore the (positionChange) event, which would, for all intents and purposes, disable dragging of the target component. Of course, all of the mouse-tracking would still be working under the hood; so, that's not necessarily the right approach to disable the drag behavior (which is why this is such a vast topic). But, it does demonstrate the power and constraint of the unidirectional data flow.

Now, let's take a look at the directive that actually performs the drag calculations. What you'll see is that as it moves from state to state - Ready, PreDrag, Drag - it's just calculating deltas and emitting events; it's not actually changing the DOM in any way.

NOTE: I happen to be using jQuery to calculate the position of the element to keep things as simple as possible.

  • // Import the core angular services.
  • import { Directive } from "@angular/core";
  • import { ElementRef } from "@angular/core";
  • import { EventEmitter } from "@angular/core";
  • import { Renderer } from "@angular/core";
  •  
  • export interface IPosition {
  • left: number;
  • top: number;
  • }
  •  
  • export interface ILocation {
  • x: number,
  • y: number
  • }
  •  
  • @Directive({
  • selector: "[dragEnabled]",
  • outputs: [ "positionChange" ]
  • })
  • export class DraggableDirective {
  •  
  • // I hold the host element reference.
  • public elementRef: ElementRef;
  •  
  • // I hold the initial position of the element when the mouse is first depressed.
  • public initialElementPosition: IPosition;
  •  
  • // I hold the initial location of the mouse when it is first depressed.
  • public initialMouseLocation: ILocation;
  •  
  • // I am the event stream for position change values. This directive doesn't actually
  • // implement any of the re-positioning of the host element - that is functionality is
  • // deferred to the calling context.
  • public positionChange: EventEmitter<IPosition>;
  •  
  • // I hold the renderer abstraction that helps bind to events on the DOM.
  • public renderer: Renderer;
  •  
  •  
  • // I initialize the draggable directive.
  • constructor( elementRef: ElementRef, renderer: Renderer ) {
  •  
  • this.elementRef = elementRef;
  • this.initialElementPosition = {
  • left: 0,
  • top: 0
  • };
  • this.initialMouseLocation = {
  • x: 0,
  • y: 0
  • };
  • this.positionChange = new EventEmitter( /* isAsync = */ false );
  • this.renderer = renderer;
  •  
  • }
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I get called once, after the directive has been instantiated and its inputs have
  • // been bound.
  • public ngOnInit() {
  •  
  • this.enterReadyState();
  •  
  • }
  •  
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  •  
  • // I put the directive into a drag state where new position events are being emitted
  • // as the mouse is moved around. The events indicate where the host element should
  • // be moved if the calling context is implementing the drag.
  • private enterDragState( newX: number, newY: number ) : void {
  •  
  • // NOTE: In the drag state, we are binding to the listeners on the global
  • // target since the mouse may move outside the host element if the position of
  • // the element is not updating fast enough; or, if the calling context is NOT
  • // implementing a change in position.
  •  
  • var unbindMousemove = this.renderer.listenGlobal(
  • "document",
  • "mousemove",
  • ( event: MouseEvent ) : void => {
  •  
  • // Try to prevent text from being highlighted by the drag action.
  • event.preventDefault();
  •  
  • // Get the change in mouse location.
  • var deltaX = ( event.pageX - this.initialMouseLocation.x );
  • var deltaY = ( event.pageY - this.initialMouseLocation.y );
  •  
  • // Use the mouse delta to calculate the element's position delta.
  • this.positionChange.next({
  • left: ( this.initialElementPosition.left + deltaX ),
  • top: ( this.initialElementPosition.top + deltaY )
  • });
  •  
  • }
  • );
  •  
  • // If the user mouses-up in the pre-drag state, they never passed the drag
  • // threshold. As such, just teardown the state and move back to ready state.
  • var unbindMouseup = this.renderer.listenGlobal(
  • "document",
  • "mouseup",
  • ( event: MouseEvent ) : void => {
  •  
  • unbindMousemove();
  • unbindMouseup();
  • this.enterReadyState();
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I put the directive into a pre-drag state where we are checking to see if the
  • // depressed mouse is moved passed a certain threshold delta. It is only after the
  • // delta is surpassed that we will start to emit drag events.
  • private enterPreDragState() : void {
  •  
  • // NOTE: In the pre-drag state, we are binding to the listeners on the global
  • // target since the mouse may move outside the host element before the drag
  • // behavior is activated.
  •  
  • var unbindMousemove = this.renderer.listenGlobal(
  • "document",
  • "mousemove",
  • ( event: MouseEvent ) : void => {
  •  
  • var mouseDelta = Math.max(
  • Math.abs( event.pageX - this.initialMouseLocation.x ),
  • Math.abs( event.pageY - this.initialMouseLocation.y )
  • );
  •  
  • // If the mouse has moved past the threshold, teardown the state and
  • // move to the drag state.
  • if ( mouseDelta > 3 ) {
  •  
  • unbindMousemove();
  • unbindMouseup();
  • this.enterDragState( event.pageX, event.pageY );
  •  
  • }
  •  
  • }
  • );
  •  
  • // If the user mouses-up in the pre-drag state, they never passed the drag
  • // threshold. As such, just teardown the state and move back to ready state.
  • var unbindMouseup = this.renderer.listenGlobal(
  • "document",
  • "mouseup",
  • ( event: MouseEvent ) : void => {
  •  
  • unbindMousemove();
  • unbindMouseup();
  • this.enterReadyState();
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I put the directive into a ready / default state where all we're doing is
  • // listening to see if the target element intends to be dragged.
  • private enterReadyState() : void {
  •  
  • // In the ready state, all we need to do is listen for the mousedown event on
  • // the target element. Such a mousedown could indicate an intent to drag.
  • var unbindMousedown = this.renderer.listen(
  • this.elementRef.nativeElement,
  • "mousedown",
  • ( event: MouseEvent ) : void => {
  •  
  • // Store the location and position of the mouse and element so that we
  • // can start to calculate the delta in the next state.
  • this.initialMouseLocation.x = event.pageX;
  • this.initialMouseLocation.y = event.pageY;
  • this.initialElementPosition = this.getElementPosition( this.elementRef.nativeElement );
  •  
  • // Teardown the ready state and move to the pre-drag state.
  • unbindMousedown();
  • this.enterPreDragState();
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I get the position of the given element relative to it's positioned parent.
  • // --
  • // CAUTION: I am using jQuery to calculate the position because, frankly, jQuery
  • // will do a better job of it than I will.
  • private getElementPosition( nativeElement: HTMLElement ) : IPosition {
  •  
  • var position = jQuery( nativeElement ).position();
  •  
  • return({
  • left: position.left,
  • top: position.top
  • });
  •  
  • }
  •  
  • }

In this implementation, I'm using the Renderer abstraction to decouple myself from the DOM. But, it probably doesn't matter since I'm also using jQuery, which is clearly coupled to the DOM. That said, the Renderer does have the added nicety of returning an unbind function from its listener methods.

That said, since the draggable directive only emits events, it doesn't care that the root component is constraining the coordinates of the Chip. It just emits the "desired" coordinates and leaves it up to the calling context to apply those coordinates to the view. As such, we run the above code, we get a draggable, but constrained component:


 
 
 

 
 Thinking about implementing a draggable behavior in a unidirectional data flow context of Angular 2 RC 1. 
 
 
 

Now, the Chip component, which is the component being positioned on the page, doesn't know nothing about nothing - it just knows how to render its own view:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  •  
  • @Component({
  • selector: "chip",
  • inputs: [ "x: chipX", "y: chipY" ],
  • template:
  • `
  • <div class="label">
  • I am Chip
  • </div>
  • <div class="coordinates">
  • {{ x }} , {{ y }}
  • </div>
  • `
  • })
  • export class ChipComponent {
  •  
  • // I hold the inputs for the view.
  • public x: number;
  • public y: number;
  •  
  • }

In this particular demo, the target component is absolutely positioned. But, what happens in something like a "sortable" context, where the sortable items are not positioned by default? In that case, there is no unidirectional data flow to work with (in regard to position). As such, it seems that a "sortable" context would operate under a completely different set of rules.... which is why the topic of draggability is daunting to even think about.




Reader Comments

Hi Ben,

Great post as usual! Just wondering if you have experimented with 'touch' events in regards to draggability ?

It's not that different, just add/substitue 'touchstart' for 'mousedown', 'touchend' for 'mouseup', 'touchmove' for 'mousemove' etc.

The reason I'm asking is that I am using 'touch' events with Angular 2 and have found issues when dragging an element, initiated by touch. Well, I actually 'drag' a clone of the original component (conditionally shown with *ngIf="isDragging") and lower the opacity of the original component for effect.

The problem arises when another event causes Angular to remove the original component from the DOM whilst a drag is underway. Eg, Appointment Book: drag Today's booking to tomorrow, causing Today to go out of view (thus removed from the DOM). I should point out that this works fine using 'mouse' events.

More detail is available on my still OPEN github issue (https://github.com/angular/angular/issues/8035), but looking at the issue backlog of 1350 it looks like I might not get an answer for quite some time.

I'd love to hear your thoughts on the matter. If you figure out a way, it could possibly be a good topic for a follow up blog ;)

Thanks again !

Reply to this Comment

@Dale,

I don't have much experience with the touch-based equivalents of the mouse events. But, right on point, its complexities that got me thinking about dragging in the first place. It's a very different world when all you have to think about is a static DOM that emits events when you're done moving stuff around. But, it's a very different world when parallel digests might mutate or completely remove the DOM elements in question.

This is exactly why I started to think about the draggability from a uni-directional data flow; because any data-driven X/Y coordinate (left/top) could, at any moment, be changed by another process (such as an AJAX response) while you are dragging the item around.

And, to be honest, I don't have great answer to those kinds of questions.

As for your issue specifically, the fact that it works for mouse events and not for touch events is truly confusing. I have no idea why that would be the case, sorry.

Reply to this Comment

@Ben,

Thanks for the fast reply! And Yes touch has its quirks, specifically on my issue this seems to be a Browser implementation detail of the w3 spec on TouchEvents.

I could normally code around these shortcomings by keeping the element reference in memory myself until the touchend or touchcancel event fired. With Angular 2 handling DOM manipulations for me, I don't have the luxury. Hence my predicament.

I was hoping you may have known some Angular 2 trickery that could delay onDestroy() or the like. Maybe through the renderer ? I'm hoping the Angular team provide an API for canDestroy(), similar to canActivate/Deactivate etc, which would solve my problem.

Reply to this Comment

@Ben,

I should also point out the reason the 'mouse' events work and the 'touch' events don't in my case is that the mouse events are 'bound' to the document or window object. The Touch spec binds touch events to the element that was first touched (i.e. touchstart element).

So when the touchstart element is removed (by Angular), the touchmove events stop firing because there is no bound element anymore. Everything resets after you lift your finger and begin again. When a mousedown element is removed (by Angular), mousemove events still fire as they are bound at a higher level (i.e. the document, which is never removed).

If you know a way to 'hand-over' the event, i.e. catch an initial event then re-target it to another element as though it originated the event, then I'm all ears !

Reply to this Comment

@Dale,

Very curious stuff! I wish I knew more about the touch events. Unfortunately, I don't really have any better advice :( Maybe you can try binding the touch events to the global element, like:

@Host( "document: ..." )

.... but, that might not be a viable alternative.

Maybe you could try to *detach* the ChangeDetectorRef (for the parent component) while the drag operation is taking place. Of course, then you can't necessarily update the view....

Uggg, tricky situation!

Reply to this Comment

I am at the beginning of my angular journey and i have been tasked with creating a app that has drag and drop within a canvas (so not so far off your chip example) and i am really struggling to find anything that will help me implement.

The unidirectional stuff (which i think is the same in React?) is hard to get your head round whilst working for so long in a traditional environment of straight up messing with the DOM, even in the example it still is tricky to figure out what is hitting the DOM and what isn't, like:

[style.left.px]

looks like you are hitting the DOM but you are not, i just end up going round and round at the moment :)

The only examples i see (using rxJS or JQuery UI) are using ElementRef and setting properties directly, but from what i understand, that is essentially the angular2 way of hitting the DOM directly and should be avoided, but when using libraries like the ones mentions (maybe not so much rxJS as i know it does a ton more on the observables side) there doesn't seem to be another option.

Like you said, building out your own solution (which would be cool) is a complex and daunting task, and maybe i have bitten off more then i can chew for my first jump in Angular :)

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.