Skip to content

Commit 2b87abb

Browse files
committed
fix(drag-drop): events fired multiple times for short drag sequences on touch devices
Fixes the `started` and `ended` events being fired multiple times for short drag sequences on touch devices. The issue comes from the fact that we listen both for mouse and touch events, which means that we also pick up the fake events that are fired by mobile browsers. Fixes #13125.
1 parent efeefd1 commit 2b87abb

File tree

2 files changed

+56
-5
lines changed

2 files changed

+56
-5
lines changed

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -412,7 +412,32 @@ describe('CdkDrag', () => {
412412
expect(dragElement.style.transform).toBeFalsy();
413413
}));
414414

415-
});
415+
it('should not dispatch multiple events for a mouse event right after a touch event',
416+
fakeAsync(() => {
417+
const fixture = createComponent(StandaloneDraggable);
418+
fixture.detectChanges();
419+
420+
const dragElement = fixture.componentInstance.dragElement.nativeElement;
421+
422+
// Dispatch a touch sequence.
423+
dispatchTouchEvent(dragElement, 'touchstart');
424+
fixture.detectChanges();
425+
dispatchTouchEvent(dragElement, 'touchend');
426+
fixture.detectChanges();
427+
tick();
428+
429+
// Immediately dispatch a mouse sequence to simulate a fake event.
430+
startDraggingViaMouse(fixture, dragElement);
431+
fixture.detectChanges();
432+
dispatchMouseEvent(dragElement, 'mouseup');
433+
fixture.detectChanges();
434+
tick();
435+
436+
expect(fixture.componentInstance.startedSpy).toHaveBeenCalledTimes(1);
437+
expect(fixture.componentInstance.endedSpy).toHaveBeenCalledTimes(1);
438+
}));
439+
440+
});
416441

417442
describe('draggable with a handle', () => {
418443
it('should not be able to drag the entire element if it has a handle', fakeAsync(() => {

src/cdk/drag-drop/drag.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ export function CDK_DRAG_CONFIG_FACTORY(): CdkDragConfig {
8383
const passiveEventListenerOptions = supportsPassiveEventListeners() ?
8484
{passive: true} as EventListenerOptions : false;
8585

86+
/**
87+
* Time in milliseconds for which to ignore mouse events, after
88+
* receiving a touch event. Used to avoid doing double work for
89+
* touch devices where the browser fires fake mouse events, in
90+
* addition to touch events.
91+
*/
92+
const MOUSE_EVENT_IGNORE_TIME = 800;
93+
8694
/** Element that can be moved inside a CdkDropList container. */
8795
@Directive({
8896
selector: '[cdkDrag]',
@@ -175,6 +183,12 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
175183

176184
/** Subscription to the event that is dispatched when the user lifts their pointer. */
177185
private _pointerUpSubscription = Subscription.EMPTY;
186+
/**
187+
* Time at which the last touch event occurred. Used to avoid firing the same
188+
* events multiple times on touch devices where the browser will fire a fake
189+
* mouse event for each touch event, after a certain time.
190+
*/
191+
private _lastTouchEventTime: number;
178192

179193
/** Elements that can be used to drag the draggable item. */
180194
@ContentChildren(CdkDragHandle, {descendants: true}) _handles: QueryList<CdkDragHandle>;
@@ -330,9 +344,13 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
330344
*/
331345
private _initializeDragSequence(referenceElement: HTMLElement, event: MouseEvent | TouchEvent) {
332346
const isDragging = this._isDragging();
347+
const isTouchEvent = this._isTouchEvent(event);
348+
const isAuxiliaryMouseButton = !isTouchEvent && (event as MouseEvent).button !== 0;
349+
const isSyntheticEvent = !isTouchEvent && this._lastTouchEventTime &&
350+
this._lastTouchEventTime + MOUSE_EVENT_IGNORE_TIME > Date.now();
333351

334352
// Abort if the user is already dragging or is using a mouse button other than the primary one.
335-
if (isDragging || (!this._isTouchEvent(event) && event.button !== 0)) {
353+
if (isDragging || isAuxiliaryMouseButton || isSyntheticEvent) {
336354
return;
337355
}
338356

@@ -359,10 +377,14 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
359377
}
360378

361379
/** Starts the dragging sequence. */
362-
private _startDragSequence() {
380+
private _startDragSequence(event: MouseEvent | TouchEvent) {
363381
// Emit the event on the item before the one on the container.
364382
this.started.emit({source: this});
365383

384+
if (this._isTouchEvent(event)) {
385+
this._lastTouchEventTime = Date.now();
386+
}
387+
366388
if (this.dropContainer) {
367389
const element = this._rootElement;
368390

@@ -397,7 +419,7 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
397419
// per pixel of movement (e.g. if the user moves their pointer quickly).
398420
if (distanceX + distanceY >= this._config.dragStartThreshold) {
399421
this._hasStartedDragging = true;
400-
this._ngZone.run(() => this._startDragSequence());
422+
this._ngZone.run(() => this._startDragSequence(event));
401423
}
402424

403425
return;
@@ -457,10 +479,14 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
457479
this._passiveTransform.x = this._activeTransform.x;
458480
this._passiveTransform.y = this._activeTransform.y;
459481
this._ngZone.run(() => this.ended.emit({source: this}));
482+
this._dragDropRegistry.stopDragging(this);
460483
return;
461484
}
462485

463-
this._animatePreviewToPlaceholder().then(() => this._cleanupDragArtifacts());
486+
this._animatePreviewToPlaceholder().then(() => {
487+
this._cleanupDragArtifacts();
488+
this._dragDropRegistry.stopDragging(this);
489+
});
464490
}
465491

466492
/** Cleans up the DOM artifacts that were added to facilitate the element being dragged. */

0 commit comments

Comments
 (0)