Skip to content

Commit d1a6ea7

Browse files
authored
fix(drag-drop): not working correctly inside transplanted views (#18499)
Currently the CDK drag&drop is set up so that it keeps track of the items inside each drop list via `ContentChildren`, however this doesn't work properly inside of a transplanted view (e.g. in the header of a `mat-table`). There are two main problems inside transplanted views: 1. On the first change detection the `ContentChildren` query may be empty, even though the items exist. Usually they're fine on the second run, but that may be too late, because the user could've started dragging already. 2. Even if we somehow ensured that the `ContentChildren` is up-to-date when dragging starts, Angular won't update the order of the transplanted view items in the query once they're shuffled around. To work around these limitations and to make it possible to support more advanced usages like in `mat-table`, these changes switch to keeping track of the items using DI. Whenever an item is created or destroyed, it registers/deregisters itself with the closest drop list. Since the insertion order can be different from the DOM order, we use `compareDocumentPosition` to sort the items right before dragging starts. Fixes #18482.
1 parent 66b4171 commit d1a6ea7

File tree

6 files changed

+148
-39
lines changed

6 files changed

+148
-39
lines changed

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1358,6 +1358,47 @@ describe('CdkDrag', () => {
13581358
expect(fixture.componentInstance.dropInstance.data).toBe(fixture.componentInstance.items);
13591359
});
13601360

1361+
it('should register an item with the drop container', () => {
1362+
const fixture = createComponent(DraggableInDropZone);
1363+
fixture.detectChanges();
1364+
const list = fixture.componentInstance.dropInstance;
1365+
1366+
spyOn(list, 'addItem').and.callThrough();
1367+
1368+
fixture.componentInstance.items.push({value: 'Extra', margin: 0, height: ITEM_HEIGHT});
1369+
fixture.detectChanges();
1370+
1371+
expect(list.addItem).toHaveBeenCalledTimes(1);
1372+
});
1373+
1374+
it('should remove an item from the drop container', () => {
1375+
const fixture = createComponent(DraggableInDropZone);
1376+
fixture.detectChanges();
1377+
const list = fixture.componentInstance.dropInstance;
1378+
1379+
spyOn(list, 'removeItem').and.callThrough();
1380+
1381+
fixture.componentInstance.items.pop();
1382+
fixture.detectChanges();
1383+
1384+
expect(list.removeItem).toHaveBeenCalledTimes(1);
1385+
});
1386+
1387+
it('should return the items sorted by their position in the DOM', () => {
1388+
const fixture = createComponent(DraggableInDropZone);
1389+
const items = fixture.componentInstance.items;
1390+
fixture.detectChanges();
1391+
1392+
// Insert a couple of items in the start and the middle so the list gets shifted around.
1393+
items.unshift({value: 'Extra 0', margin: 0, height: ITEM_HEIGHT});
1394+
items.splice(3, 0, {value: 'Extra 1', margin: 0, height: ITEM_HEIGHT});
1395+
fixture.detectChanges();
1396+
1397+
expect(fixture.componentInstance.dropInstance.getSortedItems().map(item => {
1398+
return item.element.nativeElement.textContent!.trim();
1399+
})).toEqual(['Extra 0', 'Zero', 'One', 'Extra 1', 'Two', 'Three']);
1400+
});
1401+
13611402
it('should sync the drop list inputs with the drop list ref', () => {
13621403
const fixture = createComponent(DraggableInDropZone);
13631404
fixture.detectChanges();
@@ -5167,7 +5208,7 @@ class ConnectedDropZones implements AfterViewInit {
51675208
this.groupedDragItems.push([]);
51685209
}
51695210

5170-
this.groupedDragItems[index].push(...dropZone._draggables.toArray());
5211+
this.groupedDragItems[index].push(...dropZone.getSortedItems());
51715212
});
51725213
}
51735214
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ export class CdkDrag<T = any> implements AfterViewInit, OnChanges, OnDestroy {
215215
// assigning the drop container both from here and the list.
216216
if (dropContainer) {
217217
this._dragRef._withDropContainer(dropContainer._dropListRef);
218+
dropContainer.addItem(this);
218219
}
219220

220221
this._syncInputs(this._dragRef);
@@ -303,6 +304,10 @@ export class CdkDrag<T = any> implements AfterViewInit, OnChanges, OnDestroy {
303304
}
304305

305306
ngOnDestroy() {
307+
if (this.dropContainer) {
308+
this.dropContainer.removeItem(this);
309+
}
310+
306311
this._destroyed.next();
307312
this._destroyed.complete();
308313
this._dragRef.dispose();

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

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,11 @@
88

99
import {BooleanInput, coerceArray, coerceBooleanProperty} from '@angular/cdk/coercion';
1010
import {
11-
ContentChildren,
1211
ElementRef,
1312
EventEmitter,
1413
Input,
1514
OnDestroy,
1615
Output,
17-
QueryList,
1816
Optional,
1917
Directive,
2018
ChangeDetectorRef,
@@ -71,9 +69,6 @@ export class CdkDropList<T = any> implements AfterContentInit, OnDestroy {
7169
/** Reference to the underlying drop list instance. */
7270
_dropListRef: DropListRef<CdkDropList<T>>;
7371

74-
/** Draggable items in the container. */
75-
@ContentChildren(CdkDrag, {descendants: true}) _draggables: QueryList<CdkDrag>;
76-
7772
/**
7873
* Other draggable containers that this container is connected to and into which the
7974
* container's items can be transferred. Can either be references to other drop containers,
@@ -147,6 +142,15 @@ export class CdkDropList<T = any> implements AfterContentInit, OnDestroy {
147142
@Output('cdkDropListSorted')
148143
sorted: EventEmitter<CdkDragSortEvent<T>> = new EventEmitter<CdkDragSortEvent<T>>();
149144

145+
/**
146+
* Keeps track of the items that are registered with this container. Historically we used to
147+
* do this with a `ContentChildren` query, however queries don't handle transplanted views very
148+
* well which means that we can't handle cases like dragging the headers of a `mat-table`
149+
* correctly. What we do instead is to have the items register themselves with the container
150+
* and then we sort them based on their position in the DOM.
151+
*/
152+
private _unsortedItems = new Set<CdkDrag>();
153+
150154
constructor(
151155
/** Element that the drop list is attached to. */
152156
public element: ElementRef<HTMLElement>, dragDrop: DragDrop,
@@ -187,18 +191,37 @@ export class CdkDropList<T = any> implements AfterContentInit, OnDestroy {
187191
.map(scrollable => scrollable.getElementRef().nativeElement);
188192
this._dropListRef.withScrollableParents(scrollableParents);
189193
}
194+
}
190195

191-
this._draggables.changes
192-
.pipe(startWith(this._draggables), takeUntil(this._destroyed))
193-
.subscribe((items: QueryList<CdkDrag>) => {
194-
this._dropListRef.withItems(items.reduce((filteredItems, drag) => {
195-
if (drag.dropContainer === this) {
196-
filteredItems.push(drag._dragRef);
197-
}
196+
/** Registers an items with the drop list. */
197+
addItem(item: CdkDrag): void {
198+
this._unsortedItems.add(item);
198199

199-
return filteredItems;
200-
}, [] as DragRef[]));
201-
});
200+
if (this._dropListRef.isDragging()) {
201+
this._syncItemsWithRef();
202+
}
203+
}
204+
205+
/** Removes an item from the drop list. */
206+
removeItem(item: CdkDrag): void {
207+
this._unsortedItems.delete(item);
208+
209+
if (this._dropListRef.isDragging()) {
210+
this._syncItemsWithRef();
211+
}
212+
}
213+
214+
/** Gets the registered items in the list, sorted by their position in the DOM. */
215+
getSortedItems(): CdkDrag[] {
216+
return Array.from(this._unsortedItems).sort((a: CdkDrag, b: CdkDrag) => {
217+
const documentPosition =
218+
a._dragRef.getVisibleElement().compareDocumentPosition(b._dragRef.getVisibleElement());
219+
220+
// `compareDocumentPosition` returns a bitmask so we have to use a bitwise operator.
221+
// https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
222+
// tslint:disable-next-line:no-bitwise
223+
return documentPosition & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
224+
});
202225
}
203226

204227
ngOnDestroy() {
@@ -212,6 +235,7 @@ export class CdkDropList<T = any> implements AfterContentInit, OnDestroy {
212235
this._group._items.delete(this);
213236
}
214237

238+
this._unsortedItems.clear();
215239
this._dropListRef.dispose();
216240
this._destroyed.next();
217241
this._destroyed.complete();
@@ -310,6 +334,7 @@ export class CdkDropList<T = any> implements AfterContentInit, OnDestroy {
310334
/** Handles events from the underlying DropListRef. */
311335
private _handleEvents(ref: DropListRef<CdkDropList>) {
312336
ref.beforeStarted.subscribe(() => {
337+
this._syncItemsWithRef();
313338
this._changeDetectorRef.markForCheck();
314339
});
315340

@@ -371,6 +396,11 @@ export class CdkDropList<T = any> implements AfterContentInit, OnDestroy {
371396
}
372397
}
373398

399+
/** Syncs up the registered drag items with underlying drop list ref. */
400+
private _syncItemsWithRef() {
401+
this._dropListRef.withItems(this.getSortedItems().map(item => item._dragRef));
402+
}
403+
374404
static ngAcceptInputType_disabled: BooleanInput;
375405
static ngAcceptInputType_sortingDisabled: BooleanInput;
376406
static ngAcceptInputType_autoScrollDisabled: BooleanInput;

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ export class DragRef<T = any> {
124124
/** Drop container in which the DragRef resided when dragging began. */
125125
private _initialContainer: DropListRef;
126126

127+
/** Index at which the item started in its initial container. */
128+
private _initialIndex: number;
129+
127130
/** Cached scroll position on the page when the element was picked up. */
128131
private _scrollPosition: {top: number, left: number};
129132

@@ -309,6 +312,14 @@ export class DragRef<T = any> {
309312
return this._rootElement;
310313
}
311314

315+
/**
316+
* Gets the currently-visible element that represents the drag item.
317+
* While dragging this is the placeholder, otherwise it's the root element.
318+
*/
319+
getVisibleElement(): HTMLElement {
320+
return this.isDragging() ? this.getPlaceholderElement() : this.getRootElement();
321+
}
322+
312323
/** Registers the handles that can be used to drag the element. */
313324
withHandles(handles: (HTMLElement | ElementRef<HTMLElement>)[]): this {
314325
this._handles = handles.map(handle => coerceElement(handle));
@@ -697,6 +708,10 @@ export class DragRef<T = any> {
697708
this._document.body.appendChild(parent.replaceChild(placeholder, element));
698709
getPreviewInsertionPoint(this._document).appendChild(preview);
699710
this._dropContainer.start();
711+
this._initialContainer = this._dropContainer;
712+
this._initialIndex = this._dropContainer.getItemIndex(this);
713+
} else {
714+
this._initialContainer = this._initialIndex = undefined!;
700715
}
701716
}
702717

@@ -743,7 +758,6 @@ export class DragRef<T = any> {
743758
}
744759

745760
this._hasStartedDragging = this._hasMoved = false;
746-
this._initialContainer = this._dropContainer!;
747761

748762
// Avoid multiple subscriptions and memory leaks when multi touch
749763
// (isDragging check above isn't enough because of possible temporal and/or dimensional delays)
@@ -796,13 +810,14 @@ export class DragRef<T = any> {
796810
this.dropped.next({
797811
item: this,
798812
currentIndex,
799-
previousIndex: this._initialContainer.getItemIndex(this),
813+
previousIndex: this._initialIndex,
800814
container: container,
801815
previousContainer: this._initialContainer,
802816
isPointerOverContainer,
803817
distance
804818
});
805-
container.drop(this, currentIndex, this._initialContainer, isPointerOverContainer, distance);
819+
container.drop(this, currentIndex, this._initialContainer, isPointerOverContainer, distance,
820+
this._initialIndex);
806821
this._dropContainer = this._initialContainer;
807822
});
808823
}
@@ -831,7 +846,10 @@ export class DragRef<T = any> {
831846
this._dropContainer!.exit(this);
832847
// Notify the new container that the item has entered.
833848
this._dropContainer = newContainer!;
834-
this._dropContainer.enter(this, x, y);
849+
this._dropContainer.enter(this, x, y,
850+
// If we're re-entering the initial container,
851+
// put item the into its starting index to begin with.
852+
newContainer === this._initialContainer ? this._initialIndex : undefined);
835853
this.entered.next({
836854
item: this,
837855
container: newContainer!,

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

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -256,18 +256,26 @@ export class DropListRef<T = any> {
256256
* @param item Item that was moved into the container.
257257
* @param pointerX Position of the item along the X axis.
258258
* @param pointerY Position of the item along the Y axis.
259+
* @param index Index at which the item entered. If omitted, the container will try to figure it
260+
* out automatically.
259261
*/
260-
enter(item: DragRef, pointerX: number, pointerY: number): void {
262+
enter(item: DragRef, pointerX: number, pointerY: number, index?: number): void {
261263
this.start();
262264

263265
// If sorting is disabled, we want the item to return to its starting
264266
// position if the user is returning it to its initial container.
265-
let newIndex = this.sortingDisabled ? this._draggables.indexOf(item) : -1;
267+
let newIndex: number;
266268

267-
if (newIndex === -1) {
268-
// We use the coordinates of where the item entered the drop
269-
// zone to figure out at which index it should be inserted.
270-
newIndex = this._getItemIndexFromPointerPosition(item, pointerX, pointerY);
269+
if (index == null) {
270+
newIndex = this.sortingDisabled ? this._draggables.indexOf(item) : -1;
271+
272+
if (newIndex === -1) {
273+
// We use the coordinates of where the item entered the drop
274+
// zone to figure out at which index it should be inserted.
275+
newIndex = this._getItemIndexFromPointerPosition(item, pointerX, pointerY);
276+
}
277+
} else {
278+
newIndex = index;
271279
}
272280

273281
const activeDraggables = this._activeDraggables;
@@ -325,14 +333,22 @@ export class DropListRef<T = any> {
325333
* @param isPointerOverContainer Whether the user's pointer was over the
326334
* container when the item was dropped.
327335
* @param distance Distance the user has dragged since the start of the dragging sequence.
336+
* @param previousIndex Index of the item when dragging started.
337+
*
338+
* @breaking-change 11.0.0 `previousIndex` parameter to become required.
328339
*/
329340
drop(item: DragRef, currentIndex: number, previousContainer: DropListRef,
330-
isPointerOverContainer: boolean, distance: Point): void {
341+
isPointerOverContainer: boolean, distance: Point, previousIndex?: number): void {
331342
this._reset();
332-
this.dropped.next({
333-
item,
343+
344+
// @breaking-change 11.0.0 Remove this fallback logic once `previousIndex` is a required param.
345+
if (previousIndex == null) {
346+
previousIndex = previousContainer.getItemIndex(item);
347+
}
348+
349+
this.dropped.next({item,
334350
currentIndex,
335-
previousIndex: previousContainer.getItemIndex(item),
351+
previousIndex,
336352
container: this,
337353
previousContainer,
338354
isPointerOverContainer,
@@ -591,11 +607,7 @@ export class DropListRef<T = any> {
591607
const isHorizontal = this._orientation === 'horizontal';
592608

593609
this._itemPositions = this._activeDraggables.map(drag => {
594-
const elementToMeasure = this._dragDropRegistry.isDragging(drag) ?
595-
// If the element is being dragged, we have to measure the
596-
// placeholder, because the element is hidden.
597-
drag.getPlaceholderElement() :
598-
drag.getRootElement();
610+
const elementToMeasure = drag.getVisibleElement();
599611
return {drag, offset: 0, clientRect: getMutableClientRect(elementToMeasure)};
600612
}).sort((a, b) => {
601613
return isHorizontal ? a.clientRect.left - b.clientRect.left :

tools/public_api_guard/cdk/drag-drop.d.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,6 @@ export interface CdkDragStart<T = any> {
143143
}
144144

145145
export declare class CdkDropList<T = any> implements AfterContentInit, OnDestroy {
146-
_draggables: QueryList<CdkDrag>;
147146
_dropListRef: DropListRef<CdkDropList<T>>;
148147
autoScrollDisabled: boolean;
149148
connectedTo: (CdkDropList | string)[] | CdkDropList | string;
@@ -163,17 +162,20 @@ export declare class CdkDropList<T = any> implements AfterContentInit, OnDestroy
163162
constructor(
164163
element: ElementRef<HTMLElement>, dragDrop: DragDrop, _changeDetectorRef: ChangeDetectorRef, _dir?: Directionality | undefined, _group?: CdkDropListGroup<CdkDropList<any>> | undefined,
165164
_scrollDispatcher?: ScrollDispatcher | undefined, config?: DragDropConfig);
165+
addItem(item: CdkDrag): void;
166166
drop(item: CdkDrag, currentIndex: number, previousContainer: CdkDropList, isPointerOverContainer: boolean): void;
167167
enter(item: CdkDrag, pointerX: number, pointerY: number): void;
168168
exit(item: CdkDrag): void;
169169
getItemIndex(item: CdkDrag): number;
170+
getSortedItems(): CdkDrag[];
170171
ngAfterContentInit(): void;
171172
ngOnDestroy(): void;
173+
removeItem(item: CdkDrag): void;
172174
start(): void;
173175
static ngAcceptInputType_autoScrollDisabled: BooleanInput;
174176
static ngAcceptInputType_disabled: BooleanInput;
175177
static ngAcceptInputType_sortingDisabled: BooleanInput;
176-
static ɵdir: i0.ɵɵDirectiveDefWithMeta<CdkDropList<any>, "[cdkDropList], cdk-drop-list", ["cdkDropList"], { "connectedTo": "cdkDropListConnectedTo"; "data": "cdkDropListData"; "orientation": "cdkDropListOrientation"; "id": "id"; "lockAxis": "cdkDropListLockAxis"; "disabled": "cdkDropListDisabled"; "sortingDisabled": "cdkDropListSortingDisabled"; "enterPredicate": "cdkDropListEnterPredicate"; "autoScrollDisabled": "cdkDropListAutoScrollDisabled"; }, { "dropped": "cdkDropListDropped"; "entered": "cdkDropListEntered"; "exited": "cdkDropListExited"; "sorted": "cdkDropListSorted"; }, ["_draggables"]>;
178+
static ɵdir: i0.ɵɵDirectiveDefWithMeta<CdkDropList<any>, "[cdkDropList], cdk-drop-list", ["cdkDropList"], { "connectedTo": "cdkDropListConnectedTo"; "data": "cdkDropListData"; "orientation": "cdkDropListOrientation"; "id": "id"; "lockAxis": "cdkDropListLockAxis"; "disabled": "cdkDropListDisabled"; "sortingDisabled": "cdkDropListSortingDisabled"; "enterPredicate": "cdkDropListEnterPredicate"; "autoScrollDisabled": "cdkDropListAutoScrollDisabled"; }, { "dropped": "cdkDropListDropped"; "entered": "cdkDropListEntered"; "exited": "cdkDropListExited"; "sorted": "cdkDropListSorted"; }, never>;
177179
static ɵfac: i0.ɵɵFactoryDef<CdkDropList<any>>;
178180
}
179181

@@ -298,6 +300,7 @@ export declare class DragRef<T = any> {
298300
getFreeDragPosition(): Readonly<Point>;
299301
getPlaceholderElement(): HTMLElement;
300302
getRootElement(): HTMLElement;
303+
getVisibleElement(): HTMLElement;
301304
isDragging(): boolean;
302305
reset(): void;
303306
setFreeDragPosition(value: Point): this;
@@ -368,8 +371,8 @@ export declare class DropListRef<T = any> {
368371
_stopScrolling(): void;
369372
connectedTo(connectedTo: DropListRef[]): this;
370373
dispose(): void;
371-
drop(item: DragRef, currentIndex: number, previousContainer: DropListRef, isPointerOverContainer: boolean, distance: Point): void;
372-
enter(item: DragRef, pointerX: number, pointerY: number): void;
374+
drop(item: DragRef, currentIndex: number, previousContainer: DropListRef, isPointerOverContainer: boolean, distance: Point, previousIndex?: number): void;
375+
enter(item: DragRef, pointerX: number, pointerY: number, index?: number): void;
373376
exit(item: DragRef): void;
374377
getItemIndex(item: DragRef): number;
375378
isDragging(): boolean;

0 commit comments

Comments
 (0)