Skip to content

Commit 5278f72

Browse files
crisbetojelbourn
authored andcommitted
fix(drag-drop): sorting too often if pointer is inside element after position swap (#12837)
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 #12694.
1 parent 29111cb commit 5278f72

File tree

4 files changed

+159
-9
lines changed

4 files changed

+159
-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: 43 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 POINTER_DIRECTION_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 _pointerDirectionDelta: {x: -1 | 0 | 1, y: -1 | 0 | 1};
125+
126+
/** Pointer position at which the last change in the delta occurred. */
127+
private _pointerPositionAtLastDirectionChange: Point;
128+
117129
/** Elements that can be used to drag the draggable item. */
118130
@ContentChildren(CdkDragHandle) _handles: QueryList<CdkDragHandle>;
119131

@@ -255,7 +267,10 @@ 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._pointerDirectionDelta = {x: 0, y: 0};
273+
this._pointerPositionAtLastDirectionChange = {x: pointerPosition.x, y: pointerPosition.y};
259274

260275
// Emit the event on the item before the one on the container.
261276
this.started.emit({source: this});
@@ -292,6 +307,7 @@ export class CdkDrag<T = any> implements OnDestroy {
292307
event.preventDefault();
293308

294309
const pointerPosition = this._getConstrainedPointerPosition(event);
310+
this._updatePointerDirectionDelta(pointerPosition);
295311

296312
if (this.dropContainer) {
297313
this._updateActiveDropContainer(pointerPosition);
@@ -392,7 +408,7 @@ export class CdkDrag<T = any> implements OnDestroy {
392408
});
393409
}
394410

395-
this.dropContainer._sortItem(this, x, y);
411+
this.dropContainer._sortItem(this, x, y, this._pointerDirectionDelta);
396412
this._setTransform(this._preview,
397413
x - this._pickupPositionInElement.x,
398414
y - this._pickupPositionInElement.y);
@@ -590,6 +606,31 @@ export class CdkDrag<T = any> implements OnDestroy {
590606

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

595636
/** 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)