From c7d34be0ec86c2ff5ec767d8a90077bba813380b Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 14 Aug 2018 01:41:31 +0200 Subject: [PATCH] feat(drag-drop): add move event (#12641) This is something that came up during a discussion in #8963. Adds the `cdkDragMoved` event which will emit as an item is being dragged. Also adds some extra precautions to make sure that we're not doing extra work unless the consumer opted into the event. --- src/cdk-experimental/drag-drop/drag-events.ts | 10 +++ src/cdk-experimental/drag-drop/drag.spec.ts | 53 +++++++++++++-- src/cdk-experimental/drag-drop/drag.ts | 67 ++++++++++++++++--- 3 files changed, 115 insertions(+), 15 deletions(-) diff --git a/src/cdk-experimental/drag-drop/drag-events.ts b/src/cdk-experimental/drag-drop/drag-events.ts index 7d71eb4ca38e..4ba0db09c4ff 100644 --- a/src/cdk-experimental/drag-drop/drag-events.ts +++ b/src/cdk-experimental/drag-drop/drag-events.ts @@ -55,3 +55,13 @@ export interface CdkDragDrop { /** Container from which the item was picked up. Can be the same as the `container`. */ previousContainer: CdkDropContainer; } + +/** Event emitted as the user is dragging a draggable item. */ +export interface CdkDragMove { + /** Item that is being dragged. */ + source: CdkDrag; + /** Position of the user's pointer on the page. */ + pointerPosition: {x: number, y: number}; + /** Native event that is causing the dragging. */ + event: MouseEvent | TouchEvent; +} diff --git a/src/cdk-experimental/drag-drop/drag.spec.ts b/src/cdk-experimental/drag-drop/drag.spec.ts index bee22282faaf..fadccc09d162 100644 --- a/src/cdk-experimental/drag-drop/drag.spec.ts +++ b/src/cdk-experimental/drag-drop/drag.spec.ts @@ -1,12 +1,13 @@ import { + AfterViewInit, Component, + ElementRef, + NgZone, + Provider, + QueryList, Type, ViewChild, - ElementRef, ViewChildren, - QueryList, - AfterViewInit, - Provider, ViewEncapsulation, } from '@angular/core'; import {TestBed, ComponentFixture, fakeAsync, flush, tick} from '@angular/core/testing'; @@ -201,6 +202,50 @@ describe('CdkDrag', () => { // go into an infinite loop trying to stringify the event, if the test fails. expect(event).toEqual({source: fixture.componentInstance.dragInstance}); })); + + it('should emit when the user is moving the drag element', () => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); + + const spy = jasmine.createSpy('move spy'); + const subscription = fixture.componentInstance.dragInstance.moved.subscribe(spy); + + dragElementViaMouse(fixture, fixture.componentInstance.dragElement.nativeElement, 5, 10); + expect(spy).toHaveBeenCalledTimes(1); + + dragElementViaMouse(fixture, fixture.componentInstance.dragElement.nativeElement, 10, 20); + expect(spy).toHaveBeenCalledTimes(2); + + subscription.unsubscribe(); + }); + + it('should emit to `moved` inside the NgZone', () => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); + + const spy = jasmine.createSpy('move spy'); + const subscription = fixture.componentInstance.dragInstance.moved + .subscribe(() => spy(NgZone.isInAngularZone())); + + dragElementViaMouse(fixture, fixture.componentInstance.dragElement.nativeElement, 10, 20); + expect(spy).toHaveBeenCalledWith(true); + + subscription.unsubscribe(); + }); + + it('should complete the `moved` stream on destroy', () => { + const fixture = createComponent(StandaloneDraggable); + fixture.detectChanges(); + + const spy = jasmine.createSpy('move spy'); + const subscription = fixture.componentInstance.dragInstance.moved + .subscribe(undefined, undefined, spy); + + fixture.destroy(); + expect(spy).toHaveBeenCalled(); + subscription.unsubscribe(); + }); + }); describe('draggable with a handle', () => { diff --git a/src/cdk-experimental/drag-drop/drag.ts b/src/cdk-experimental/drag-drop/drag.ts index 0034438b3cb5..3ad3a0d648ee 100644 --- a/src/cdk-experimental/drag-drop/drag.ts +++ b/src/cdk-experimental/drag-drop/drag.ts @@ -27,12 +27,19 @@ import {DOCUMENT} from '@angular/common'; import {Directionality} from '@angular/cdk/bidi'; import {CdkDragHandle} from './drag-handle'; import {CdkDropContainer, CDK_DROP_CONTAINER} from './drop-container'; -import {CdkDragStart, CdkDragEnd, CdkDragExit, CdkDragEnter, CdkDragDrop} from './drag-events'; +import { + CdkDragStart, + CdkDragEnd, + CdkDragExit, + CdkDragEnter, + CdkDragDrop, + CdkDragMove, +} from './drag-events'; import {CdkDragPreview} from './drag-preview'; import {CdkDragPlaceholder} from './drag-placeholder'; import {ViewportRuler} from '@angular/cdk/overlay'; import {DragDropRegistry} from './drag-drop-registry'; -import {Subject, merge} from 'rxjs'; +import {Subject, merge, Observable} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; // TODO(crisbeto): add auto-scrolling functionality. @@ -97,6 +104,15 @@ export class CdkDrag implements OnDestroy { /** Cached scroll position on the page when the element was picked up. */ private _scrollPosition: {top: number, left: number}; + /** Emits when the item is being moved. */ + private _moveEvents = new Subject>(); + + /** + * Amount of subscriptions to the move event. Used to avoid + * hitting the zone if the consumer didn't subscribe to it. + */ + private _moveEventSubscriptions = 0; + /** Elements that can be used to drag the draggable item. */ @ContentChildren(CdkDragHandle) _handles: QueryList; @@ -129,6 +145,20 @@ export class CdkDrag implements OnDestroy { @Output('cdkDragDropped') dropped: EventEmitter> = new EventEmitter>(); + /** + * Emits as the user is dragging the item. Use with caution, + * because this event will fire for every pixel that the user has dragged. + */ + @Output('cdkDragMoved') moved: Observable> = Observable.create(observer => { + const subscription = this._moveEvents.subscribe(observer); + this._moveEventSubscriptions++; + + return () => { + subscription.unsubscribe(); + this._moveEventSubscriptions--; + }; + }); + constructor( /** Element that the draggable is attached to. */ public element: ElementRef, @@ -166,6 +196,7 @@ export class CdkDrag implements OnDestroy { this._nextSibling = null; this._dragDropRegistry.removeDragItem(this); + this._moveEvents.complete(); this._destroyed.next(); this._destroyed.complete(); } @@ -245,15 +276,31 @@ export class CdkDrag implements OnDestroy { this._hasMoved = true; event.preventDefault(); + const pointerPosition = this._getPointerPositionOnPage(event); + if (this.dropContainer) { - this._updateActiveDropContainer(event); + this._updateActiveDropContainer(pointerPosition); } else { const activeTransform = this._activeTransform; - const {x: pageX, y: pageY} = this._getPointerPositionOnPage(event); - activeTransform.x = pageX - this._pickupPositionOnPage.x + this._passiveTransform.x; - activeTransform.y = pageY - this._pickupPositionOnPage.y + this._passiveTransform.y; + activeTransform.x = + pointerPosition.x - this._pickupPositionOnPage.x + this._passiveTransform.x; + activeTransform.y = + pointerPosition.y - this._pickupPositionOnPage.y + this._passiveTransform.y; this._setTransform(this.element.nativeElement, activeTransform.x, activeTransform.y); } + + // Since this event gets fired for every pixel while dragging, we only + // want to fire it if the consumer opted into it. Also we have to + // re-enter the zone becaus we run all of the events on the outside. + if (this._moveEventSubscriptions > 0) { + this._ngZone.run(() => { + this._moveEvents.next({ + source: this, + pointerPosition, + event + }); + }); + } } /** Handler that is invoked when the user lifts their pointer up, after initiating a drag. */ @@ -314,19 +361,17 @@ export class CdkDrag implements OnDestroy { * Updates the item's position in its drop container, or moves it * into a new one, depending on its current drag position. */ - private _updateActiveDropContainer(event: MouseEvent | TouchEvent) { - const {x, y} = this._getPointerPositionOnPage(event); - + private _updateActiveDropContainer({x, y}: Point) { // Drop container that draggable has been moved into. const newContainer = this.dropContainer._getSiblingContainerFromPosition(x, y); if (newContainer) { this._ngZone.run(() => { // Notify the old container that the item has left. - this.exited.emit({ item: this, container: this.dropContainer }); + this.exited.emit({item: this, container: this.dropContainer}); this.dropContainer.exit(this); // Notify the new container that the item has entered. - this.entered.emit({ item: this, container: newContainer }); + this.entered.emit({item: this, container: newContainer}); this.dropContainer = newContainer; this.dropContainer.enter(this, x, y); });