diff --git a/src/cdk/scrolling/fixed-size-virtual-scroll.ts b/src/cdk/scrolling/fixed-size-virtual-scroll.ts index eeb7480d65b9..350852b38312 100644 --- a/src/cdk/scrolling/fixed-size-virtual-scroll.ts +++ b/src/cdk/scrolling/fixed-size-virtual-scroll.ts @@ -6,7 +6,10 @@ * found in the LICENSE file at https://angular.io/license */ -import {coerceNumberProperty, NumberInput} from '@angular/cdk/coercion'; +import { + coerceNumberProperty, + NumberInput +} from '@angular/cdk/coercion'; import {Directive, forwardRef, Input, OnChanges} from '@angular/core'; import {Observable, Subject} from 'rxjs'; import {distinctUntilChanged} from 'rxjs/operators'; diff --git a/src/cdk/scrolling/scrolling.md b/src/cdk/scrolling/scrolling.md index 1ddf16cc06c9..aec8935d065a 100644 --- a/src/cdk/scrolling/scrolling.md +++ b/src/cdk/scrolling/scrolling.md @@ -121,3 +121,10 @@ custom strategy by creating a class that implements the `VirtualScrollStrategy` providing it as the `VIRTUAL_SCROLL_STRATEGY` on the component containing your viewport. + +### Append only mode +Virtual scroll viewports that render nontrivial items may find it more performant to simply append +to the list as the user scrolls without removing rendered views. The `appendOnly` input ensures +views that are already rendered persist in the DOM after they scroll out of view. + + diff --git a/src/cdk/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk/scrolling/virtual-scroll-viewport.spec.ts index 2ac1602f2a8b..0ce137cfcf47 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.spec.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.spec.ts @@ -928,6 +928,34 @@ describe('CdkVirtualScrollViewport', () => { expect(testComponent.trackBy).toHaveBeenCalled(); })); }); + + describe('with append only', () => { + let fixture: ComponentFixture; + let testComponent: VirtualScrollWithAppendOnly; + let viewport: CdkVirtualScrollViewport; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ScrollingModule, CommonModule], + declarations: [VirtualScrollWithAppendOnly], + }).compileComponents(); + fixture = TestBed.createComponent(VirtualScrollWithAppendOnly); + testComponent = fixture.componentInstance; + viewport = testComponent.viewport; + })); + + it('should not remove item that have already been rendered', fakeAsync(() => { + finishInit(fixture); + viewport.setRenderedRange({start: 100, end: 200}); + fixture.detectChanges(); + flush(); + viewport.setRenderedRange({start: 10, end: 50}); + fixture.detectChanges(); + flush(); + + expect(viewport.getRenderedRange()).toEqual({start: 0, end: 200}); + })); + }); }); @@ -1182,3 +1210,36 @@ class DelayedInitializationVirtualScroll { trackBy = jasmine.createSpy('trackBy').and.callFake((item: unknown) => item); renderVirtualFor = false; } + +@Component({ + template: ` + +
{{item}}
+
+ `, + styles: [` + .cdk-virtual-scroll-content-wrapper { + display: flex; + flex-direction: column; + } + + .cdk-virtual-scroll-viewport { + width: 200px; + height: 200px; + background-color: #f5f5f5; + } + + .item { + width: 100%; + height: 50px; + box-sizing: border-box; + border: 1px dashed #ccc; + } + `], + encapsulation: ViewEncapsulation.None +}) +class VirtualScrollWithAppendOnly { + @ViewChild(CdkVirtualScrollViewport, {static: true}) viewport: CdkVirtualScrollViewport; + itemSize = 50; + items = Array(20000).fill(0).map((_, i) => i); +} diff --git a/src/cdk/scrolling/virtual-scroll-viewport.ts b/src/cdk/scrolling/virtual-scroll-viewport.ts index 5eae4fd7bc6c..6e12c2fb784c 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.ts @@ -37,6 +37,7 @@ import {CdkScrollable, ExtendedScrollToOptions} from './scrollable'; import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy'; import {ViewportRuler} from './viewport-ruler'; import {CdkVirtualScrollRepeater} from './virtual-scroll-repeater'; +import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; /** Checks if the given ranges are equal. */ function rangesEqual(r1: ListRange, r2: ListRange): boolean { @@ -89,6 +90,19 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O } private _orientation: 'horizontal' | 'vertical' = 'vertical'; + /** + * Whether rendered items should persist in the DOM after scrolling out of view. By default, items + * will be removed. + */ + @Input() + get appendOnly(): boolean { + return this._appendOnly; + } + set appendOnly(value: boolean) { + this._appendOnly = coerceBooleanProperty(value); + } + private _appendOnly = false; + // Note: we don't use the typical EventEmitter here because we need to subscribe to the scroll // strategy lazily (i.e. only if the user is actually listening to the events). We do this because // depending on how the strategy calculates the scrolled index, it may come at a cost to @@ -271,6 +285,9 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O /** Sets the currently rendered range of indices. */ setRenderedRange(range: ListRange) { if (!rangesEqual(this._renderedRange, range)) { + if (this.appendOnly) { + range = {start: 0, end: Math.max(this._renderedRange.end, range.end)}; + } this._renderedRangeSubject.next(this._renderedRange = range); this._markChangeDetectionNeeded(() => this._scrollStrategy.onContentRendered()); } @@ -431,4 +448,6 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O this._totalContentWidth = this.orientation === 'horizontal' ? `${this._totalContentSize}px` : ''; } + + static ngAcceptInputType_appendOnly: BooleanInput; } diff --git a/src/components-examples/cdk/scrolling/cdk-virtual-scroll-append-only/cdk-virtual-scroll-append-only-example.css b/src/components-examples/cdk/scrolling/cdk-virtual-scroll-append-only/cdk-virtual-scroll-append-only-example.css new file mode 100644 index 000000000000..55eb0cd3c6e6 --- /dev/null +++ b/src/components-examples/cdk/scrolling/cdk-virtual-scroll-append-only/cdk-virtual-scroll-append-only-example.css @@ -0,0 +1,9 @@ +.example-viewport { + height: 200px; + width: 200px; + border: 1px solid black; +} + +.example-item { + height: 50px; +} diff --git a/src/components-examples/cdk/scrolling/cdk-virtual-scroll-append-only/cdk-virtual-scroll-append-only-example.html b/src/components-examples/cdk/scrolling/cdk-virtual-scroll-append-only/cdk-virtual-scroll-append-only-example.html new file mode 100644 index 000000000000..6e90e653a9aa --- /dev/null +++ b/src/components-examples/cdk/scrolling/cdk-virtual-scroll-append-only/cdk-virtual-scroll-append-only-example.html @@ -0,0 +1,3 @@ + +
{{item}}
+
diff --git a/src/components-examples/cdk/scrolling/cdk-virtual-scroll-append-only/cdk-virtual-scroll-append-only-example.ts b/src/components-examples/cdk/scrolling/cdk-virtual-scroll-append-only/cdk-virtual-scroll-append-only-example.ts new file mode 100644 index 000000000000..eebdca6275a5 --- /dev/null +++ b/src/components-examples/cdk/scrolling/cdk-virtual-scroll-append-only/cdk-virtual-scroll-append-only-example.ts @@ -0,0 +1,12 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; + +/** @title Virtual scroll with view recycling disabled. */ +@Component({ + selector: 'cdk-virtual-scroll-append-only-example', + styleUrls: ['cdk-virtual-scroll-append-only-example.css'], + templateUrl: 'cdk-virtual-scroll-append-only-example.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CdkVirtualScrollAppendOnlyExample { + items = Array.from({length: 100000}).map((_, i) => `Item #${i}`); +} diff --git a/src/components-examples/cdk/scrolling/index.ts b/src/components-examples/cdk/scrolling/index.ts index a56f91f77759..8bda4c551b7f 100644 --- a/src/components-examples/cdk/scrolling/index.ts +++ b/src/components-examples/cdk/scrolling/index.ts @@ -1,5 +1,8 @@ import {ScrollingModule} from '@angular/cdk/scrolling'; import {NgModule} from '@angular/core'; +import { + CdkVirtualScrollAppendOnlyExample +} from './cdk-virtual-scroll-append-only/cdk-virtual-scroll-append-only-example'; import { CdkVirtualScrollContextExample } from './cdk-virtual-scroll-context/cdk-virtual-scroll-context-example'; @@ -24,6 +27,7 @@ import { } from './cdk-virtual-scroll-template-cache/cdk-virtual-scroll-template-cache-example'; export { + CdkVirtualScrollAppendOnlyExample, CdkVirtualScrollContextExample, CdkVirtualScrollCustomStrategyExample, CdkVirtualScrollDataSourceExample, @@ -35,6 +39,7 @@ export { }; const EXAMPLES = [ + CdkVirtualScrollAppendOnlyExample, CdkVirtualScrollContextExample, CdkVirtualScrollCustomStrategyExample, CdkVirtualScrollDataSourceExample, diff --git a/src/dev-app/virtual-scroll/virtual-scroll-demo.html b/src/dev-app/virtual-scroll/virtual-scroll-demo.html index 40792b7aab14..7ada2fe5a921 100644 --- a/src/dev-app/virtual-scroll/virtual-scroll-demo.html +++ b/src/dev-app/virtual-scroll/virtual-scroll-demo.html @@ -170,3 +170,11 @@

Use with <table>

+ +

Append only

+ +
+ Item #{{i}} - ({{size}}px) +
+
diff --git a/tools/public_api_guard/cdk/scrolling.d.ts b/tools/public_api_guard/cdk/scrolling.d.ts index 9b842bfff95f..7b25241f2465 100644 --- a/tools/public_api_guard/cdk/scrolling.d.ts +++ b/tools/public_api_guard/cdk/scrolling.d.ts @@ -120,6 +120,8 @@ export declare class CdkVirtualScrollViewport extends CdkScrollable implements O _contentWrapper: ElementRef; _totalContentHeight: string; _totalContentWidth: string; + get appendOnly(): boolean; + set appendOnly(value: boolean); elementRef: ElementRef; get orientation(): 'horizontal' | 'vertical'; set orientation(orientation: 'horizontal' | 'vertical'); @@ -143,7 +145,8 @@ export declare class CdkVirtualScrollViewport extends CdkScrollable implements O setRenderedContentOffset(offset: number, to?: 'to-start' | 'to-end'): void; setRenderedRange(range: ListRange): void; setTotalContentSize(size: number): void; - static ɵcmp: i0.ɵɵComponentDeclaration; + static ngAcceptInputType_appendOnly: BooleanInput; + static ɵcmp: i0.ɵɵComponentDeclaration; static ɵfac: i0.ɵɵFactoryDeclaration; }