Skip to content

Commit 6867190

Browse files
committed
fix(drag-drop): incorrectly laying out items with different height or margins
Currently the `CdkDropList` makes some assumptions about the items not having a margin between each other. It also won't lay out and animate items with varying heights, in some cases. The following changes rework the directive to allow it to handle both varying heights and different margins. Fixes #13483.
1 parent 0bd8899 commit 6867190

File tree

2 files changed

+272
-15
lines changed

2 files changed

+272
-15
lines changed

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

Lines changed: 191 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,92 @@ describe('CdkDrag', () => {
10681068
flush();
10691069
}));
10701070

1071+
it('should lay out the elements correctly, when swapping down with a taller element',
1072+
fakeAsync(() => {
1073+
const fixture = createComponent(DraggableInDropZone);
1074+
fixture.detectChanges();
1075+
1076+
const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement);
1077+
const {top, left} = items[0].getBoundingClientRect();
1078+
1079+
fixture.componentInstance.items[0].height = ITEM_HEIGHT * 2;
1080+
fixture.detectChanges();
1081+
1082+
startDraggingViaMouse(fixture, items[0], left, top);
1083+
1084+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
1085+
const target = items[1];
1086+
const targetRect = target.getBoundingClientRect();
1087+
1088+
// Add a few pixels to the top offset so we get some overlap.
1089+
dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.top + 5);
1090+
fixture.detectChanges();
1091+
1092+
expect(placeholder.style.transform).toBe(`translate3d(0px, ${ITEM_HEIGHT}px, 0px)`);
1093+
expect(target.style.transform).toBe(`translate3d(0px, ${-ITEM_HEIGHT * 2}px, 0px)`);
1094+
1095+
dispatchMouseEvent(document, 'mouseup');
1096+
fixture.detectChanges();
1097+
flush();
1098+
}));
1099+
1100+
it('should lay out the elements correctly, when swapping up with a taller element',
1101+
fakeAsync(() => {
1102+
const fixture = createComponent(DraggableInDropZone);
1103+
fixture.detectChanges();
1104+
1105+
const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement);
1106+
const {top, left} = items[1].getBoundingClientRect();
1107+
1108+
fixture.componentInstance.items[1].height = ITEM_HEIGHT * 2;
1109+
fixture.detectChanges();
1110+
1111+
startDraggingViaMouse(fixture, items[1], left, top);
1112+
1113+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
1114+
const target = items[0];
1115+
const targetRect = target.getBoundingClientRect();
1116+
1117+
// Add a few pixels to the top offset so we get some overlap.
1118+
dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.bottom - 5);
1119+
fixture.detectChanges();
1120+
1121+
expect(placeholder.style.transform).toBe(`translate3d(0px, ${-ITEM_HEIGHT}px, 0px)`);
1122+
expect(target.style.transform).toBe(`translate3d(0px, ${ITEM_HEIGHT * 2}px, 0px)`);
1123+
1124+
dispatchMouseEvent(document, 'mouseup');
1125+
fixture.detectChanges();
1126+
flush();
1127+
}));
1128+
1129+
it('should lay out elements correctly, when swapping an item with margin', fakeAsync(() => {
1130+
const fixture = createComponent(DraggableInDropZone);
1131+
fixture.detectChanges();
1132+
1133+
const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement);
1134+
const {top, left} = items[0].getBoundingClientRect();
1135+
1136+
fixture.componentInstance.items[0].margin = 12;
1137+
fixture.detectChanges();
1138+
1139+
startDraggingViaMouse(fixture, items[0], left, top);
1140+
1141+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
1142+
const target = items[1];
1143+
const targetRect = target.getBoundingClientRect();
1144+
1145+
// Add a few pixels to the top offset so we get some overlap.
1146+
dispatchMouseEvent(document, 'mousemove', targetRect.left, targetRect.top + 5);
1147+
fixture.detectChanges();
1148+
1149+
expect(placeholder.style.transform).toBe(`translate3d(0px, ${ITEM_HEIGHT + 12}px, 0px)`);
1150+
expect(target.style.transform).toBe(`translate3d(0px, ${-ITEM_HEIGHT - 12}px, 0px)`);
1151+
1152+
dispatchMouseEvent(document, 'mouseup');
1153+
fixture.detectChanges();
1154+
flush();
1155+
}));
1156+
10711157
it('should lay out the elements correctly, if an element skips multiple positions when ' +
10721158
'sorting horizontally', fakeAsync(() => {
10731159
const fixture = createComponent(DraggableInHorizontalDropZone);
@@ -1094,6 +1180,90 @@ describe('CdkDrag', () => {
10941180
flush();
10951181
}));
10961182

1183+
it('should lay out the elements correctly, when swapping to the right with a wider element',
1184+
fakeAsync(() => {
1185+
const fixture = createComponent(DraggableInHorizontalDropZone);
1186+
fixture.detectChanges();
1187+
1188+
const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement);
1189+
1190+
fixture.componentInstance.items[0].width = ITEM_WIDTH * 2;
1191+
fixture.detectChanges();
1192+
1193+
const {top, left} = items[0].getBoundingClientRect();
1194+
startDraggingViaMouse(fixture, items[0], left, top);
1195+
1196+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
1197+
const target = items[1];
1198+
const targetRect = target.getBoundingClientRect();
1199+
1200+
dispatchMouseEvent(document, 'mousemove', targetRect.right - 5, targetRect.top);
1201+
fixture.detectChanges();
1202+
1203+
expect(placeholder.style.transform).toBe(`translate3d(${ITEM_WIDTH}px, 0px, 0px)`);
1204+
expect(target.style.transform).toBe(`translate3d(${-ITEM_WIDTH * 2}px, 0px, 0px)`);
1205+
1206+
dispatchMouseEvent(document, 'mouseup');
1207+
fixture.detectChanges();
1208+
flush();
1209+
}));
1210+
1211+
it('should lay out the elements correctly, when swapping left with a wider element',
1212+
fakeAsync(() => {
1213+
const fixture = createComponent(DraggableInHorizontalDropZone);
1214+
fixture.detectChanges();
1215+
1216+
const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement);
1217+
const {top, left} = items[1].getBoundingClientRect();
1218+
1219+
fixture.componentInstance.items[1].width = ITEM_WIDTH * 2;
1220+
fixture.detectChanges();
1221+
1222+
startDraggingViaMouse(fixture, items[1], left, top);
1223+
1224+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
1225+
const target = items[0];
1226+
const targetRect = target.getBoundingClientRect();
1227+
1228+
dispatchMouseEvent(document, 'mousemove', targetRect.right - 5, targetRect.top);
1229+
fixture.detectChanges();
1230+
1231+
expect(placeholder.style.transform).toBe(`translate3d(${-ITEM_WIDTH}px, 0px, 0px)`);
1232+
expect(target.style.transform).toBe(`translate3d(${ITEM_WIDTH * 2}px, 0px, 0px)`);
1233+
1234+
dispatchMouseEvent(document, 'mouseup');
1235+
fixture.detectChanges();
1236+
flush();
1237+
}));
1238+
1239+
it('should lay out elements correctly, when horizontally swapping an item with margin',
1240+
fakeAsync(() => {
1241+
const fixture = createComponent(DraggableInHorizontalDropZone);
1242+
fixture.detectChanges();
1243+
1244+
const items = fixture.componentInstance.dragItems.map(i => i.element.nativeElement);
1245+
const {top, left} = items[0].getBoundingClientRect();
1246+
1247+
fixture.componentInstance.items[0].margin = 12;
1248+
fixture.detectChanges();
1249+
1250+
startDraggingViaMouse(fixture, items[0], left, top);
1251+
1252+
const placeholder = document.querySelector('.cdk-drag-placeholder')! as HTMLElement;
1253+
const target = items[1];
1254+
const targetRect = target.getBoundingClientRect();
1255+
1256+
dispatchMouseEvent(document, 'mousemove', targetRect.right - 5, targetRect.top);
1257+
fixture.detectChanges();
1258+
1259+
expect(placeholder.style.transform).toBe(`translate3d(${ITEM_WIDTH + 12}px, 0px, 0px)`);
1260+
expect(target.style.transform).toBe(`translate3d(${-ITEM_WIDTH - 12}px, 0px, 0px)`);
1261+
1262+
dispatchMouseEvent(document, 'mouseup');
1263+
fixture.detectChanges();
1264+
flush();
1265+
}));
1266+
10971267
it('should not swap position for tiny pointer movements', fakeAsync(() => {
10981268
const fixture = createComponent(DraggableInDropZone);
10991269
fixture.detectChanges();
@@ -1819,14 +1989,21 @@ class StandaloneDraggableWithMultipleHandles {
18191989
*ngFor="let item of items"
18201990
cdkDrag
18211991
[cdkDragData]="item"
1822-
style="width: 100%; height: ${ITEM_HEIGHT}px; background: red;">{{item}}</div>
1992+
[style.height.px]="item.height"
1993+
[style.margin-bottom.px]="item.margin"
1994+
style="width: 100%; background: red;">{{item.value}}</div>
18231995
</div>
18241996
`
18251997
})
18261998
class DraggableInDropZone {
18271999
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
18282000
@ViewChild(CdkDropList) dropInstance: CdkDropList;
1829-
items = ['Zero', 'One', 'Two', 'Three'];
2001+
items = [
2002+
{value: 'Zero', height: ITEM_HEIGHT, margin: 0},
2003+
{value: 'One', height: ITEM_HEIGHT, margin: 0},
2004+
{value: 'Two', height: ITEM_HEIGHT, margin: 0},
2005+
{value: 'Three', height: ITEM_HEIGHT, margin: 0}
2006+
];
18302007
dropZoneId = 'items';
18312008
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop<string[]>) => {
18322009
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
@@ -1841,13 +2018,12 @@ class DraggableInDropZone {
18412018
`
18422019
.cdk-drop-list {
18432020
display: block;
1844-
width: 300px;
2021+
width: 500px;
18452022
background: pink;
18462023
font-size: 0;
18472024
}
18482025
18492026
.cdk-drag {
1850-
width: ${ITEM_WIDTH}px;
18512027
height: ${ITEM_HEIGHT}px;
18522028
background: red;
18532029
display: inline-block;
@@ -1859,14 +2035,23 @@ class DraggableInDropZone {
18592035
cdkDropListOrientation="horizontal"
18602036
[cdkDropListData]="items"
18612037
(cdkDropListDropped)="droppedSpy($event)">
1862-
<div *ngFor="let item of items" cdkDrag>{{item}}</div>
2038+
<div
2039+
*ngFor="let item of items"
2040+
[style.width.px]="item.width"
2041+
[style.margin-right.px]="item.margin"
2042+
cdkDrag>{{item.value}}</div>
18632043
</div>
18642044
`
18652045
})
18662046
class DraggableInHorizontalDropZone {
18672047
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
18682048
@ViewChild(CdkDropList) dropInstance: CdkDropList;
1869-
items = ['Zero', 'One', 'Two', 'Three'];
2049+
items = [
2050+
{value: 'Zero', width: ITEM_WIDTH, margin: 0},
2051+
{value: 'One', width: ITEM_WIDTH, margin: 0},
2052+
{value: 'Two', width: ITEM_WIDTH, margin: 0},
2053+
{value: 'Three', width: ITEM_WIDTH, margin: 0}
2054+
];
18702055
droppedSpy = jasmine.createSpy('dropped spy').and.callFake((event: CdkDragDrop<string[]>) => {
18712056
moveItemInArray(this.items, event.previousIndex, event.currentIndex);
18722057
});

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

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,34 @@ let _uniqueIdCounter = 0;
3737
*/
3838
const DROP_PROXIMITY_THRESHOLD = 0.05;
3939

40+
/** Object used to cache the position of a drag list, its items and siblings. */
41+
interface PositionCache {
42+
/** Cached positions of the items in the list. */
43+
items: ItemPositionCacheEntry[];
44+
/** Cached positions of the connected lists. */
45+
siblings: ListPositionCacheEntry[];
46+
/** Dimensions of the list itself. */
47+
self: ClientRect;
48+
}
49+
50+
/** Entry in the position cache for draggable items. */
51+
interface ItemPositionCacheEntry {
52+
/** Instance of the drag item. */
53+
drag: CdkDrag;
54+
/** Dimensions of the item. */
55+
clientRect: ClientRect;
56+
/** Amount by which the item has been moved since dragging started. */
57+
offset: number;
58+
}
59+
60+
/** Entry in the position cache for drop lists. */
61+
interface ListPositionCacheEntry {
62+
/** Instance of the drop list. */
63+
drop: CdkDropList;
64+
/** Dimensions of the list. */
65+
clientRect: ClientRect;
66+
}
67+
4068
/** Container that wraps a set of draggable items. */
4169
@Directive({
4270
selector: '[cdkDropList], cdk-drop-list',
@@ -118,11 +146,7 @@ export class CdkDropList<T = any> implements OnInit, OnDestroy {
118146
_dragging = false;
119147

120148
/** Cache of the dimensions of all the items and the sibling containers. */
121-
private _positionCache = {
122-
items: [] as {drag: CdkDrag, clientRect: ClientRect, offset: number}[],
123-
siblings: [] as {drop: CdkDropList, clientRect: ClientRect}[],
124-
self: {} as ClientRect
125-
};
149+
private _positionCache: PositionCache = {items: [], siblings: [], self: {} as ClientRect};
126150

127151
/**
128152
* Draggable items that are currently active inside the container. Includes the items
@@ -263,12 +287,10 @@ export class CdkDropList<T = any> implements OnInit, OnDestroy {
263287
this._previousSwap.delta = isHorizontal ? pointerDelta.x : pointerDelta.y;
264288

265289
// How many pixels the item's placeholder should be offset.
266-
const itemOffset = isHorizontal ? newPosition.left - currentPosition.left :
267-
newPosition.top - currentPosition.top;
290+
const itemOffset = this._getItemOffset(currentPosition, newPosition, delta);
268291

269292
// How many pixels all the other items should be offset.
270-
const siblingOffset = isHorizontal ? currentPosition.width * delta :
271-
currentPosition.height * delta;
293+
const siblingOffset = this._getSiblingOffset(currentIndex, siblings, delta);
272294

273295
// Save the previous order of the items before moving the item to its new index.
274296
// We use this to check whether an item has been moved as a result of the sorting.
@@ -449,6 +471,56 @@ export class CdkDropList<T = any> implements OnInit, OnDestroy {
449471
return pointerY > top - yThreshold && pointerY < bottom + yThreshold &&
450472
pointerX > left - xThreshold && pointerX < right + xThreshold;
451473
}
474+
475+
/**
476+
* Gets the offset by which the item that is being dragged should be moved.
477+
* @param currentPosition Current position of the item.
478+
* @param newPosition Position of the item where the current item should be moved.
479+
* @param delta Direction in which the user is moving.
480+
*/
481+
private _getItemOffset(currentPosition: ClientRect, newPosition: ClientRect, delta: number) {
482+
const isHorizontal = this.orientation === 'horizontal';
483+
let itemOffset = isHorizontal ? newPosition.left - currentPosition.left :
484+
newPosition.top - currentPosition.top;
485+
486+
// Account for differences in the item width/height.
487+
if (delta === -1) {
488+
itemOffset += isHorizontal ? newPosition.width - currentPosition.width :
489+
newPosition.height - currentPosition.height;
490+
}
491+
492+
return itemOffset;
493+
}
494+
495+
/**
496+
* Gets the offset by which the items that aren't being dragged should be moved.
497+
* @param currentIndex Index of the item currently being dragged.
498+
* @param siblings All of the items in the list.
499+
* @param delta Direction in which the user is moving.
500+
*/
501+
private _getSiblingOffset(currentIndex: number,
502+
siblings: ItemPositionCacheEntry[],
503+
delta: number) {
504+
505+
const isHorizontal = this.orientation === 'horizontal';
506+
const currentPosition = siblings[currentIndex].clientRect;
507+
const immediateSibling = siblings[currentIndex + delta * -1];
508+
let siblingOffset = currentPosition[isHorizontal ? 'width' : 'height'] * delta;
509+
510+
// Account for any margin between the current item and the one right before/after it.
511+
if (immediateSibling && delta !== 0) {
512+
const start = isHorizontal ? 'left' : 'top';
513+
const end = isHorizontal ? 'right' : 'bottom';
514+
515+
if (delta === -1) {
516+
siblingOffset -= immediateSibling.clientRect[start] - currentPosition[end];
517+
} else {
518+
siblingOffset += currentPosition[start] - immediateSibling.clientRect[end];
519+
}
520+
}
521+
522+
return siblingOffset;
523+
}
452524
}
453525

454526

0 commit comments

Comments
 (0)