Skip to content

Commit 619794f

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 9ab2c90 commit 619794f

File tree

2 files changed

+55
-5
lines changed

2 files changed

+55
-5
lines changed

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

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

341-
});
341+
it('should not dispatch multiple events for a mouse event right after a touch event',
342+
fakeAsync(() => {
343+
const fixture = createComponent(StandaloneDraggable);
344+
fixture.detectChanges();
345+
346+
const dragElement = fixture.componentInstance.dragElement.nativeElement;
347+
348+
// Dispatch a touch sequence.
349+
dispatchTouchEvent(dragElement, 'touchstart');
350+
fixture.detectChanges();
351+
dispatchTouchEvent(dragElement, 'touchend');
352+
fixture.detectChanges();
353+
tick();
354+
355+
// Immediately dispatch a mouse sequence to simulate a fake event.
356+
dispatchMouseEvent(dragElement, 'mousedown');
357+
fixture.detectChanges();
358+
dispatchMouseEvent(dragElement, 'mouseup');
359+
fixture.detectChanges();
360+
tick();
361+
362+
expect(fixture.componentInstance.startedSpy).toHaveBeenCalledTimes(1);
363+
expect(fixture.componentInstance.endedSpy).toHaveBeenCalledTimes(1);
364+
}));
365+
366+
});
342367

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

src/cdk/drag-drop/drag.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,14 @@ import {CDK_DROP_CONTAINER, CdkDropContainer} from './drop-container';
5454
*/
5555
const POINTER_DIRECTION_CHANGE_THRESHOLD = 5;
5656

57+
/**
58+
* Time in milliseconds for which to ignore mouse events, after
59+
* receiving a touch event. Used to avoid doing double work for
60+
* touch devices where the browser fires fake mouse events, in
61+
* addition to touch events.
62+
*/
63+
const MOUSE_EVENT_IGNORE_TIME = 800;
64+
5765
/** Element that can be moved inside a CdkDrop container. */
5866
@Directive({
5967
selector: '[cdkDrag]',
@@ -129,6 +137,13 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
129137
/** Root element that will be dragged by the user. */
130138
private _rootElement: HTMLElement;
131139

140+
/**
141+
* Time at which the last touch event occurred. Used to avoid firing the same
142+
* events multiple times on touch devices where the browser will fire a fake
143+
* mouse event for each touch event, after a certain time.
144+
*/
145+
private _lastTouchEventTime: number;
146+
132147
/** Elements that can be used to drag the draggable item. */
133148
@ContentChildren(CdkDragHandle) _handles: QueryList<CdkDragHandle>;
134149

@@ -276,9 +291,13 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
276291
event: MouseEvent | TouchEvent) => {
277292

278293
const isDragging = this._isDragging();
294+
const isTouchEvent = this._isTouchEvent(event);
295+
const isAuxiliaryMouseButton = !isTouchEvent && (event as MouseEvent).button !== 0;
296+
const isSyntheticEvent = !isTouchEvent && this._lastTouchEventTime &&
297+
this._lastTouchEventTime + MOUSE_EVENT_IGNORE_TIME > Date.now();
279298

280299
// Abort if the user is already dragging or is using a mouse button other than the primary one.
281-
if (isDragging || (!this._isTouchEvent(event) && event.button !== 0)) {
300+
if (isDragging || isAuxiliaryMouseButton || isSyntheticEvent) {
282301
return;
283302
}
284303

@@ -309,6 +328,10 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
309328
// Emit the event on the item before the one on the container.
310329
this.started.emit({source: this});
311330

331+
if (isTouchEvent) {
332+
this._lastTouchEventTime = Date.now();
333+
}
334+
312335
if (this.dropContainer) {
313336
const element = this._rootElement;
314337

@@ -375,19 +398,21 @@ export class CdkDrag<T = any> implements AfterViewInit, OnDestroy {
375398
return;
376399
}
377400

378-
this._dragDropRegistry.stopDragging(this);
379-
380401
if (!this.dropContainer) {
381402
// Convert the active transform into a passive one. This means that next time
382403
// the user starts dragging the item, its position will be calculated relatively
383404
// to the new passive transform.
384405
this._passiveTransform.x = this._activeTransform.x;
385406
this._passiveTransform.y = this._activeTransform.y;
386407
this._ngZone.run(() => this.ended.emit({source: this}));
408+
this._dragDropRegistry.stopDragging(this);
387409
return;
388410
}
389411

390-
this._animatePreviewToPlaceholder().then(() => this._cleanupDragArtifacts());
412+
this._animatePreviewToPlaceholder().then(() => {
413+
this._cleanupDragArtifacts();
414+
this._dragDropRegistry.stopDragging(this);
415+
});
391416
}
392417

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

0 commit comments

Comments
 (0)