From 49c4d33c026e58edb292b476262378a9edc1a9b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20K=C3=B6ller?= Date: Thu, 10 Feb 2022 09:50:02 +0100 Subject: [PATCH] feat(cdk/scrolling): make scroller element configurable for virtual scrolling Decouples the scroller from the virtual-scroll-viewport which allows library consumers to use any parent element as a scroller. This is especially helpful when building SPAs with a single global scrollbar. Closes #13862 --- src/cdk/scrolling/public-api.ts | 3 + src/cdk/scrolling/scrollable.ts | 8 ++- src/cdk/scrolling/scrolling-module.ts | 12 +++- .../scrolling/virtual-scroll-viewport.scss | 14 ++-- .../scrolling/virtual-scroll-viewport.spec.ts | 4 +- src/cdk/scrolling/virtual-scroll-viewport.ts | 71 +++++++++++++++---- .../scrolling/virtual-scrollable-element.ts | 30 ++++++++ .../scrolling/virtual-scrollable-window.ts | 22 ++++++ src/cdk/scrolling/virtual-scrollable.ts | 31 ++++++++ .../virtual-scroll/virtual-scroll-demo.html | 22 ++++++ tools/public_api_guard/cdk/scrolling.md | 42 ++++++++++- 11 files changed, 232 insertions(+), 27 deletions(-) create mode 100644 src/cdk/scrolling/virtual-scrollable-element.ts create mode 100644 src/cdk/scrolling/virtual-scrollable-window.ts create mode 100644 src/cdk/scrolling/virtual-scrollable.ts diff --git a/src/cdk/scrolling/public-api.ts b/src/cdk/scrolling/public-api.ts index f4a89ab4032c..3153cd2845e7 100644 --- a/src/cdk/scrolling/public-api.ts +++ b/src/cdk/scrolling/public-api.ts @@ -15,3 +15,6 @@ export * from './virtual-for-of'; export * from './virtual-scroll-strategy'; export * from './virtual-scroll-viewport'; export * from './virtual-scroll-repeater'; +export * from './virtual-scrollable'; +export * from './virtual-scrollable-element'; +export * from './virtual-scrollable-window'; diff --git a/src/cdk/scrolling/scrollable.ts b/src/cdk/scrolling/scrollable.ts index d095e97efed3..a124633a0a07 100644 --- a/src/cdk/scrolling/scrollable.ts +++ b/src/cdk/scrolling/scrollable.ts @@ -49,7 +49,13 @@ export class CdkScrollable implements OnInit, OnDestroy { private _elementScrolled: Observable = new Observable((observer: Observer) => this.ngZone.runOutsideAngular(() => - fromEvent(this.elementRef.nativeElement, 'scroll') + /* it seems like scroll-events are not fired on the documentElement, event if it's the actual scrolling element */ + fromEvent( + this.elementRef.nativeElement === document.documentElement + ? document + : this.elementRef.nativeElement, + 'scroll', + ) .pipe(takeUntil(this._destroyed)) .subscribe(observer), ), diff --git a/src/cdk/scrolling/scrolling-module.ts b/src/cdk/scrolling/scrolling-module.ts index 0f3dcecdc8f0..213c2711df77 100644 --- a/src/cdk/scrolling/scrolling-module.ts +++ b/src/cdk/scrolling/scrolling-module.ts @@ -12,6 +12,8 @@ import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll'; import {CdkScrollable} from './scrollable'; import {CdkVirtualForOf} from './virtual-for-of'; import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; +import {CdkVirtualScrollableElement} from './virtual-scrollable-element'; +import {CdkVirtualScrollableWindow} from './virtual-scrollable-window'; @NgModule({ exports: [CdkScrollable], @@ -30,7 +32,15 @@ export class CdkScrollableModule {} CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport, + CdkVirtualScrollableWindow, + CdkVirtualScrollableElement, + ], + declarations: [ + CdkFixedSizeVirtualScroll, + CdkVirtualForOf, + CdkVirtualScrollViewport, + CdkVirtualScrollableWindow, + CdkVirtualScrollableElement, ], - declarations: [CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport], }) export class ScrollingModule {} diff --git a/src/cdk/scrolling/virtual-scroll-viewport.scss b/src/cdk/scrolling/virtual-scroll-viewport.scss index c24aa19ed3ef..8d41add70983 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.scss +++ b/src/cdk/scrolling/virtual-scroll-viewport.scss @@ -27,14 +27,18 @@ } -// Scrolling container. +// viewport cdk-virtual-scroll-viewport { display: block; position: relative; - overflow: auto; - contain: strict; transform: translateZ(0); +} + +// Scrolling container. +.cdk-virtual-scrollable { + overflow: auto; will-change: scroll-position; + contain: strict; -webkit-overflow-scrolling: touch; } @@ -69,11 +73,7 @@ cdk-virtual-scroll-viewport { // set if it were rendered all at once. This ensures that the scrollable content region is the // correct size. .cdk-virtual-scroll-spacer { - position: absolute; - top: 0; - left: 0; height: 1px; - width: 1px; transform-origin: 0 0; // Note: We can't put `will-change: transform;` here because it causes Safari to not update the diff --git a/src/cdk/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk/scrolling/virtual-scroll-viewport.spec.ts index 223cb0802674..489e2432e042 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.spec.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.spec.ts @@ -772,9 +772,9 @@ describe('CdkVirtualScrollViewport', () => { spyOn(dispatcher, 'register').and.callThrough(); spyOn(dispatcher, 'deregister').and.callThrough(); finishInit(fixture); - expect(dispatcher.register).toHaveBeenCalledWith(testComponent.viewport); + expect(dispatcher.register).toHaveBeenCalledWith(testComponent.viewport.scrollable); fixture.destroy(); - expect(dispatcher.deregister).toHaveBeenCalledWith(testComponent.viewport); + expect(dispatcher.deregister).toHaveBeenCalledWith(testComponent.viewport.scrollable); }), )); diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index d9181fbf8569..e3b9fdf799f6 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -20,6 +20,7 @@ import { OnInit, Optional, Output, + Renderer2, ViewChild, ViewEncapsulation, } from '@angular/core'; @@ -38,6 +39,7 @@ import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-s import {ViewportRuler} from './viewport-ruler'; import {CdkVirtualScrollRepeater} from './virtual-scroll-repeater'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; +import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable'; /** Checks if the given ranges are equal. */ function rangesEqual(r1: ListRange, r2: ListRange): boolean { @@ -67,11 +69,12 @@ const SCROLL_SCHEDULER = providers: [ { provide: CdkScrollable, - useExisting: CdkVirtualScrollViewport, + useFactory: (scrollViewport: CdkVirtualScrollViewport) => scrollViewport.scrollable, + deps: [CdkVirtualScrollViewport], }, ], }) -export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, OnDestroy { +export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements OnInit, OnDestroy { /** Emits when the viewport is detached from a CdkVirtualForOf. */ private readonly _detachedSubject = new Subject(); @@ -179,6 +182,8 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O @Optional() dir: Directionality, scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, + renderer: Renderer2, + @Optional() @Inject(VIRTUAL_SCROLLABLE) public scrollable: CdkVirtualScrollable, ) { super(elementRef, scrollDispatcher, ngZone, dir); @@ -189,11 +194,18 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O this._viewportChanges = viewportRuler.change().subscribe(() => { this.checkViewportSize(); }); + + if (!this.scrollable) { + // No scrollable is provided, so the virtual-scroll-viewport needs to become a scrollable + renderer.addClass(this.elementRef.nativeElement, 'cdk-virtual-scrollable'); + this.scrollable = this; + } } override ngOnInit() { - super.ngOnInit(); - + if (this.scrollable === this) { + super.ngOnInit(); + } // It's still too early to measure the viewport at this point. Deferring with a promise allows // the Viewport to be rendered with the correct size before we measure. We run this outside the // zone to avoid causing more change detection cycles. We handle the change detection loop @@ -203,7 +215,8 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O this._measureViewportSize(); this._scrollStrategy.attach(this); - this.elementScrolled() + this.scrollable + .elementScrolled() .pipe( // Start off with a fake scroll event so we properly detect our initial position. startWith(null), @@ -361,7 +374,7 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O } else { options.top = offset; } - this.scrollTo(options); + this.scrollable.scrollTo(options); } /** @@ -374,16 +387,50 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O } /** - * Gets the current scroll offset from the start of the viewport (in pixels). + * Gets the current scroll offset from the start of the scrollable (in pixels). * @param from The edge to measure the offset from. Defaults to 'top' in vertical mode and 'start' * in horizontal mode. */ override measureScrollOffset( from?: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end', ): number { - return from - ? super.measureScrollOffset(from) - : super.measureScrollOffset(this.orientation === 'horizontal' ? 'start' : 'top'); + // This is to break the call cycle + let measureScrollOffset: InstanceType['measureScrollOffset']; + if (this.scrollable == this) { + measureScrollOffset = (_from: NonNullable) => super.measureScrollOffset(_from); + } else { + measureScrollOffset = (_from: NonNullable) => + this.scrollable.measureScrollOffset(_from); + } + + return Math.max( + 0, + measureScrollOffset(from ?? (this.orientation === 'horizontal' ? 'start' : 'top')) - + this.measureViewportOffset(), + ); + } + + measureViewportOffset(from?: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end') { + let fromRect: 'left' | 'top' | 'right' | 'bottom'; + const LEFT = 'left'; + const RIGHT = 'right'; + const isRtl = this.dir?.value == 'rtl'; + if (from == 'start') { + fromRect = isRtl ? RIGHT : LEFT; + } else if (from == 'end') { + fromRect = isRtl ? LEFT : RIGHT; + } else if (from) { + fromRect = from; + } else { + fromRect = this.orientation === 'horizontal' ? 'left' : 'top'; + } + + const scrollerClientRect = this.scrollable + .getElementRef() + .nativeElement.getBoundingClientRect()[fromRect]; + const viewportClientRect = this.elementRef.nativeElement.getBoundingClientRect()[fromRect]; + + return viewportClientRect - scrollerClientRect; } /** Measure the combined size of all of the rendered items. */ @@ -412,9 +459,7 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O /** Measure the viewport size. */ private _measureViewportSize() { - const viewportEl = this.elementRef.nativeElement; - this._viewportSize = - this.orientation === 'horizontal' ? viewportEl.clientWidth : viewportEl.clientHeight; + this._viewportSize = this.scrollable.measureViewportSize(this.orientation); } /** Queue up change detection to run. */ diff --git a/src/cdk/scrolling/virtual-scrollable-element.ts b/src/cdk/scrolling/virtual-scrollable-element.ts new file mode 100644 index 000000000000..a39c313b43a9 --- /dev/null +++ b/src/cdk/scrolling/virtual-scrollable-element.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directionality} from '@angular/cdk/bidi'; +import {Directive, ElementRef, NgZone, Optional} from '@angular/core'; +import {ScrollDispatcher} from './scroll-dispatcher'; +import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable'; + +@Directive({ + selector: '[cdk-virtual-scrollable-element], [cdkVirtualScrollableElement]', + providers: [{provide: VIRTUAL_SCROLLABLE, useExisting: CdkVirtualScrollableElement}], + host: { + 'class': 'cdk-virtual-scrollable', + }, +}) +export class CdkVirtualScrollableElement extends CdkVirtualScrollable { + constructor( + elementRef: ElementRef, + scrollDispatcher: ScrollDispatcher, + ngZone: NgZone, + @Optional() dir: Directionality, + ) { + super(elementRef, scrollDispatcher, ngZone, dir); + } +} diff --git a/src/cdk/scrolling/virtual-scrollable-window.ts b/src/cdk/scrolling/virtual-scrollable-window.ts new file mode 100644 index 000000000000..af48b8853475 --- /dev/null +++ b/src/cdk/scrolling/virtual-scrollable-window.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directionality} from '@angular/cdk/bidi'; +import {Directive, ElementRef, NgZone, Optional} from '@angular/core'; +import {ScrollDispatcher} from './scroll-dispatcher'; +import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable'; + +@Directive({ + selector: 'cdk-virtual-scroll-viewport[scrollable-window]', + providers: [{provide: VIRTUAL_SCROLLABLE, useExisting: CdkVirtualScrollableWindow}], +}) +export class CdkVirtualScrollableWindow extends CdkVirtualScrollable { + constructor(scrollDispatcher: ScrollDispatcher, ngZone: NgZone, @Optional() dir: Directionality) { + super(new ElementRef(document.documentElement), scrollDispatcher, ngZone, dir); + } +} diff --git a/src/cdk/scrolling/virtual-scrollable.ts b/src/cdk/scrolling/virtual-scrollable.ts new file mode 100644 index 000000000000..dd2a259bfc8c --- /dev/null +++ b/src/cdk/scrolling/virtual-scrollable.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directionality} from '@angular/cdk/bidi'; +import {Directive, ElementRef, InjectionToken, NgZone, Optional} from '@angular/core'; +import {ScrollDispatcher} from './scroll-dispatcher'; +import {CdkScrollable} from './scrollable'; + +export const VIRTUAL_SCROLLABLE = new InjectionToken('VIRTUAL_SCROLLABLE'); + +@Directive() +export abstract class CdkVirtualScrollable extends CdkScrollable { + constructor( + elementRef: ElementRef, + scrollDispatcher: ScrollDispatcher, + ngZone: NgZone, + @Optional() dir?: Directionality, + ) { + super(elementRef, scrollDispatcher, ngZone, dir); + } + + measureViewportSize(orientation: 'horizontal' | 'vertical') { + const viewportEl = this.elementRef.nativeElement; + return orientation === 'horizontal' ? viewportEl.clientWidth : viewportEl.clientHeight; + } +} diff --git a/src/dev-app/virtual-scroll/virtual-scroll-demo.html b/src/dev-app/virtual-scroll/virtual-scroll-demo.html index 7ada2fe5a921..f959b618d0ac 100644 --- a/src/dev-app/virtual-scroll/virtual-scroll-demo.html +++ b/src/dev-app/virtual-scroll/virtual-scroll-demo.html @@ -178,3 +178,25 @@

