Skip to content

Commit aa93d82

Browse files
committed
feat(drag-drop): add support for automatic scrolling
* 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.
1 parent 6a7fc81 commit aa93d82

File tree

9 files changed

+845
-84
lines changed

9 files changed

+845
-84
lines changed

src/cdk/drag-drop/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ ng_test_library(
3535
deps = [
3636
":drag-drop",
3737
"//src/cdk/bidi",
38+
"//src/cdk/scrolling",
3839
"//src/cdk/testing",
3940
"@npm//@angular/common",
4041
"@npm//rxjs",

src/cdk/drag-drop/directives/drag.spec.ts

Lines changed: 464 additions & 29 deletions
Large diffs are not rendered by default.

src/cdk/drag-drop/directives/drop-list.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ export class CdkDropList<T = any> implements CdkDropListContainer, AfterContentI
129129
@Input('cdkDropListEnterPredicate')
130130
enterPredicate: (drag: CdkDrag, drop: CdkDropList) => boolean = () => true
131131

132+
/** Whether to auto-scroll the view when the user moves their pointer close to the edges. */
133+
@Input('cdkDropListAutoScroll')
134+
autoScroll: boolean = true;
135+
132136
/** Emits when the user drops an item inside the container. */
133137
@Output('cdkDropListDropped')
134138
dropped: EventEmitter<CdkDragDrop<T, any>> = new EventEmitter<CdkDragDrop<T, any>>();
@@ -298,6 +302,7 @@ export class CdkDropList<T = any> implements CdkDropListContainer, AfterContentI
298302
ref.disabled = this.disabled;
299303
ref.lockAxis = this.lockAxis;
300304
ref.sortingDisabled = this.sortingDisabled;
305+
ref.autoScroll = this.autoScroll;
301306
ref
302307
.connectedTo(siblings.filter(drop => drop && drop !== this).map(list => list._dropListRef))
303308
.withOrientation(this.orientation);

src/cdk/drag-drop/drag-drop-registry.spec.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ describe('DragDropRegistry', () => {
155155
pointerMoveSubscription.unsubscribe();
156156
});
157157

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

161161
// First finger down
@@ -211,15 +211,6 @@ describe('DragDropRegistry', () => {
211211
expect(event.defaultPrevented).toBe(true);
212212
});
213213

214-
it('should not prevent the default `wheel` actions when nothing is being dragged', () => {
215-
expect(dispatchFakeEvent(document, 'wheel').defaultPrevented).toBe(false);
216-
});
217-
218-
it('should prevent the default `wheel` action when an item is being dragged', () => {
219-
registry.startDragging(testComponent.dragItems.first, createMouseEvent('mousedown'));
220-
expect(dispatchFakeEvent(document, 'wheel').defaultPrevented).toBe(true);
221-
});
222-
223214
it('should not prevent the default `selectstart` actions when nothing is being dragged', () => {
224215
expect(dispatchFakeEvent(document, 'selectstart').defaultPrevented).toBe(false);
225216
});
@@ -229,6 +220,26 @@ describe('DragDropRegistry', () => {
229220
expect(dispatchFakeEvent(document, 'selectstart').defaultPrevented).toBe(true);
230221
});
231222

223+
it('should dispatch `scroll` events if the viewport is scrolled while dragging', () => {
224+
const spy = jasmine.createSpy('scroll spy');
225+
const subscription = registry.scroll.subscribe(spy);
226+
227+
registry.startDragging(testComponent.dragItems.first, createMouseEvent('mousedown'));
228+
dispatchFakeEvent(document, 'scroll');
229+
230+
expect(spy).toHaveBeenCalled();
231+
subscription.unsubscribe();
232+
});
233+
234+
it('should not dispatch `scroll` events when not dragging', () => {
235+
const spy = jasmine.createSpy('scroll spy');
236+
const subscription = registry.scroll.subscribe(spy);
237+
238+
dispatchFakeEvent(document, 'scroll');
239+
240+
expect(spy).not.toHaveBeenCalled();
241+
subscription.unsubscribe();
242+
});
232243

233244
});
234245

src/cdk/drag-drop/drag-drop-registry.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ export class DragDropRegistry<I, C extends {id: string}> implements OnDestroy {
5656
*/
5757
readonly pointerUp: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
5858

59+
/** Emits when the viewport has been scrolled while the user is dragging an item. */
60+
readonly scroll: Subject<Event> = new Subject<Event>();
61+
5962
constructor(
6063
private _ngZone: NgZone,
6164
@Inject(DOCUMENT) _document: any) {
@@ -136,6 +139,9 @@ export class DragDropRegistry<I, C extends {id: string}> implements OnDestroy {
136139
handler: (e: Event) => this.pointerUp.next(e as TouchEvent | MouseEvent),
137140
options: true
138141
})
142+
.set('scroll', {
143+
handler: (e: Event) => this.scroll.next(e)
144+
})
139145
// Preventing the default action on `mousemove` isn't enough to disable text selection
140146
// on Safari so we need to prevent the selection event as well. Alternatively this can
141147
// be done by setting `user-select: none` on the `body`, however it has causes a style
@@ -145,15 +151,6 @@ export class DragDropRegistry<I, C extends {id: string}> implements OnDestroy {
145151
options: activeCapturingEventOptions
146152
});
147153

