diff --git a/src/cdk/drag-drop/directives/drag.spec.ts b/src/cdk/drag-drop/directives/drag.spec.ts index dcac4f49d05a..9dbb60315d1e 100644 --- a/src/cdk/drag-drop/directives/drag.spec.ts +++ b/src/cdk/drag-drop/directives/drag.spec.ts @@ -23,7 +23,7 @@ import { } from '@angular/core'; import {TestBed, ComponentFixture, fakeAsync, flush, tick} from '@angular/core/testing'; import {DOCUMENT} from '@angular/common'; -import {ViewportRuler} from '@angular/cdk/scrolling'; +import {ViewportRuler, ScrollingModule} from '@angular/cdk/scrolling'; import {_supportsShadowDom} from '@angular/cdk/platform'; import {of as observableOf} from 'rxjs'; @@ -47,7 +47,7 @@ describe('CdkDrag', () => { extraDeclarations: Type[] = []): ComponentFixture { TestBed .configureTestingModule({ - imports: [DragDropModule], + imports: [DragDropModule, ScrollingModule], declarations: [componentType, PassthroughComponent, ...extraDeclarations], providers: [ { @@ -3375,6 +3375,24 @@ describe('CdkDrag', () => { cleanup(); })); + it('should be able to auto-scroll a parent container', fakeAsync(() => { + const fixture = createComponent(DraggableInScrollableParentContainer); + fixture.detectChanges(); + const item = fixture.componentInstance.dragItems.first.element.nativeElement; + const container = fixture.nativeElement.querySelector('.container'); + const containerRect = container.getBoundingClientRect(); + + expect(container.scrollTop).toBe(0); + + startDraggingViaMouse(fixture, item); + dispatchMouseEvent(document, 'mousemove', + containerRect.left + containerRect.width / 2, containerRect.top + containerRect.height); + fixture.detectChanges(); + tickAnimationFrames(20); + + expect(container.scrollTop).toBeGreaterThan(0); + })); + it('should pick up descendants inside of containers', fakeAsync(() => { const fixture = createComponent(DraggableInDropZoneWithContainer); fixture.detectChanges(); @@ -4659,6 +4677,30 @@ class DraggableInScrollableVerticalDropZone extends DraggableInDropZone { } } +@Component({ + template: '
' + DROP_ZONE_FIXTURE_TEMPLATE + '
', + + // Note that it needs a margin to ensure that it's not flush against the viewport + // edge which will cause the viewport to scroll, rather than the list. + styles: [` + .container { + max-height: 200px; + overflow: auto; + margin: 10vw 0 0 10vw; + } + `] +}) +class DraggableInScrollableParentContainer extends DraggableInDropZone { + constructor() { + super(); + + for (let i = 0; i < 60; i++) { + this.items.push({value: `Extra item ${i}`, height: ITEM_HEIGHT, margin: 0}); + } + } +} + + @Component({ // Note that we need the blank `ngSwitch` below to hit the code path that we're testing. template: ` diff --git a/src/cdk/drag-drop/directives/drop-list.ts b/src/cdk/drag-drop/directives/drop-list.ts index ecde4757033c..b7eee425d20b 100644 --- a/src/cdk/drag-drop/directives/drop-list.ts +++ b/src/cdk/drag-drop/directives/drop-list.ts @@ -22,6 +22,7 @@ import { AfterContentInit, } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; +import {ScrollDispatcher} from '@angular/cdk/scrolling'; import {CdkDrag, CDK_DROP_LIST} from './drag'; import {CdkDragDrop, CdkDragEnter, CdkDragExit, CdkDragSortEvent} from '../drag-events'; import {CdkDropListGroup} from './drop-list-group'; @@ -148,7 +149,13 @@ export class CdkDropList implements AfterContentInit, OnDestroy { /** Element that the drop list is attached to. */ public element: ElementRef, dragDrop: DragDrop, private _changeDetectorRef: ChangeDetectorRef, @Optional() private _dir?: Directionality, - @Optional() @SkipSelf() private _group?: CdkDropListGroup) { + @Optional() @SkipSelf() private _group?: CdkDropListGroup, + + /** + * @deprecated _scrollDispatcher parameter to become required. + * @breaking-change 11.0.0 + */ + private _scrollDispatcher?: ScrollDispatcher) { this._dropListRef = dragDrop.createDropList(element); this._dropListRef.data = this; this._dropListRef.enterPredicate = (drag: DragRef, drop: DropListRef) => { @@ -165,6 +172,14 @@ export class CdkDropList implements AfterContentInit, OnDestroy { } ngAfterContentInit() { + // @breaking-change 11.0.0 Remove null check for _scrollDispatcher once it's required. + if (this._scrollDispatcher) { + const scrollableParents = this._scrollDispatcher + .getAncestorScrollContainers(this.element) + .map(scrollable => scrollable.getElementRef().nativeElement); + this._dropListRef.withScrollableParents(scrollableParents); + } + this._draggables.changes .pipe(startWith(this._draggables), takeUntil(this._destroyed)) .subscribe((items: QueryList) => { diff --git a/src/cdk/drag-drop/drop-list-ref.ts b/src/cdk/drag-drop/drop-list-ref.ts index b964e30ea246..9a6f4bb2fc12 100644 --- a/src/cdk/drag-drop/drop-list-ref.ts +++ b/src/cdk/drag-drop/drop-list-ref.ts @@ -137,11 +137,11 @@ export class DropListRef { /** Cache of the dimensions of all the items inside the container. */ private _itemPositions: CachedItemPosition[] = []; - /** Keeps track of the container's scroll position. */ - private _scrollPosition: ScrollPosition = {top: 0, left: 0}; - - /** Keeps track of the scroll position of the viewport. */ - private _viewportScrollPosition: ScrollPosition = {top: 0, left: 0}; + /** Cached positions of the scrollable parent elements. */ + private _parentPositions = new Map(); /** Cached `ClientRect` of the drop list. */ private _clientRect: ClientRect; @@ -195,6 +195,9 @@ export class DropListRef { /** Reference to the document. */ private _document: Document; + /** Elements that can be scrolled while the user is dragging. */ + private _scrollableElements: HTMLElement[]; + constructor( element: ElementRef | HTMLElement, private _dragDropRegistry: DragDropRegistry, @@ -203,6 +206,7 @@ export class DropListRef { private _viewportRuler: ViewportRuler) { this.element = coerceElement(element); this._document = _document; + this.withScrollableParents([this.element]); _dragDropRegistry.registerDropContainer(this); } @@ -210,7 +214,7 @@ export class DropListRef { dispose() { this._stopScrolling(); this._stopScrollTimers.complete(); - this._removeListeners(); + this._viewportScrollSubscription.unsubscribe(); this.beforeStarted.complete(); this.entered.complete(); this.exited.complete(); @@ -218,6 +222,7 @@ export class DropListRef { this.sorted.complete(); this._activeSiblings.clear(); this._scrollNode = null!; + this._parentPositions.clear(); this._dragDropRegistry.removeDropContainer(this); } @@ -228,13 +233,11 @@ export class DropListRef { /** Starts dragging an item. */ start(): void { - const element = coerceElement(this.element); this.beforeStarted.next(); this._isDragging = true; this._cacheItems(); this._siblings.forEach(sibling => sibling._startReceiving(this)); - this._removeListeners(); - this._ngZone.runOutsideAngular(() => element.addEventListener('scroll', this._handleScroll)); + this._viewportScrollSubscription.unsubscribe(); this._listenToScrollEvents(); } @@ -367,6 +370,20 @@ export class DropListRef { return this; } + /** + * Sets which parent elements are can be scrolled while the user is dragging. + * @param elements Elements that can be scrolled. + */ + withScrollableParents(elements: HTMLElement[]): this { + const element = coerceElement(this.element); + + // We always allow the current element to be scrollable + // so we need to ensure that it's in the array. + this._scrollableElements = + elements.indexOf(element) === -1 ? [element, ...elements] : elements.slice(); + return this; + } + /** * Figures out the index of an item in the container. * @param item Item whose index should be determined. @@ -403,7 +420,7 @@ export class DropListRef { _sortItem(item: DragRef, pointerX: number, pointerY: number, pointerDelta: {x: number, y: number}): void { // Don't sort the item if sorting is disabled or it's out of range. - if (this.sortingDisabled || !this._isPointerNearDropContainer(pointerX, pointerY)) { + if (this.sortingDisabled || !isPointerNearClientRect(this._clientRect, pointerX, pointerY)) { return; } @@ -489,17 +506,23 @@ export class DropListRef { let verticalScrollDirection = AutoScrollVerticalDirection.NONE; let horizontalScrollDirection = AutoScrollHorizontalDirection.NONE; - // Check whether we should start scrolling the container. - if (this._isPointerNearDropContainer(pointerX, pointerY)) { - const element = coerceElement(this.element); + // Check whether we should start scrolling any of the parent containers. + this._parentPositions.forEach((position, element) => { + // We have special handling for the `document` below. Also this would be + // nicer with a for...of loop, but it requires changing a compiler flag. + if (element === this._document || !position.clientRect || scrollNode) { + return; + } - [verticalScrollDirection, horizontalScrollDirection] = - getElementScrollDirections(element, this._clientRect, pointerX, pointerY); + if (isPointerNearClientRect(position.clientRect, pointerX, pointerY)) { + [verticalScrollDirection, horizontalScrollDirection] = getElementScrollDirections( + element as HTMLElement, position.clientRect, pointerX, pointerY); - if (verticalScrollDirection || horizontalScrollDirection) { - scrollNode = element; + if (verticalScrollDirection || horizontalScrollDirection) { + scrollNode = element as HTMLElement; + } } - } + }); // Otherwise check if we can start scrolling the viewport. if (!verticalScrollDirection && !horizontalScrollDirection) { @@ -530,11 +553,27 @@ export class DropListRef { this._stopScrollTimers.next(); } - /** Caches the position of the drop list. */ - private _cacheOwnPosition() { - const element = coerceElement(this.element); - this._clientRect = getMutableClientRect(element); - this._scrollPosition = {top: element.scrollTop, left: element.scrollLeft}; + /** Caches the positions of the configured scrollable parents. */ + private _cacheParentPositions() { + this._parentPositions.clear(); + this._parentPositions.set(this._document, { + scrollPosition: this._viewportRuler!.getViewportScrollPosition(), + }); + this._scrollableElements.forEach(element => { + const clientRect = getMutableClientRect(element); + + // We keep the ClientRect cached in two properties, because it's referenced in a lot of + // performance-sensitive places and we want to avoid the extra lookups. The `element` is + // guaranteed to always be in the `_scrollableElements` so this should always match. + if (element === this.element) { + this._clientRect = clientRect; + } + + this._parentPositions.set(element, { + scrollPosition: {top: element.scrollTop, left: element.scrollLeft}, + clientRect + }); + }); } /** Refreshes the position cache of the items and sibling containers. */ @@ -566,7 +605,8 @@ export class DropListRef { this._previousSwap.drag = null; this._previousSwap.delta = 0; this._stopScrolling(); - this._removeListeners(); + this._viewportScrollSubscription.unsubscribe(); + this._parentPositions.clear(); } /** @@ -602,20 +642,6 @@ export class DropListRef { return siblingOffset; } - /** - * Checks whether the pointer coordinates are close to the drop container. - * @param pointerX Coordinates along the X axis. - * @param pointerY Coordinates along the Y axis. - */ - private _isPointerNearDropContainer(pointerX: number, pointerY: number): boolean { - const {top, right, bottom, left, width, height} = this._clientRect; - const xThreshold = width * DROP_PROXIMITY_THRESHOLD; - const yThreshold = height * DROP_PROXIMITY_THRESHOLD; - - return pointerY > top - yThreshold && pointerY < bottom + yThreshold && - pointerX > left - xThreshold && pointerX < right + xThreshold; - } - /** * Gets the offset in pixels by which the item that is being dragged should be moved. * @param currentPosition Current position of the item. @@ -676,26 +702,29 @@ export class DropListRef { private _cacheItems(): void { this._activeDraggables = this._draggables.slice(); this._cacheItemPositions(); - this._cacheOwnPosition(); + this._cacheParentPositions(); } /** * Updates the internal state of the container after a scroll event has happened. - * @param scrollPosition Object that is keeping track of the scroll position. + * @param scrolledParent Element that was scrolled. * @param newTop New top scroll position. * @param newLeft New left scroll position. - * @param extraClientRect Extra `ClientRect` object that should be updated, in addition to the - * ones of the drag items. Useful when the viewport has been scrolled and we also need to update - * the `ClientRect` of the list. */ - private _updateAfterScroll(scrollPosition: ScrollPosition, newTop: number, newLeft: number, - extraClientRect?: ClientRect) { + private _updateAfterScroll(scrolledParent: HTMLElement | Document, + newTop: number, + newLeft: number) { + const scrollPosition = this._parentPositions.get(scrolledParent)!.scrollPosition; const topDifference = scrollPosition.top - newTop; const leftDifference = scrollPosition.left - newLeft; - if (extraClientRect) { - adjustClientRect(extraClientRect, topDifference, leftDifference); - } + // Go through and update the cached positions of the scroll + // parents that are inside the element that was scrolled. + this._parentPositions.forEach((position, node) => { + if (position.clientRect && scrolledParent !== node && scrolledParent.contains(node)) { + adjustClientRect(position.clientRect, topDifference, leftDifference); + } + }); // Since we know the amount that the user has scrolled we can shift all of the client rectangles // ourselves. This is cheaper than re-measuring everything and we can avoid inconsistent @@ -718,22 +747,6 @@ export class DropListRef { scrollPosition.left = newLeft; } - /** Handles the container being scrolled. Has to be an arrow function to preserve the context. */ - private _handleScroll = () => { - if (!this.isDragging()) { - return; - } - - const element = coerceElement(this.element); - this._updateAfterScroll(this._scrollPosition, element.scrollTop, element.scrollLeft); - } - - /** Removes the event listeners associated with this drop list. */ - private _removeListeners() { - coerceElement(this.element).removeEventListener('scroll', this._handleScroll); - this._viewportScrollSubscription.unsubscribe(); - } - /** Starts the interval that'll auto-scroll the element. */ private _startScrollInterval = () => { this._stopScrolling(); @@ -816,7 +829,7 @@ export class DropListRef { if (!activeSiblings.has(sibling)) { activeSiblings.add(sibling); - this._cacheOwnPosition(); + this._cacheParentPositions(); this._listenToScrollEvents(); } } @@ -835,14 +848,28 @@ export class DropListRef { * Used for updating the internal state of the list. */ private _listenToScrollEvents() { - this._viewportScrollPosition = this._viewportRuler!.getViewportScrollPosition(); - this._viewportScrollSubscription = this._dragDropRegistry.scroll.subscribe(() => { + this._viewportScrollSubscription = this._dragDropRegistry.scroll.subscribe(event => { if (this.isDragging()) { - const newPosition = this._viewportRuler!.getViewportScrollPosition(); - this._updateAfterScroll(this._viewportScrollPosition, newPosition.top, newPosition.left, - this._clientRect); + const target = event.target as HTMLElement | Document; + const position = this._parentPositions.get(target); + + if (position) { + let newTop: number; + let newLeft: number; + + if (target === this._document) { + const scrollPosition = this._viewportRuler!.getViewportScrollPosition(); + newTop = scrollPosition.top; + newLeft = scrollPosition.left; + } else { + newTop = (target as HTMLElement).scrollTop; + newLeft = (target as HTMLElement).scrollLeft; + } + + this._updateAfterScroll(target, newTop, newLeft); + } } else if (this.isReceiving()) { - this._cacheOwnPosition(); + this._cacheParentPositions(); } }); } @@ -877,6 +904,20 @@ function adjustClientRect(clientRect: ClientRect, top: number, left: number) { clientRect.right = clientRect.left + clientRect.width; } +/** + * Checks whether the pointer coordinates are close to a ClientRect. + * @param rect ClientRect to check against. + * @param pointerX Coordinates along the X axis. + * @param pointerY Coordinates along the Y axis. + */ +function isPointerNearClientRect(rect: ClientRect, pointerX: number, pointerY: number): boolean { + const {top, right, bottom, left, width, height} = rect; + const xThreshold = width * DROP_PROXIMITY_THRESHOLD; + const yThreshold = height * DROP_PROXIMITY_THRESHOLD; + + return pointerY > top - yThreshold && pointerY < bottom + yThreshold && + pointerX > left - xThreshold && pointerX < right + xThreshold; +} /** * Finds the index of an item that matches a predicate function. Used as an equivalent diff --git a/tools/public_api_guard/cdk/drag-drop.d.ts b/tools/public_api_guard/cdk/drag-drop.d.ts index 320c21d7fe5c..f89dd9b21d2c 100644 --- a/tools/public_api_guard/cdk/drag-drop.d.ts +++ b/tools/public_api_guard/cdk/drag-drop.d.ts @@ -161,7 +161,8 @@ export declare class CdkDropList implements AfterContentInit, OnDestroy sorted: EventEmitter>; sortingDisabled: boolean; constructor( - element: ElementRef, dragDrop: DragDrop, _changeDetectorRef: ChangeDetectorRef, _dir?: Directionality | undefined, _group?: CdkDropListGroup> | undefined); + element: ElementRef, dragDrop: DragDrop, _changeDetectorRef: ChangeDetectorRef, _dir?: Directionality | undefined, _group?: CdkDropListGroup> | undefined, + _scrollDispatcher?: ScrollDispatcher | undefined); drop(item: CdkDrag, currentIndex: number, previousContainer: CdkDropList, isPointerOverContainer: boolean): void; enter(item: CdkDrag, pointerX: number, pointerY: number): void; exit(item: CdkDrag): void; @@ -351,6 +352,7 @@ export declare class DropListRef { withDirection(direction: Direction): this; withItems(items: DragRef[]): this; withOrientation(orientation: 'vertical' | 'horizontal'): this; + withScrollableParents(elements: HTMLElement[]): this; } export declare function moveItemInArray(array: T[], fromIndex: number, toIndex: number): void;