feat(drag-drop): add support for sorting animations (#12530)

This commit is contained in:
Kristiyan Kostadinov
2018-08-07 22:44:37 +02:00
committed by Joey Perrott
parent 8c08bb1977
commit adcac367bf
5 changed files with 202 additions and 85 deletions
+38 -7
View File
@@ -562,7 +562,7 @@ describe('CdkDrag', () => {
// Add a few pixels to the left offset so we get some overlap.
dispatchMouseEvent(document, 'mousemove', elementRect.left + 5, elementRect.top);
fixture.detectChanges();
expect(getElementIndex(placeholder)).toBe(i);
expect(getElementIndexByPosition(placeholder, 'left')).toBe(i);
}
dispatchMouseEvent(document, 'mouseup');
@@ -590,7 +590,7 @@ describe('CdkDrag', () => {
// Remove a few pixels from the right offset so we get some overlap.
dispatchMouseEvent(document, 'mousemove', elementRect.right - 5, elementRect.top);
fixture.detectChanges();
expect(getElementIndex(placeholder)).toBe(Math.min(i + 1, items.length - 1));
expect(getElementIndexByPosition(placeholder, 'left')).toBe(i);
}
dispatchMouseEvent(document, 'mouseup');
@@ -660,6 +660,31 @@ describe('CdkDrag', () => {
expect(placeholder.textContent!.trim()).toContain('Custom placeholder');
}));
it('should clear the `transform` value from siblings when item is dropped`', fakeAsync(() => {
const fixture = createComponent(DraggableInDropZone);
fixture.detectChanges();
const dragItems = fixture.componentInstance.dragItems;
const firstItem = dragItems.first;
const thirdItem = dragItems.toArray()[2].element.nativeElement;
const thirdItemRect = thirdItem.getBoundingClientRect();
dispatchMouseEvent(firstItem.element.nativeElement, 'mousedown');
fixture.detectChanges();
dispatchMouseEvent(document, 'mousemove', thirdItemRect.left + 1, thirdItemRect.top + 1);
fixture.detectChanges();
expect(thirdItem.style.transform).toBeTruthy();
dispatchMouseEvent(document, 'mouseup');
fixture.detectChanges();
flush();
fixture.detectChanges();
expect(thirdItem.style.transform).toBeFalsy();
}));
});
describe('in a connected drop container', () => {
@@ -1151,9 +1176,15 @@ function dragElementViaTouch(fixture: ComponentFixture<any>,
fixture.detectChanges();
}
/** Gets the index of a DOM element inside its parent. */
function getElementIndex(element: HTMLElement) {
return element.parentElement ? Array.from(element.parentElement.children).indexOf(element) : -1;
/** Gets the index of an element among its siblings, based on their position on the page. */
function getElementIndexByPosition(element: HTMLElement, direction: 'top' | 'left') {
if (!element.parentElement) {
return -1;
}
return Array.from(element.parentElement.children)
.sort((a, b) => a.getBoundingClientRect()[direction] - b.getBoundingClientRect()[direction])
.indexOf(element);
}
/**
@@ -1193,7 +1224,7 @@ function assertDownwardSorting(fixture: ComponentFixture<any>, items: Element[])
// Add a few pixels to the top offset so we get some overlap.
dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.top + 5);
fixture.detectChanges();
expect(getElementIndex(placeholder)).toBe(i);
expect(getElementIndexByPosition(placeholder, 'top')).toBe(i);
}
dispatchMouseEvent(document, 'mouseup');
@@ -1222,7 +1253,7 @@ function assertUpwardSorting(fixture: ComponentFixture<any>, items: Element[]) {
// Remove a few pixels from the bottom offset so we get some overlap.
dispatchMouseEvent(document, 'mousemove', elementRect.left, elementRect.bottom - 5);
fixture.detectChanges();
expect(getElementIndex(placeholder)).toBe(Math.min(i + 1, items.length - 1));
expect(getElementIndexByPosition(placeholder, 'top')).toBe(i);
}
dispatchMouseEvent(document, 'mouseup');
+6 -37
View File
@@ -35,8 +35,8 @@ import {CdkDragDropRegistry} from './drag-drop-registry';
import {Subject, merge} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
// TODO: add auto-scrolling functionality.
// TODO: add an API for moving a draggable up/down the
// TODO(crisbeto): add auto-scrolling functionality.
// TODO(crisbeto): add an API for moving a draggable up/down the
// list programmatically. Useful for keyboard controls.
/** Element that can be moved inside a CdkDrop container. */
@@ -236,7 +236,7 @@ export class CdkDrag<T = any> implements OnDestroy {
/** Handler that is invoked when the user moves their pointer after they've initiated a drag. */
private _pointerMove = (event: MouseEvent | TouchEvent) => {
// TODO: this should start dragging after a certain threshold,
// TODO(crisbeto): this should start dragging after a certain threshold,
// otherwise we risk interfering with clicks on the element.
if (!this._dragDropRegistry.isDragging(this)) {
return;
@@ -279,8 +279,6 @@ export class CdkDrag<T = any> implements OnDestroy {
/** Cleans up the DOM artifacts that were added to facilitate the element being dragged. */
private _cleanupDragArtifacts() {
const currentIndex = this._getElementIndexInDom(this._placeholder);
// Restore the element's visibility and insert it at its old position in the DOM.
// It's important that we maintain the position, because moving the element around in the DOM
// can throw off `NgFor` which does smart diffing and re-creates elements only when necessary,
@@ -298,6 +296,8 @@ export class CdkDrag<T = any> implements OnDestroy {
// Re-enter the NgZone since we bound `document` events on the outside.
this._ngZone.run(() => {
const currentIndex = this.dropContainer.getItemIndex(this);
this.ended.emit({source: this});
this.dropped.emit({
item: this,
@@ -328,7 +328,7 @@ export class CdkDrag<T = any> implements OnDestroy {
// Notify the new container that the item has entered.
this.entered.emit({ item: this, container: newContainer });
this.dropContainer = newContainer;
this.dropContainer.enter(this);
this.dropContainer.enter(this, x, y);
});
}
@@ -386,37 +386,6 @@ export class CdkDrag<T = any> implements OnDestroy {
return placeholder;
}
/** Gets the index of an element, based on its index in the DOM. */
private _getElementIndexInDom(element: HTMLElement): number {
// Note: we may be able to figure this in memory while sorting, but doing so won't be very
// reliable when transferring between containers, because the new container doesn't have
// the proper indices yet. Also this will work better for the case where the consumer
// isn't using an `ngFor` to render the list.
if (!element.parentElement) {
return -1;
}
// Avoid accessing `children` and `children.length` too much since they're a "live collection".
let index = 0;
const siblings = element.parentElement.children;
const siblingsLength = siblings.length;
const draggableElements = this.dropContainer._draggables
.filter(item => item !== this)
.map(item => item.element.nativeElement);
// Loop through the sibling elements to find out the index of the
// current one, while skipping any elements that aren't draggable.
for (let i = 0; i < siblingsLength; i++) {
if (siblings[i] === element) {
return index;
} else if (draggableElements.indexOf(siblings[i] as HTMLElement) > -1) {
index++;
}
}
return -1;
}
/**
* Figures out the coordinates at which an element was picked up.
* @param referenceElement Element that initiated the dragging.
@@ -30,8 +30,10 @@ export interface CdkDropContainer<T = any> {
/**
* Emits an event to indicate that the user moved an item into the container.
* @param item Item that was moved into the container.
* @param xOffset Position of the item along the X axis.
* @param yOffset Position of the item along the Y axis.
*/
enter(item: CdkDrag): void;
enter(item: CdkDrag, xOffset: number, yOffset: number): void;
/**
* Removes an item from the container after it was dragged into another container by the user.
+151 -36
View File
@@ -100,14 +100,22 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
/** Cache of the dimensions of all the items and the sibling containers. */
private _positionCache = {
items: [] as {drag: CdkDrag, clientRect: ClientRect}[],
items: [] as {drag: CdkDrag, clientRect: ClientRect, offset: number}[],
siblings: [] as {drop: CdkDrop, clientRect: ClientRect}[]
};
/**
* Draggable items that are currently active inside the container. Includes the items
* from `_draggables`, as well as any items that have been dragged in, but haven't
* been dropped yet.
*/
private _activeDraggables: CdkDrag[];
/** Starts dragging an item. */
start(): void {
this._dragging = true;
this._refreshPositions();
this._activeDraggables = this._draggables.toArray();
this._cachePositions();
}
/**
@@ -117,25 +125,57 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
* @param previousContainer Container from which the item got dragged in.
*/
drop(item: CdkDrag, currentIndex: number, previousContainer: CdkDrop): void {
this._reset();
this.dropped.emit({
item,
currentIndex,
previousIndex: previousContainer.getItemIndex(item),
container: this,
// TODO: reconsider whether to make this null if the containers are the same.
// TODO(crisbeto): reconsider whether to make this null if the containers are the same.
previousContainer
});
this._reset();
}
/**
* Emits an event to indicate that the user moved an item into the container.
* @param item Item that was moved into the container.
* @param xOffset Position of the item along the X axis.
* @param yOffset Position of the item along the Y axis.
*/
enter(item: CdkDrag): void {
enter(item: CdkDrag, xOffset: number, yOffset: number): void {
this.entered.emit({item, container: this});
this.start();
// We use the coordinates of where the item entered the drop
// zone to figure out at which index it should be inserted.
const newIndex = this._getItemIndexFromPointerPosition(item, xOffset, yOffset);
const currentIndex = this._activeDraggables.indexOf(item);
const newPositionReference = this._activeDraggables[newIndex];
const placeholder = item.getPlaceholderElement();
// Since the item may be in the `activeDraggables` already (e.g. if the user dragged it
// into another container and back again), we have to ensure that it isn't duplicated.
if (currentIndex > -1) {
this._activeDraggables.splice(currentIndex, 1);
}
// Don't use items that are being dragged as a reference, because
// their element has been moved down to the bottom of the body.
if (newPositionReference && !this._dragDropRegistry.isDragging(newPositionReference)) {
const element = newPositionReference.element.nativeElement;
element.parentElement!.insertBefore(placeholder, element);
this._activeDraggables.splice(newIndex, 0, item);
} else {
this.element.nativeElement.appendChild(placeholder);
this._activeDraggables.push(item);
}
// The transform needs to be cleared so it doesn't throw off the measurements.
placeholder.style.transform = '';
// Note that the positions were already cached when we called `start` above,
// but we need to refresh them since the amount of items has changed.
this._cachePositions();
}
/**
@@ -152,7 +192,9 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
* @param item Item whose index should be determined.
*/
getItemIndex(item: CdkDrag): number {
return this._draggables.toArray().indexOf(item);
return this._dragging ?
this._positionCache.items.findIndex(currentItem => currentItem.drag === item) :
this._draggables.toArray().indexOf(item);
}
/**
@@ -163,37 +205,45 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
*/
_sortItem(item: CdkDrag, xOffset: number, yOffset: number): void {
const siblings = this._positionCache.items;
const newPosition = siblings.find(({drag, clientRect}) => {
if (drag === item) {
// If there's only one left item in the container, it must be
// the dragged item itself so we use it as a reference.
return siblings.length < 2;
}
const isHorizontal = this.orientation === 'horizontal';
const newIndex = this._getItemIndexFromPointerPosition(item, xOffset, yOffset);
const placeholder = item.getPlaceholderElement();
return this.orientation === 'horizontal' ?
xOffset > clientRect.left && xOffset < clientRect.right :
yOffset > clientRect.top && yOffset < clientRect.bottom;
});
if (!newPosition && siblings.length > 0) {
if (newIndex === -1 && siblings.length > 0) {
return;
}
// Don't take the element of a dragged item as a reference,
// because it has been moved down to the end of the body.
const element = (newPosition && !this._dragDropRegistry.isDragging(newPosition.drag)) ?
newPosition.drag.element.nativeElement : null;
const next = element ? element!.nextSibling : null;
const parent = element ? element.parentElement! : this.element.nativeElement;
const placeholder = item.getPlaceholderElement();
const currentIndex = siblings.findIndex(currentItem => currentItem.drag === item);
const currentPosition = siblings[currentIndex];
const newPosition = siblings[newIndex];
if (next) {
parent.insertBefore(placeholder, next === placeholder ? element : next);
} else {
parent.appendChild(placeholder);
}
// Figure out the offset necessary for the items to be swapped.
const offset = isHorizontal ?
currentPosition.clientRect.left - newPosition.clientRect.left :
currentPosition.clientRect.top - newPosition.clientRect.top;
const topAdjustment = isHorizontal ? 0 : offset;
const leftAdjustment = isHorizontal ? offset : 0;
this._refreshPositions();
// Since we've moved the items with a `transform`, we need to adjust their cached
// client rects to reflect their new position, as well as swap their positions in the cache.
// Note that we shouldn't use `getBoundingClientRect` here to update the cache, because the
// elements may be mid-animation which will give us a wrong result.
this._adjustClientRect(currentPosition.clientRect, -topAdjustment, -leftAdjustment);
currentPosition.offset -= offset;
siblings[currentIndex] = newPosition;
this._adjustClientRect(newPosition.clientRect, topAdjustment, leftAdjustment);
newPosition.offset += offset;
siblings[newIndex] = currentPosition;
// Swap the placeholder's position with the one of the target draggable.
placeholder.style.transform = isHorizontal ?
`translate3d(${currentPosition.offset}px, 0, 0)` :
`translate3d(0, ${currentPosition.offset}px, 0)`;
newPosition.drag.element.nativeElement.style.transform = isHorizontal ?
`translate3d(${newPosition.offset}px, 0, 0)` :
`translate3d(0, ${newPosition.offset}px, 0)`;
}
/**
@@ -212,12 +262,37 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
}
/** Refreshes the position cache of the items and sibling containers. */
private _refreshPositions() {
this._positionCache.items = this._draggables
.map(drag => ({drag, clientRect: drag.element.nativeElement.getBoundingClientRect()}))
private _cachePositions() {
this._positionCache.items = this._activeDraggables
.map(drag => {
const elementToMeasure = this._dragDropRegistry.isDragging(drag) ?
// If the element is being dragged, we have to measure the
// placeholder, because the element is hidden.
drag.getPlaceholderElement() :
drag.element.nativeElement;
const clientRect = elementToMeasure.getBoundingClientRect();
return {
drag,
offset: 0,
// We need to clone the `clientRect` here, because all the values on it are readonly
// and we need to be able to update them. Also we can't use a spread here, because
// the values on a `ClientRect` aren't own properties. See:
// https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect#Notes
clientRect: {
top: clientRect.top,
right: clientRect.right,
bottom: clientRect.bottom,
left: clientRect.left,
width: clientRect.width,
height: clientRect.height
}
};
})
.sort((a, b) => a.clientRect.top - b.clientRect.top);
// TODO: add filter here that ensures that the current container isn't being passed to itself.
// TODO(crisbeto): add filter here that ensures that the
// current container isn't being passed to itself.
this._positionCache.siblings = this.connectedTo
.map(drop => typeof drop === 'string' ? this._dragDropRegistry.getDropContainer(drop)! : drop)
.filter(Boolean)
@@ -227,7 +302,47 @@ export class CdkDrop<T = any> implements OnInit, OnDestroy {
/** Resets the container to its initial state. */
private _reset() {
this._dragging = false;
// TODO(crisbeto): may have to wait for the animations to finish.
this._activeDraggables.forEach(item => item.element.nativeElement.style.transform = '');
this._activeDraggables = [];
this._positionCache.items = [];
this._positionCache.siblings = [];
}
/**
* Updates the top/left positions of a `ClientRect`, as well as their bottom/right counterparts.
* @param clientRect `ClientRect` that should be updated.
* @param top New value for the `top` position.
* @param left New value for the `left` position.
*/
private _adjustClientRect(clientRect: ClientRect, top: number, left: number) {
clientRect.top += top;
clientRect.bottom = clientRect.top + clientRect.height;
clientRect.left += left;
clientRect.right = clientRect.left + clientRect.width;
}
/**
* Gets the index of an item in the drop container, based on the position of the user's pointer.
* @param item Item that is being sorted.
* @param xOffset Position of the user's pointer along the X axis.
* @param yOffset Position of the user's pointer along the Y axis.
*/
private _getItemIndexFromPointerPosition(item: CdkDrag, xOffset: number, yOffset: number) {
return this._positionCache.items.findIndex(({drag, clientRect}, _, array) => {
if (drag === item) {
// If there's only one item left in the container, it must be
// the dragged item itself so we use it as a reference.
return array.length < 2;
}
return this.orientation === 'horizontal' ?
// Round these down since most browsers report client rects with
// sub-pixel precision, whereas the mouse coordinates are rounded to pixels.
xOffset >= Math.floor(clientRect.left) && xOffset <= Math.floor(clientRect.right) :
yOffset >= Math.floor(clientRect.top) && yOffset <= Math.floor(clientRect.bottom);
});
}
}
+4 -4
View File
@@ -38,8 +38,8 @@
justify-content: space-between;
box-sizing: border-box;
.cdk-drop-dragging & {
transition: transform 500ms ease;
.cdk-drop-dragging &:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.horizontal & {
@@ -65,11 +65,11 @@
}
.cdk-drag-animating {
transition: transform 500ms ease;
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.cdk-drag-placeholder {
opacity: 0.5;
opacity: 0;
}
.wrapper {