Append only

Item #{{i}} - ({{size}}px) + +

Custom virtual scroller

+ +
+

Content before virtual scrolling items

+ +
+ Item #{{i}} - ({{size}}px) +
+
+

Content after virtual scrolling items

+
+ +

Window virtual scroller

+ + +
+ Item #{{i}} - ({{size}}px) +
+
diff --git a/tools/public_api_guard/cdk/scrolling.md b/tools/public_api_guard/cdk/scrolling.md index d37bc51abb8b..72f0ff45d942 100644 --- a/tools/public_api_guard/cdk/scrolling.md +++ b/tools/public_api_guard/cdk/scrolling.md @@ -136,6 +136,35 @@ export type CdkVirtualForOfContext = { odd: boolean; }; +// @public (undocumented) +export abstract class CdkVirtualScrollable extends CdkScrollable { + constructor(elementRef: ElementRef, scrollDispatcher: ScrollDispatcher, ngZone: NgZone, dir?: Directionality); + // (undocumented) + measureViewportSize(orientation: 'horizontal' | 'vertical'): number; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public (undocumented) +export class CdkVirtualScrollableElement extends CdkVirtualScrollable { + constructor(elementRef: ElementRef, scrollDispatcher: ScrollDispatcher, ngZone: NgZone, dir: Directionality); + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public (undocumented) +export class CdkVirtualScrollableWindow extends CdkVirtualScrollable { + constructor(scrollDispatcher: ScrollDispatcher, ngZone: NgZone, dir: Directionality); + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public export interface CdkVirtualScrollRepeater { // (undocumented) @@ -145,8 +174,8 @@ export interface CdkVirtualScrollRepeater { } // @public -export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, OnDestroy { - constructor(elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, ngZone: NgZone, _scrollStrategy: VirtualScrollStrategy, dir: Directionality, scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler); +export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements OnInit, OnDestroy { + constructor(elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, ngZone: NgZone, _scrollStrategy: VirtualScrollStrategy, dir: Directionality, scrollDispatcher: ScrollDispatcher, viewportRuler: ViewportRuler, renderer: Renderer2, scrollable: CdkVirtualScrollable); get appendOnly(): boolean; set appendOnly(value: BooleanInput); attach(forOf: CdkVirtualScrollRepeater): void; @@ -163,12 +192,16 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O measureRenderedContentSize(): number; measureScrollOffset(from?: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end'): number; // (undocumented) + measureViewportOffset(from?: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end'): number; + // (undocumented) ngOnDestroy(): void; // (undocumented) ngOnInit(): void; get orientation(): 'horizontal' | 'vertical'; set orientation(orientation: 'horizontal' | 'vertical'); readonly renderedRangeStream: Observable; + // (undocumented) + scrollable: CdkVirtualScrollable; readonly scrolledIndexChange: Observable; scrollToIndex(index: number, behavior?: ScrollBehavior): void; scrollToOffset(offset: number, behavior?: ScrollBehavior): void; @@ -180,7 +213,7 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public @@ -300,6 +333,9 @@ export interface ViewportScrollPosition { // @public export const VIRTUAL_SCROLL_STRATEGY: InjectionToken; +// @public (undocumented) +export const VIRTUAL_SCROLLABLE: InjectionToken; + // @public export interface VirtualScrollStrategy { attach(viewport: CdkVirtualScrollViewport): void;