Skip to content

Commit 89701ef

Browse files
crisbetojelbourn
authored andcommitted
fix(drag-drop): incorrectly laying out items with different height or margins (#13849)
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 50f999b commit 89701ef

File tree

2 files changed

+284
-15
lines changed

2 files changed

+284
-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: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,43 @@ let _uniqueIdCounter = 0;
3737
*/
3838
const DROP_PROXIMITY_THRESHOLD = 0.05;
3939

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

120157
/** 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-
};
158+
private _positionCache: PositionCache = {items: [], siblings: [], self: {} as ClientRect};
126159

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

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

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

273304
// Save the previous order of the items before moving the item to its new index.
274305
// We use this to check whether an item has been moved as a result of the sorting.
@@ -449,6 +480,59 @@ export class CdkDropList<T = any> implements OnInit, OnDestroy {
449480
return pointerY > top - yThreshold && pointerY < bottom + yThreshold &&
450481
pointerX > left - xThreshold && pointerX < right + xThreshold;
451482
}
483+
484+
/**
485+
* Gets the offset in pixels by which the item that is being dragged should be moved.
486+
* @param currentPosition Current position of the item.
487+
* @param newPosition Position of the item where the current item should be moved.
488+
* @param delta Direction in which the user is moving.
489+
*/
490+
private _getItemOffsetPx(currentPosition: ClientRect, newPosition: ClientRect, delta: 1 | -1) {
491+
const isHorizontal = this.orientation === 'horizontal';
492+
let itemOffset = isHorizontal ? newPosition.left - currentPosition.left :
493+
newPosition.top - currentPosition.top;
494+
495+
// Account for differences in the item width/height.
496+
if (delta === -1) {
497+
itemOffset += isHorizontal ? newPosition.width - currentPosition.width :
498+
newPosition.height - currentPosition.height;
499+
}
500+
501+
return itemOffset;
502+
}
503+
504+
/**
505+
* Gets the offset in pixels by which the items that aren't being dragged should be moved.
506+
* @param currentIndex Index of the item currently being dragged.
507+
* @param siblings All of the items in the list.
508+
* @param delta Direction in which the user is moving.
509+
*/
510+
private _getSiblingOffsetPx(currentIndex: number,
511+
siblings: ItemPositionCacheEntry[],
512+
delta: 1 | -1) {
513+
514+
const isHorizontal = this.orientation === 'horizontal';
515+
const currentPosition = siblings[currentIndex].clientRect;
516+
const immediateSibling = siblings[currentIndex + delta * -1];
517+
let siblingOffset = currentPosition[isHorizontal ? 'width' : 'height'] * delta;
518+
519+
if (immediateSibling) {
520+
const start = isHorizontal ? 'left' : 'top';
521+
const end = isHorizontal ? 'right' : 'bottom';
522+
523+
// Get the spacing between the start of the current item and the end of the one immediately
524+
// after it in the direction in which the user is dragging, or vice versa. We add it to the
525+
// offset in order to push the element to where it will be when it's inline and is influenced
526+
// by the `margin` of its siblings.
527+
if (delta === -1) {
528+
siblingOffset -= immediateSibling.clientRect[start] - currentPosition[end];
529+
} else {
530+
siblingOffset += currentPosition[start] - immediateSibling.clientRect[end];
531+
}
532+
}
533+
534+
return siblingOffset;
535+
}
452536
}
453537

454538

0 commit comments

Comments
 (0)