Skip to content

Commit ff5cb6f

Browse files
committed
fix(drag-drop): sorting too often if pointer is inside element after position swap
Currently we check whether the user's pointer overlaps an item for each pixel that they've moved. This works fine when the items have a similar height, however it breaks down if there's a position swap between a very tall item and a smaller one. These changes rework the logic to take into account the direction that the user is moving in, before doing a position swap. Fixes angular#12694.
1 parent cfa2d04 commit ff5cb6f

File tree

4 files changed

+157
-9
lines changed

4 files changed

+157
-9
lines changed

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,86 @@ describe('CdkDrag', () => {
843843
flush();
844844
}));
845845

846+
it('should not swap position for tiny pointer movements', fakeAsync(() => {
847+
const fixture = createComponent(DraggableInDropZone);
848+
fixture.detectChanges();
849+
850+
const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement);
851+
const draggedItem = items[0];
852+
const target = items[1];
853+
const {top, left} = draggedItem.getBoundingClientRect();
854+
855+
// Bump the height so the pointer doesn't leave after swapping.
856+
target.style.height = `${ITEM_HEIGHT * 3}px`;
857+
858+
dispatchMouseEvent(draggedItem, 'mousedown', left, top);
859+
fixture.detectChanges();
860+
861+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
862+
863+
expect(getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()))
864+
.toEqual(['Zero', 'One', 'Two', 'Three']);
865+
866+
const targetRect = target.getBoundingClientRect();
867+
const pointerTop = targetRect.top + 20;
868+
869+
// Move over the target so there's a 20px overlap.
870+
dispatchMouseEvent(document, 'mousemove', targetRect.left, pointerTop);
871+
fixture.detectChanges();
872+
expect(getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()))
873+
.toEqual(['One', 'Zero', 'Two', 'Three'], 'Expected position to swap.');
874+
875+
// Move down a further 1px.
876+
dispatchMouseEvent(document, 'mousemove', targetRect.left, pointerTop + 1);
877+
fixture.detectChanges();
878+
expect(getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()))
879+
.toEqual(['One', 'Zero', 'Two', 'Three'], 'Expected positions not to swap.');
880+
881+
dispatchMouseEvent(document, 'mouseup');
882+
fixture.detectChanges();
883+
flush();
884+
}));
885+
886+
it('should swap position for pointer movements in the opposite direction', fakeAsync(() => {
887+
const fixture = createComponent(DraggableInDropZone);
888+
fixture.detectChanges();
889+
890+
const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement);
891+
const draggedItem = items[0];
892+
const target = items[1];
893+
const {top, left} = draggedItem.getBoundingClientRect();
894+
895+
// Bump the height so the pointer doesn't leave after swapping.
896+
target.style.height = `${ITEM_HEIGHT * 3}px`;
897+
898+
dispatchMouseEvent(draggedItem, 'mousedown', left, top);
899+
fixture.detectChanges();
900+
901+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
902+
903+
expect(getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()))
904+
.toEqual(['Zero', 'One', 'Two', 'Three']);
905+
906+
const targetRect = target.getBoundingClientRect();
907+
const pointerTop = targetRect.top + 20;
908+
909+
// Move over the target so there's a 20px overlap.
910+
dispatchMouseEvent(document, 'mousemove', targetRect.left, pointerTop);
911+
fixture.detectChanges();
912+
expect(getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()))
913+
.toEqual(['One', 'Zero', 'Two', 'Three'], 'Expected position to swap.');
914+
915+
// Move up 10px.
916+
dispatchMouseEvent(document, 'mousemove', targetRect.left, pointerTop - 10);
917+
fixture.detectChanges();
918+
expect(getElementSibligsByPosition(placeholder, 'top').map(e => e.textContent!.trim()))
919+
.toEqual(['Zero', 'One', 'Two', 'Three'], 'Expected positions to swap again.');
920+
921+
dispatchMouseEvent(document, 'mouseup');
922+
fixture.detectChanges();
923+
flush();
924+
}));
925+
846926
it('should clean up the preview element if the item is destroyed mid-drag', fakeAsync(() => {
847927
const fixture = createComponent(DraggableInDropZone);
848928
fixture.detectChanges();

src/cdk/drag-drop/drag.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ import {takeUntil} from 'rxjs/operators';
4646
// TODO(crisbeto): add an API for moving a draggable up/down the
4747
// list programmatically. Useful for keyboard controls.
4848

49+
/**
50+
* Amount the pixels the user should drag before we
51+
* consider them to have changed the drag direction.
52+
*/
53+
const DELTA_CHANGE_THRESHOLD = 5;
54+
4955
/** Element that can be moved inside a CdkDrop container. */
5056
@Directive({
5157
selector: '[cdkDrag]',
@@ -114,6 +120,12 @@ export class CdkDrag<T = any> implements OnDestroy {
114120
*/
115121
private _moveEventSubscriptions = 0;
116122

123+
/** Keeps track of the direction in which the user is dragging along each axis. */
124+
private _delta: {
125+
/** Pointer position at which the last change in the delta occurred. */
126+
positionAtLastChange: Point
127+
} & Point;
128+
117129
/** Elements that can be used to drag the draggable item. */
118130
@ContentChildren(CdkDragHandle) _handles: QueryList<CdkDragHandle>;
119131

@@ -255,7 +267,9 @@ export class CdkDrag<T = any> implements OnDestroy {
255267
// extra `getBoundingClientRect` calls and just move the preview next to the cursor.
256268
this._pickupPositionInElement = this._previewTemplate ? {x: 0, y: 0} :
257269
this._getPointerPositionInElement(referenceElement, event);
258-
this._pickupPositionOnPage = this._getPointerPositionOnPage(event);
270+
const pointerPosition = this._pickupPositionOnPage = this._getPointerPositionOnPage(event);
271+
272+
this._delta = {x: 0, y: 0, positionAtLastChange: {x: pointerPosition.x, y: pointerPosition.y}};
259273

260274
// Emit the event on the item before the one on the container.
261275
this.started.emit({source: this});
@@ -292,6 +306,7 @@ export class CdkDrag<T = any> implements OnDestroy {
292306
event.preventDefault();
293307

294308
const pointerPosition = this._getConstrainedPointerPosition(event);
309+
this._updateDelta(pointerPosition);
295310

296311
if (this.dropContainer) {
297312
this._updateActiveDropContainer(pointerPosition);
@@ -392,7 +407,7 @@ export class CdkDrag<T = any> implements OnDestroy {
392407
});
393408
}
394409

395-
this.dropContainer._sortItem(this, x, y);
410+
this.dropContainer._sortItem(this, x, y, this._delta);
396411
this._setTransform(this._preview,
397412
x - this._pickupPositionInElement.x,
398413
y - this._pickupPositionInElement.y);
@@ -590,6 +605,30 @@ export class CdkDrag<T = any> implements OnDestroy {
590605

591606
this._placeholder = this._placeholderRef = null!;
592607
}
608+
609+
/** Updates the current drag delta, based on the user's current pointer position on the page. */
610+
private _updateDelta(pointerPositionOnPage: Point) {
611+
const {x, y} = pointerPositionOnPage;
612+
const delta = this._delta;
613+
614+
// Amount of pixels the user has dragged since the last time the direction changed.
615+
const changeX = Math.abs(x - delta.positionAtLastChange.x);
616+
const changeY = Math.abs(y - delta.positionAtLastChange.y);
617+
618+
// Because we handle pointer events on a per-pixel basis, we don't want the delta
619+
// to change for every pixel, otherwise anything that depends on it can look erratic.
620+
// To make the delta more consistent, we track how much the user has moved since the last
621+
// delta change and we only update it after it has reached a certain threshold.
622+
if (changeX > DELTA_CHANGE_THRESHOLD) {
623+
delta.x = x > delta.positionAtLastChange.x ? 1 : -1;
624+
delta.positionAtLastChange.x = x;
625+
}
626+
627+
if (changeY > DELTA_CHANGE_THRESHOLD) {
628+
delta.y = y > delta.positionAtLastChange.y ? 1 : -1;
629+
delta.positionAtLastChange.y = y;
630+
}
631+
}
593632
}
594633

595634
/** Parses a CSS time value to milliseconds. */

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export interface CdkDropContainer<T = any> {
5252
* @param item Item whose index should be determined.
5353
*/
5454
getItemIndex(item: CdkDrag): number;
55-
_sortItem(item: CdkDrag, pointerX: number, pointerY: number): void;
55+
_sortItem(item: CdkDrag, pointerX: number, pointerY: number, delta: {x: number, y: number}): void;
5656
_draggables: QueryList<CdkDrag>;
5757
_getSiblingContainerFromPosition(item: CdkDrag, x: number, y: number): CdkDropContainer | null;
5858
}

src/cdk/drag-drop/drop.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
129129
*/
130130
private _activeDraggables: CdkDrag[];
131131

132+
/**
133+
* Keeps track of the item that was last swapped with the dragged item, as
134+
* well as what direction the pointer was moving in when the swap occured.
135+
*/
136+
private _previousSwap = {drag: null as CdkDrag | null, delta: 0};
137+
132138
/** Starts dragging an item. */
133139
start(): void {
134140
this._dragging = true;
@@ -220,26 +226,32 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
220226
* @param item Item to be sorted.
221227
* @param pointerX Position of the item along the X axis.
222228
* @param pointerY Position of the item along the Y axis.
229+
* @param pointerDeta Direction in which the pointer is moving along each axis.
223230
*/
224-
_sortItem(item: CdkDrag, pointerX: number, pointerY: number): void {
231+
_sortItem(item: CdkDrag, pointerX: number, pointerY: number,
232+
pointerDelta: {x: number, y: number}): void {
225233
// Don't sort the item if it's out of range.
226234
if (!this._isPointerNearDropContainer(pointerX, pointerY)) {
227235
return;
228236
}
229237

230238
const siblings = this._positionCache.items;
231-
const newIndex = this._getItemIndexFromPointerPosition(item, pointerX, pointerY);
239+
const newIndex = this._getItemIndexFromPointerPosition(item, pointerX, pointerY, pointerDelta);
232240

233241
if (newIndex === -1 && siblings.length > 0) {
234242
return;
235243
}
236244

237245
const isHorizontal = this.orientation === 'horizontal';
238246
const currentIndex = siblings.findIndex(currentItem => currentItem.drag === item);
247+
const siblingAtNewPosition = siblings[newIndex];
239248
const currentPosition = siblings[currentIndex].clientRect;
240-
const newPosition = siblings[newIndex].clientRect;
249+
const newPosition = siblingAtNewPosition.clientRect;
241250
const delta = currentIndex > newIndex ? 1 : -1;
242251

252+
this._previousSwap.drag = siblingAtNewPosition.drag;
253+
this._previousSwap.delta = isHorizontal ? pointerDelta.x : pointerDelta.y;
254+
243255
// How many pixels the item's placeholder should be offset.
244256
const itemOffset = isHorizontal ? newPosition.left - currentPosition.left :
245257
newPosition.top - currentPosition.top;
@@ -346,6 +358,8 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
346358
this._activeDraggables = [];
347359
this._positionCache.items = [];
348360
this._positionCache.siblings = [];
361+
this._previousSwap.drag = null;
362+
this._previousSwap.delta = 0;
349363
}
350364

351365
/**
@@ -367,18 +381,33 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
367381
* @param item Item that is being sorted.
368382
* @param pointerX Position of the user's pointer along the X axis.
369383
* @param pointerY Position of the user's pointer along the Y axis.
384+
* @param delta Direction in which the user is moving their pointer.
370385
*/
371-
private _getItemIndexFromPointerPosition(item: CdkDrag, pointerX: number, pointerY: number) {
386+
private _getItemIndexFromPointerPosition(item: CdkDrag, pointerX: number, pointerY: number,
387+
delta?: {x: number, y: number}) {
388+
389+
const isHorizontal = this.orientation === 'horizontal';
390+
372391
return this._positionCache.items.findIndex(({drag, clientRect}, _, array) => {
373392
if (drag === item) {
374393
// If there's only one item left in the container, it must be
375394
// the dragged item itself so we use it as a reference.
376395
return array.length < 2;
377396
}
378397

379-
return this.orientation === 'horizontal' ?
398+
if (delta) {
399+
const direction = isHorizontal ? delta.x : delta.y;
400+
401+
// If the user is still hovering over the same item as last time, and they didn't change
402+
// the direction in which they're dragging, we don't consider it a direction swap.
403+
if (drag === this._previousSwap.drag && direction === this._previousSwap.delta) {
404+
return false;
405+
}
406+
}
407+
408+
return isHorizontal ?
380409
// Round these down since most browsers report client rects with
381-
// sub-pixel precision, whereas the mouse coordinates are rounded to pixels.
410+
// sub-pixel precision, whereas the pointer coordinates are rounded to pixels.
382411
pointerX >= Math.floor(clientRect.left) && pointerX <= Math.floor(clientRect.right) :
383412
pointerY >= Math.floor(clientRect.top) && pointerY <= Math.floor(clientRect.bottom);
384413
});

0 commit comments

Comments
 (0)