148-
// TODO(crisbeto): prevent mouse wheel scrolling while
149-
// dragging until we've set up proper scroll handling.
150-
if (!isTouchEvent) {
151-
this._globalListeners.set('wheel', {
152-
handler: this._preventDefaultWhileDragging,
153-
options: activeCapturingEventOptions
154-
});
155-
}
156-
157154
this._ngZone.runOutsideAngular(() => {
158155
this._globalListeners.forEach((config, name) => {
159156
this._document.addEventListener(name, config.handler, config.options);

src/cdk/drag-drop/drag-drop.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export class DragDrop {
4747
* @param element Element to which to attach the drop list functionality.
4848
*/
4949
createDropList<T = any>(element: ElementRef<HTMLElement> | HTMLElement): DropListRef<T> {
50-
return new DropListRef<T>(element, this._dragDropRegistry, this._document);
50+
return new DropListRef<T>(element, this._dragDropRegistry, this._document, this._ngZone,
51+
this._viewportRuler);
5152
}
5253
}

src/cdk/drag-drop/drag-ref.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {Direction} from '@angular/cdk/bidi';
1212
import {normalizePassiveListenerOptions} from '@angular/cdk/platform';
1313
import {coerceBooleanProperty, coerceElement} from '@angular/cdk/coercion';
1414
import {Subscription, Subject, Observable} from 'rxjs';
15+
import {startWith} from 'rxjs/operators';
1516
import {DropListRefInternal as DropListRef} from './drop-list-ref';
1617
import {DragDropRegistry} from './drag-drop-registry';
1718
import {extendStyles, toggleNativeDragInteractions} from './drag-styling';
@@ -155,6 +156,9 @@ export class DragRef<T = any> {
155156
/** Subscription to the event that is dispatched when the user lifts their pointer. */
156157
private _pointerUpSubscription = Subscription.EMPTY;
157158

159+
/** Subscription to the viewport being scrolled. */
160+
private _scrollSubscription = Subscription.EMPTY;
161+
158162
/**
159163
* Time at which the last touch event occurred. Used to avoid firing the same
160164
* events multiple times on touch devices where the browser will fire a fake
@@ -446,10 +450,20 @@ export class DragRef<T = any> {
446450
return this;
447451
}
448452

453+
/** Updates the item's sort order based on the last-known pointer position. */
454+
_sortFromLastPointerPosition() {
455+
const position = this._pointerPositionAtLastDirectionChange;
456+
457+
if (position && this._dropContainer) {
458+
this._updateActiveDropContainer(position);
459+
}
460+
}
461+
449462
/** Unsubscribes from the global subscriptions. */
450463
private _removeSubscriptions() {
451464
this._pointerMoveSubscription.unsubscribe();
452465
this._pointerUpSubscription.unsubscribe();
466+
this._scrollSubscription.unsubscribe();
453467
}
454468

455469
/** Destroys the preview element and its ViewRef. */
@@ -593,7 +607,14 @@ export class DragRef<T = any> {
593607

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

596-
if (!this._dropContainer) {
610+
if (this._dropContainer) {
611+
// Stop scrolling immediately, instead of waiting for the animation to finish.
612+
this._dropContainer._stopScrolling();
613+
this._animatePreviewToPlaceholder().then(() => {
614+
this._cleanupDragArtifacts(event);
615+
this._dragDropRegistry.stopDragging(this);
616+
});
617+
} else {
597618
// Convert the active transform into a passive one. This means that next time
598619
// the user starts dragging the item, its position will be calculated relatively
599620
// to the new passive transform.
@@ -606,13 +627,7 @@ export class DragRef<T = any> {
606627
});
607628
});
608629
this._dragDropRegistry.stopDragging(this);
609-
return;
610630
}
611-
612-
this._animatePreviewToPlaceholder().then(() => {
613-
this._cleanupDragArtifacts(event);
614-
this._dragDropRegistry.stopDragging(this);
615-
});
616631
}
617632

618633
/** Starts the dragging sequence. */
@@ -695,8 +710,9 @@ export class DragRef<T = any> {
695710
this._removeSubscriptions();
696711
this._pointerMoveSubscription = this._dragDropRegistry.pointerMove.subscribe(this._pointerMove);
697712
this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe(this._pointerUp);
698-
699-
this._scrollPosition = this._viewportRuler.getViewportScrollPosition();
713+
this._scrollSubscription = this._dragDropRegistry.scroll.pipe(startWith(null)).subscribe(() => {
714+
this._scrollPosition = this._viewportRuler.getViewportScrollPosition();
715+
});
700716

701717
if (this._boundaryElement) {
702718
this._boundaryRect = this._boundaryElement.getBoundingClientRect();
@@ -789,6 +805,7 @@ export class DragRef<T = any> {
789805
});
790806
}
791807

808+
this._dropContainer!._startScrollingIfNecessary(x, y);
792809
this._dropContainer!._sortItem(this, x, y, this._pointerDirectionDelta);
793810
this._preview.style.transform =
794811
getTransform(x - this._pickupPositionInElement.x, y - this._pickupPositionInElement.y);

0 commit comments

Comments
 (0)