Skip to content

Commit

Permalink
feat(drag-drop): add support for automatic scrolling (#16382)
Browse files Browse the repository at this point in the history
* Adds support for automatically scrolling either the list or the viewport when the user's cursor gets within a certain threshold of the edges (currently within 5% inside and outside).
* Handles changes to the scroll position of both the list and the viewport while the user is dragging. Previous our positioning would break down and we'd emit incorrect data.
* No longer blocks the mouse wheel scrolling while the user is dragging.
* Allows the consumer to opt out of the automatic scrolling.

Fixes #13588.
  • Loading branch information
crisbeto authored and jelbourn committed Jul 9, 2019
1 parent 98a231d commit 207dba6
Show file tree
Hide file tree
Showing 9 changed files with 858 additions and 85 deletions.
1 change: 1 addition & 0 deletions src/cdk/drag-drop/BUILD.bazel
Expand Up @@ -35,6 +35,7 @@ ng_test_library(
deps = [
":drag-drop",
"//src/cdk/bidi",
"//src/cdk/scrolling",
"//src/cdk/testing",
"@npm//@angular/common",
"@npm//rxjs",
Expand Down
497 changes: 468 additions & 29 deletions src/cdk/drag-drop/directives/drag.spec.ts

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/cdk/drag-drop/directives/drop-list.ts
Expand Up @@ -129,6 +129,10 @@ export class CdkDropList<T = any> implements CdkDropListContainer, AfterContentI
@Input('cdkDropListEnterPredicate')
enterPredicate: (drag: CdkDrag, drop: CdkDropList) => boolean = () => true

/** Whether to auto-scroll the view when the user moves their pointer close to the edges. */
@Input('cdkDropListAutoScrollDisabled')
autoScrollDisabled: boolean = false;

/** Emits when the user drops an item inside the container. */
@Output('cdkDropListDropped')
dropped: EventEmitter<CdkDragDrop<T, any>> = new EventEmitter<CdkDragDrop<T, any>>();
Expand Down Expand Up @@ -298,6 +302,7 @@ export class CdkDropList<T = any> implements CdkDropListContainer, AfterContentI
ref.disabled = this.disabled;
ref.lockAxis = this.lockAxis;
ref.sortingDisabled = this.sortingDisabled;
ref.autoScrollDisabled = this.autoScrollDisabled;
ref
.connectedTo(siblings.filter(drop => drop && drop !== this).map(list => list._dropListRef))
.withOrientation(this.orientation);
Expand Down
31 changes: 21 additions & 10 deletions src/cdk/drag-drop/drag-drop-registry.spec.ts
Expand Up @@ -155,7 +155,7 @@ describe('DragDropRegistry', () => {
pointerMoveSubscription.unsubscribe();
});

it('should not emit pointer events when dragging is over (mutli touch)', () => {
it('should not emit pointer events when dragging is over (multi touch)', () => {
const firstItem = testComponent.dragItems.first;

// First finger down
Expand Down Expand Up @@ -211,15 +211,6 @@ describe('DragDropRegistry', () => {
expect(event.defaultPrevented).toBe(true);
});

it('should not prevent the default `wheel` actions when nothing is being dragged', () => {
expect(dispatchFakeEvent(document, 'wheel').defaultPrevented).toBe(false);
});

it('should prevent the default `wheel` action when an item is being dragged', () => {
registry.startDragging(testComponent.dragItems.first, createMouseEvent('mousedown'));
expect(dispatchFakeEvent(document, 'wheel').defaultPrevented).toBe(true);
});

it('should not prevent the default `selectstart` actions when nothing is being dragged', () => {
expect(dispatchFakeEvent(document, 'selectstart').defaultPrevented).toBe(false);
});
Expand All @@ -229,6 +220,26 @@ describe('DragDropRegistry', () => {
expect(dispatchFakeEvent(document, 'selectstart').defaultPrevented).toBe(true);
});

it('should dispatch `scroll` events if the viewport is scrolled while dragging', () => {
const spy = jasmine.createSpy('scroll spy');
const subscription = registry.scroll.subscribe(spy);

registry.startDragging(testComponent.dragItems.first, createMouseEvent('mousedown'));
dispatchFakeEvent(document, 'scroll');

expect(spy).toHaveBeenCalled();
subscription.unsubscribe();
});

it('should not dispatch `scroll` events when not dragging', () => {
const spy = jasmine.createSpy('scroll spy');
const subscription = registry.scroll.subscribe(spy);

dispatchFakeEvent(document, 'scroll');

expect(spy).not.toHaveBeenCalled();
subscription.unsubscribe();
});

});

Expand Down
15 changes: 6 additions & 9 deletions src/cdk/drag-drop/drag-drop-registry.ts
Expand Up @@ -56,6 +56,9 @@ export class DragDropRegistry<I, C extends {id: string}> implements OnDestroy {
*/
readonly pointerUp: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();

/** Emits when the viewport has been scrolled while the user is dragging an item. */
readonly scroll: Subject<Event> = new Subject<Event>();

constructor(
private _ngZone: NgZone,
@Inject(DOCUMENT) _document: any) {
Expand Down Expand Up @@ -136,6 +139,9 @@ export class DragDropRegistry<I, C extends {id: string}> implements OnDestroy {
handler: (e: Event) => this.pointerUp.next(e as TouchEvent | MouseEvent),
options: true
})
.set('scroll', {
handler: (e: Event) => this.scroll.next(e)
})
// Preventing the default action on `mousemove` isn't enough to disable text selection
// on Safari so we need to prevent the selection event as well. Alternatively this can
// be done by setting `user-select: none` on the `body`, however it has causes a style
Expand All @@ -145,15 +151,6 @@ export class DragDropRegistry<I, C extends {id: string}> implements OnDestroy {
options: activeCapturingEventOptions
});

// TODO(crisbeto): prevent mouse wheel scrolling while
// dragging until we've set up proper scroll handling.
if (!isTouchEvent) {
this._globalListeners.set('wheel', {
handler: this._preventDefaultWhileDragging,
options: activeCapturingEventOptions
});
}

this._ngZone.runOutsideAngular(() => {
this._globalListeners.forEach((config, name) => {
this._document.addEventListener(name, config.handler, config.options);
Expand Down
3 changes: 2 additions & 1 deletion src/cdk/drag-drop/drag-drop.ts
Expand Up @@ -47,6 +47,7 @@ export class DragDrop {
* @param element Element to which to attach the drop list functionality.
*/
createDropList<T = any>(element: ElementRef<HTMLElement> | HTMLElement): DropListRef<T> {
return new DropListRef<T>(element, this._dragDropRegistry, this._document);
return new DropListRef<T>(element, this._dragDropRegistry, this._document, this._ngZone,
this._viewportRuler);
}
}
36 changes: 26 additions & 10 deletions src/cdk/drag-drop/drag-ref.ts
Expand Up @@ -12,6 +12,7 @@ import {Direction} from '@angular/cdk/bidi';
import {normalizePassiveListenerOptions} from '@angular/cdk/platform';
import {coerceBooleanProperty, coerceElement} from '@angular/cdk/coercion';
import {Subscription, Subject, Observable} from 'rxjs';
import {startWith} from 'rxjs/operators';
import {DropListRefInternal as DropListRef} from './drop-list-ref';
import {DragDropRegistry} from './drag-drop-registry';
import {extendStyles, toggleNativeDragInteractions} from './drag-styling';
Expand Down Expand Up @@ -46,7 +47,6 @@ const activeEventListenerOptions = normalizePassiveListenerOptions({passive: fal
*/
const MOUSE_EVENT_IGNORE_TIME = 800;

// TODO(crisbeto): add auto-scrolling functionality.
// TODO(crisbeto): add an API for moving a draggable up/down the
// list programmatically. Useful for keyboard controls.

Expand Down Expand Up @@ -155,6 +155,9 @@ export class DragRef<T = any> {
/** Subscription to the event that is dispatched when the user lifts their pointer. */
private _pointerUpSubscription = Subscription.EMPTY;

/** Subscription to the viewport being scrolled. */
private _scrollSubscription = Subscription.EMPTY;

/**
* Time at which the last touch event occurred. Used to avoid firing the same
* events multiple times on touch devices where the browser will fire a fake
Expand Down Expand Up @@ -446,10 +449,20 @@ export class DragRef<T = any> {
return this;
}

/** Updates the item's sort order based on the last-known pointer position. */
_sortFromLastPointerPosition() {
const position = this._pointerPositionAtLastDirectionChange;

if (position && this._dropContainer) {
this._updateActiveDropContainer(position);
}
}

/** Unsubscribes from the global subscriptions. */
private _removeSubscriptions() {
this._pointerMoveSubscription.unsubscribe();
this._pointerUpSubscription.unsubscribe();
this._scrollSubscription.unsubscribe();
}

/** Destroys the preview element and its ViewRef. */
Expand Down Expand Up @@ -593,7 +606,14 @@ export class DragRef<T = any> {

this.released.next({source: this});

if (!this._dropContainer) {
if (this._dropContainer) {
// Stop scrolling immediately, instead of waiting for the animation to finish.
this._dropContainer._stopScrolling();
this._animatePreviewToPlaceholder().then(() => {
this._cleanupDragArtifacts(event);
this._dragDropRegistry.stopDragging(this);
});
} else {
// Convert the active transform into a passive one. This means that next time
// the user starts dragging the item, its position will be calculated relatively
// to the new passive transform.
Expand All @@ -606,13 +626,7 @@ export class DragRef<T = any> {
});
});
this._dragDropRegistry.stopDragging(this);
return;
}

this._animatePreviewToPlaceholder().then(() => {
this._cleanupDragArtifacts(event);
this._dragDropRegistry.stopDragging(this);
});
}

/** Starts the dragging sequence. */
Expand Down Expand Up @@ -695,8 +709,9 @@ export class DragRef<T = any> {
this._removeSubscriptions();
this._pointerMoveSubscription = this._dragDropRegistry.pointerMove.subscribe(this._pointerMove);
this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe(this._pointerUp);

this._scrollPosition = this._viewportRuler.getViewportScrollPosition();
this._scrollSubscription = this._dragDropRegistry.scroll.pipe(startWith(null)).subscribe(() => {
this._scrollPosition = this._viewportRuler.getViewportScrollPosition();
});

if (this._boundaryElement) {
this._boundaryRect = this._boundaryElement.getBoundingClientRect();
Expand Down Expand Up @@ -789,6 +804,7 @@ export class DragRef<T = any> {
});
}

this._dropContainer!._startScrollingIfNecessary(x, y);
this._dropContainer!._sortItem(this, x, y, this._pointerDirectionDelta);
this._preview.style.transform =
getTransform(x - this._pickupPositionInElement.x, y - this._pickupPositionInElement.y);
Expand Down

0 comments on commit 207dba6

Please sign in to comment.