Skip to content

Commit 156361a

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 156361a

File tree

9 files changed

+858
-85
lines changed

9 files changed

+858
-85
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: 468 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('cdkDropListAutoScrollDisabled')
134+
autoScrollDisabled: boolean = false;
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.autoScrollDisabled = this.autoScrollDisabled;
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 & 10 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';
@@ -46,7 +47,6 @@ const activeEventListenerOptions = normalizePassiveListenerOptions({passive: fal
4647
*/
4748
const MOUSE_EVENT_IGNORE_TIME = 800;
4849

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

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

158+
/** Subscription to the viewport being scrolled. */
159+
private _scrollSubscription = Subscription.EMPTY;
160+
158161
/**
159162
* Time at which the last touch event occurred. Used to avoid firing the same
160163
* events multiple times on touch devices where the browser will fire a fake
@@ -446,10 +449,20 @@ export class DragRef<T = any> {
446449
return this;
447450
}
448451

452+
/** Updates the item's sort order based on the last-known pointer position. */
453+
_sortFromLastPointerPosition() {
454+
const position = this._pointerPositionAtLastDirectionChange;
455+
456+
if (position && this._dropContainer) {
457+
this._updateActiveDropContainer(position);
458+
}
459+
}
460+
449461
/** Unsubscribes from the global subscriptions. */
450462
private _removeSubscriptions() {
451463
this._pointerMoveSubscription.unsubscribe();
452464
this._pointerUpSubscription.unsubscribe();
465+
this._scrollSubscription.unsubscribe();
453466
}
454467

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

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

596-
if (!this._dropContainer) {
609+
if (this._dropContainer) {
610+
// Stop scrolling immediately, instead of waiting for the animation to finish.
611+
this._dropContainer._stopScrolling();
612+
this._animatePreviewToPlaceholder().then(() => {
613+
this._cleanupDragArtifacts(event);
614+
this._dragDropRegistry.stopDragging(this);
615+
});
616+
} else {
597617
// Convert the active transform into a passive one. This means that next time
598618
// the user starts dragging the item, its position will be calculated relatively
599619
// to the new passive transform.
@@ -606,13 +626,7 @@ export class DragRef<T = any> {
606626
});
607627
});
608628
this._dragDropRegistry.stopDragging(this);
609-
return;
610629
}
611-
612-
this._animatePreviewToPlaceholder().then(() => {
613-
this._cleanupDragArtifacts(event);
614-
this._dragDropRegistry.stopDragging(this);
615-
});
616630
}
617631

618632
/** Starts the dragging sequence. */
@@ -695,8 +709,9 @@ export class DragRef<T = any> {
695709
this._removeSubscriptions();
696710
this._pointerMoveSubscription = this._dragDropRegistry.pointerMove.subscribe(this._pointerMove);
697711
this._pointerUpSubscription = this._dragDropRegistry.pointerUp.subscribe(this._pointerUp);
698-
699-
this._scrollPosition = this._viewportRuler.getViewportScrollPosition();
712+
this._scrollSubscription = this._dragDropRegistry.scroll.pipe(startWith(null)).subscribe(() => {
713+
this._scrollPosition = this._viewportRuler.getViewportScrollPosition();
714+
});
700715

701716
if (this._boundaryElement) {
702717
this._boundaryRect = this._boundaryElement.getBoundingClientRect();
@@ -789,6 +804,7 @@ export class DragRef<T = any> {
789804
});
790805
}
791806

807+
this._dropContainer!._startScrollingIfNecessary(x, y);
792808
this._dropContainer!._sortItem(this, x, y, this._pointerDirectionDelta);
793809
this._preview.style.transform =
794810
getTransform(x - this._pickupPositionInElement.x, y - this._pickupPositionInElement.y);

0 commit comments

Comments
 (0)