diff --git a/src/cdk/collections/dispose-view-repeater-strategy.ts b/src/cdk/collections/dispose-view-repeater-strategy.ts new file mode 100644 index 000000000000..edbfaec06b77 --- /dev/null +++ b/src/cdk/collections/dispose-view-repeater-strategy.ts @@ -0,0 +1,72 @@ +/** + * @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 { + EmbeddedViewRef, + IterableChangeRecord, + IterableChanges, + ViewContainerRef +} from '@angular/core'; +import { + _ViewRepeater, + _ViewRepeaterItemChanged, + _ViewRepeaterItemContext, + _ViewRepeaterItemContextFactory, + _ViewRepeaterItemValueResolver, + _ViewRepeaterOperation +} from './view-repeater'; + +/** + * A repeater that destroys views when they are removed from a + * {@link ViewContainerRef}. When new items are inserted into the container, + * the repeater will always construct a new embedded view for each item. + * + * @template T The type for the embedded view's $implicit property. + * @template R The type for the item in each IterableDiffer change record. + * @template C The type for the context passed to each embedded view. + */ +export class _DisposeViewRepeaterStrategy> + implements _ViewRepeater { + applyChanges(changes: IterableChanges, + viewContainerRef: ViewContainerRef, + itemContextFactory: _ViewRepeaterItemContextFactory, + itemValueResolver: _ViewRepeaterItemValueResolver, + itemViewChanged?: _ViewRepeaterItemChanged) { + changes.forEachOperation( + (record: IterableChangeRecord, + adjustedPreviousIndex: number | null, + currentIndex: number | null) => { + let view: EmbeddedViewRef | undefined; + let operation: _ViewRepeaterOperation; + if (record.previousIndex == null) { + const insertContext = itemContextFactory(record, adjustedPreviousIndex, currentIndex); + view = viewContainerRef.createEmbeddedView( + insertContext.templateRef, insertContext.context, insertContext.index); + operation = _ViewRepeaterOperation.INSERTED; + } else if (currentIndex == null) { + viewContainerRef.remove(adjustedPreviousIndex!); + operation = _ViewRepeaterOperation.REMOVED; + } else { + view = viewContainerRef.get(adjustedPreviousIndex!) as EmbeddedViewRef; + viewContainerRef.move(view!, currentIndex); + operation = _ViewRepeaterOperation.MOVED; + } + + if (itemViewChanged) { + itemViewChanged({ + context: view?.context, + operation, + record, + }); + } + }); + } + + detach() { + } +} diff --git a/src/cdk/collections/public-api.ts b/src/cdk/collections/public-api.ts index d77a45408c72..2d2d06f1a2eb 100644 --- a/src/cdk/collections/public-api.ts +++ b/src/cdk/collections/public-api.ts @@ -9,9 +9,12 @@ export * from './array-data-source'; export * from './collection-viewer'; export * from './data-source'; +export * from './dispose-view-repeater-strategy'; +export * from './recycle-view-repeater-strategy'; export * from './selection-model'; export { UniqueSelectionDispatcher, UniqueSelectionDispatcherListener, } from './unique-selection-dispatcher'; export * from './tree-adapter'; +export * from './view-repeater'; diff --git a/src/cdk/collections/recycle-view-repeater-strategy.ts b/src/cdk/collections/recycle-view-repeater-strategy.ts new file mode 100644 index 000000000000..2f72e42dc906 --- /dev/null +++ b/src/cdk/collections/recycle-view-repeater-strategy.ts @@ -0,0 +1,167 @@ +/** + * @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 { + EmbeddedViewRef, + IterableChangeRecord, + IterableChanges, + ViewContainerRef +} from '@angular/core'; +import { + _ViewRepeater, + _ViewRepeaterItemChanged, + _ViewRepeaterItemContext, + _ViewRepeaterItemContextFactory, + _ViewRepeaterItemInsertArgs, + _ViewRepeaterItemValueResolver, + _ViewRepeaterOperation +} from './view-repeater'; + + +/** + * A repeater that caches views when they are removed from a + * {@link ViewContainerRef}. When new items are inserted into the container, + * the repeater will reuse one of the cached views instead of creating a new + * embedded view. Recycling cached views reduces the quantity of expensive DOM + * inserts. + * + * @template T The type for the embedded view's $implicit property. + * @template R The type for the item in each IterableDiffer change record. + * @template C The type for the context passed to each embedded view. + */ +export class _RecycleViewRepeaterStrategy> + implements _ViewRepeater { + /** + * The size of the cache used to store unused views. + * Setting the cache size to `0` will disable caching. Defaults to 20 views. + */ + viewCacheSize: number = 20; + + /** + * View cache that stores embedded view instances that have been previously stamped out, + * but don't are not currently rendered. The view repeater will reuse these views rather than + * creating brand new ones. + * + * TODO(michaeljamesparsons) Investigate whether using a linked list would improve performance. + */ + private _viewCache: EmbeddedViewRef[] = []; + + /** Apply changes to the DOM. */ + applyChanges(changes: IterableChanges, + viewContainerRef: ViewContainerRef, + itemContextFactory: _ViewRepeaterItemContextFactory, + itemValueResolver: _ViewRepeaterItemValueResolver, + itemViewChanged?: _ViewRepeaterItemChanged) { + // Rearrange the views to put them in the right location. + changes.forEachOperation((record: IterableChangeRecord, + adjustedPreviousIndex: number | null, + currentIndex: number | null) => { + let view: EmbeddedViewRef | undefined; + let operation: _ViewRepeaterOperation; + if (record.previousIndex == null) { // Item added. + const viewArgsFactory = () => itemContextFactory( + record, adjustedPreviousIndex, currentIndex); + view = this._insertView(viewArgsFactory, currentIndex!, viewContainerRef, + itemValueResolver(record)); + operation = view ? _ViewRepeaterOperation.INSERTED : _ViewRepeaterOperation.REPLACED; + } else if (currentIndex == null) { // Item removed. + this._detachAndCacheView(adjustedPreviousIndex!, viewContainerRef); + operation = _ViewRepeaterOperation.REMOVED; + } else { // Item moved. + view = this._moveView(adjustedPreviousIndex!, currentIndex!, viewContainerRef, + itemValueResolver(record)); + operation = _ViewRepeaterOperation.MOVED; + } + + if (itemViewChanged) { + itemViewChanged({ + context: view?.context, + operation, + record, + }); + } + }); + } + + detach() { + for (const view of this._viewCache) { + view.destroy(); + } + } + + /** + * Inserts a view for a new item, either from the cache or by creating a new + * one. Returns `undefined` if the item was inserted into a cached view. + */ + private _insertView(viewArgsFactory: () => _ViewRepeaterItemInsertArgs, currentIndex: number, + viewContainerRef: ViewContainerRef, + value: T): EmbeddedViewRef | undefined { + let cachedView = this._insertViewFromCache(currentIndex!, viewContainerRef); + if (cachedView) { + cachedView.context.$implicit = value; + return undefined; + } + + const viewArgs = viewArgsFactory(); + return viewContainerRef.createEmbeddedView( + viewArgs.templateRef, viewArgs.context, viewArgs.index); + } + + /** Detaches the view at the given index and inserts into the view cache. */ + private _detachAndCacheView(index: number, viewContainerRef: ViewContainerRef) { + const detachedView = this._detachView(index, viewContainerRef); + this._maybeCacheView(detachedView, viewContainerRef); + } + + /** Moves view at the previous index to the current index. */ + private _moveView(adjustedPreviousIndex: number, currentIndex: number, + viewContainerRef: ViewContainerRef, value: T): EmbeddedViewRef { + const view = viewContainerRef.get(adjustedPreviousIndex!) as + EmbeddedViewRef; + viewContainerRef.move(view, currentIndex); + view.context.$implicit = value; + return view; + } + + /** + * Cache the given detached view. If the cache is full, the view will be + * destroyed. + */ + private _maybeCacheView(view: EmbeddedViewRef, viewContainerRef: ViewContainerRef) { + if (this._viewCache.length < this.viewCacheSize) { + this._viewCache.push(view); + } else { + const index = viewContainerRef.indexOf(view); + + // The host component could remove views from the container outside of + // the view repeater. It's unlikely this will occur, but just in case, + // destroy the view on its own, otherwise destroy it through the + // container to ensure that all the references are removed. + if (index === -1) { + view.destroy(); + } else { + viewContainerRef.remove(index); + } + } + } + + /** Inserts a recycled view from the cache at the given index. */ + private _insertViewFromCache(index: number, + viewContainerRef: ViewContainerRef): EmbeddedViewRef | null { + const cachedView = this._viewCache.pop(); + if (cachedView) { + viewContainerRef.insert(cachedView, index); + } + return cachedView || null; + } + + /** Detaches the embedded view at the given index. */ + private _detachView(index: number, viewContainerRef: ViewContainerRef): EmbeddedViewRef { + return viewContainerRef.detach(index) as EmbeddedViewRef; + } +} diff --git a/src/cdk/collections/view-repeater.ts b/src/cdk/collections/view-repeater.ts new file mode 100644 index 000000000000..568e7a2857ea --- /dev/null +++ b/src/cdk/collections/view-repeater.ts @@ -0,0 +1,122 @@ +/** + * @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 { + InjectionToken, + IterableChangeRecord, + IterableChanges, + TemplateRef, + ViewContainerRef +} from '@angular/core'; + +/** + * The context for an embedded view in the repeater's view container. + * + * @template T The type for the embedded view's $implicit property. + */ +export interface _ViewRepeaterItemContext { + $implicit?: T; +} + +/** + * The arguments needed to construct an embedded view for an item in a view + * container. + * + * @template C The type for the context passed to each embedded view. + */ +export interface _ViewRepeaterItemInsertArgs { + templateRef: TemplateRef; + context?: C; + index?: number; +} + +/** + * A factory that derives the embedded view context for an item in a view + * container. + * + * @template T The type for the embedded view's $implicit property. + * @template R The type for the item in each IterableDiffer change record. + * @template C The type for the context passed to each embedded view. + */ +export type _ViewRepeaterItemContextFactory> = + (record: IterableChangeRecord, + adjustedPreviousIndex: number | null, + currentIndex: number | null) => _ViewRepeaterItemInsertArgs; + +/** + * Extracts the value of an item from an {@link IterableChangeRecord}. + * + * @template T The type for the embedded view's $implicit property. + * @template R The type for the item in each IterableDiffer change record. + */ +export type _ViewRepeaterItemValueResolver = + (record: IterableChangeRecord) => T; + +/** Indicates how a view was changed by a {@link _ViewRepeater}. */ +export const enum _ViewRepeaterOperation { + /** The content of an existing view was replaced with another item. */ + REPLACED, + /** A new view was created with `createEmbeddedView`. */ + INSERTED, + /** The position of a view changed, but the content remains the same. */ + MOVED, + /** A view was detached from the view container. */ + REMOVED, +} + +/** + * Meta data describing the state of a view after it was updated by a + * {@link _ViewRepeater}. + * + * @template R The type for the item in each IterableDiffer change record. + * @template C The type for the context passed to each embedded view. + */ +export interface _ViewRepeaterItemChange { + /** The view's context after it was changed. */ + context?: C; + /** Indicates how the view was changed. */ + operation: _ViewRepeaterOperation; + /** The view's corresponding change record. */ + record: IterableChangeRecord; +} + +/** + * Type for a callback to be executed after a view has changed. + * + * @template R The type for the item in each IterableDiffer change record. + * @template C The type for the context passed to each embedded view. + */ +export type _ViewRepeaterItemChanged = + (change: _ViewRepeaterItemChange) => void; + +/** + * Describes a strategy for rendering items in a {@link ViewContainerRef}. + * + * @template T The type for the embedded view's $implicit property. + * @template R The type for the item in each IterableDiffer change record. + * @template C The type for the context passed to each embedded view. + */ +export interface _ViewRepeater> { + applyChanges( + changes: IterableChanges, + viewContainerRef: ViewContainerRef, + itemContextFactory: _ViewRepeaterItemContextFactory, + itemValueResolver: _ViewRepeaterItemValueResolver, + itemViewChanged?: _ViewRepeaterItemChanged): void; + + detach(): void; +} + +/** + * Injection token for {@link _ViewRepeater}. + * + * INTERNAL ONLY - not for public consumption. + * @docs-private + */ +export const _VIEW_REPEATER_STRATEGY = new InjectionToken< + _ViewRepeater>>('_ViewRepeater'); diff --git a/src/cdk/scrolling/virtual-for-of.ts b/src/cdk/scrolling/virtual-for-of.ts index b8a70f5f4a1c..8237cc07d916 100644 --- a/src/cdk/scrolling/virtual-for-of.ts +++ b/src/cdk/scrolling/virtual-for-of.ts @@ -12,11 +12,15 @@ import { DataSource, ListRange, isDataSource, + _RecycleViewRepeaterStrategy, + _VIEW_REPEATER_STRATEGY, + _ViewRepeaterItemInsertArgs, } from '@angular/cdk/collections'; import { Directive, DoCheck, EmbeddedViewRef, + Inject, Input, IterableChangeRecord, IterableChanges, @@ -30,10 +34,11 @@ import { TrackByFunction, ViewContainerRef, } from '@angular/core'; +import {coerceNumberProperty, NumberInput} from '@angular/cdk/coercion'; import {Observable, Subject, of as observableOf, isObservable} from 'rxjs'; import {pairwise, shareReplay, startWith, switchMap, takeUntil} from 'rxjs/operators'; -import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; import {CdkVirtualScrollRepeater} from './virtual-scroll-repeater'; +import {CdkVirtualScrollViewport} from './virtual-scroll-viewport'; /** The context for an item rendered by `CdkVirtualForOf` */ @@ -74,6 +79,9 @@ function getSize(orientation: 'horizontal' | 'vertical', node: Node): number { */ @Directive({ selector: '[cdkVirtualFor][cdkVirtualForOf]', + providers: [ + {provide: _VIEW_REPEATER_STRATEGY, useClass: _RecycleViewRepeaterStrategy}, + ] }) export class CdkVirtualForOf implements CdkVirtualScrollRepeater, CollectionViewer, DoCheck, OnDestroy { @@ -98,6 +106,7 @@ export class CdkVirtualForOf implements isObservable(value) ? value : Array.prototype.slice.call(value || []))); } } + _cdkVirtualForOf: DataSource | Observable | NgIterable | null | undefined; /** @@ -129,21 +138,27 @@ export class CdkVirtualForOf implements * The size of the cache used to store templates that are not being used for re-use later. * Setting the cache size to `0` will disable caching. Defaults to 20 templates. */ - @Input() cdkVirtualForTemplateCacheSize: number = 20; + @Input() + get cdkVirtualForTemplateCacheSize() { + return this._viewRepeater.viewCacheSize; + } + set cdkVirtualForTemplateCacheSize(size: number) { + this._viewRepeater.viewCacheSize = coerceNumberProperty(size); + } /** Emits whenever the data in the current DataSource changes. */ dataStream: Observable> = this._dataSourceChanges - .pipe( - // Start off with null `DataSource`. - startWith(null!), - // Bundle up the previous and current data sources so we can work with both. - pairwise(), - // Use `_changeDataSource` to disconnect from the previous data source and connect to the - // new one, passing back a stream of data changes which we run through `switchMap` to give - // us a data stream that emits the latest data from whatever the current `DataSource` is. - switchMap(([prev, cur]) => this._changeDataSource(prev, cur)), - // Replay the last emitted data when someone subscribes. - shareReplay(1)); + .pipe( + // Start off with null `DataSource`. + startWith(null!), + // Bundle up the previous and current data sources so we can work with both. + pairwise(), + // Use `_changeDataSource` to disconnect from the previous data source and connect to the + // new one, passing back a stream of data changes which we run through `switchMap` to give + // us a data stream that emits the latest data from whatever the current `DataSource` is. + switchMap(([prev, cur]) => this._changeDataSource(prev, cur)), + // Replay the last emitted data when someone subscribes. + shareReplay(1)); /** The differ used to calculate changes to the data. */ private _differ: IterableDiffer | null = null; @@ -157,13 +172,6 @@ export class CdkVirtualForOf implements /** The currently rendered range of indices. */ private _renderedRange: ListRange; - /** - * The template cache used to hold on ot template instancess that have been stamped out, but don't - * currently need to be rendered. These instances will be reused in the future rather than - * stamping out brand new ones. - */ - private _templateCache: EmbeddedViewRef>[] = []; - /** Whether the rendered data should be updated during the next ngDoCheck cycle. */ private _needsUpdate = false; @@ -176,6 +184,9 @@ export class CdkVirtualForOf implements private _template: TemplateRef>, /** The set of available differs. */ private _differs: IterableDiffers, + /** The strategy used to render items in the virtual scroll viewport. */ + @Inject(_VIEW_REPEATER_STRATEGY) + private _viewRepeater: _RecycleViewRepeaterStrategy>, /** The virtual scrolling viewport that these items are being rendered in. */ @SkipSelf() private _viewport: CdkVirtualScrollViewport, ngZone: NgZone) { @@ -248,10 +259,7 @@ export class CdkVirtualForOf implements this._destroyed.next(); this._destroyed.complete(); - - for (let view of this._templateCache) { - view.destroy(); - } + this._viewRepeater.detach(); } /** React to scroll state changes in the viewport. */ @@ -268,7 +276,7 @@ export class CdkVirtualForOf implements /** Swap out one `DataSource` for another. */ private _changeDataSource(oldDs: DataSource | null, newDs: DataSource | null): - Observable> { + Observable> { if (oldDs) { oldDs.disconnect(this); @@ -293,22 +301,13 @@ export class CdkVirtualForOf implements /** Apply changes to the DOM. */ private _applyChanges(changes: IterableChanges) { - // Rearrange the views to put them in the right location. - changes.forEachOperation((record: IterableChangeRecord, - adjustedPreviousIndex: number | null, - currentIndex: number | null) => { - if (record.previousIndex == null) { // Item added. - const view = this._insertViewForNewItem(currentIndex!); - view.context.$implicit = record.item; - } else if (currentIndex == null) { // Item removed. - this._cacheView(this._detachView(adjustedPreviousIndex !)); - } else { // Item moved. - const view = this._viewContainerRef.get(adjustedPreviousIndex!) as - EmbeddedViewRef>; - this._viewContainerRef.move(view, currentIndex); - view.context.$implicit = record.item; - } - }); + this._viewRepeater.applyChanges( + changes, + this._viewContainerRef, + (record: IterableChangeRecord, + adjustedPreviousIndex: number | null, + currentIndex: number | null) => this._getEmbeddedViewArgs(record, currentIndex!), + (record) => record.item); // Update $implicit for any items that had an identity change. changes.forEachIdentityChange((record: IterableChangeRecord) => { @@ -328,29 +327,6 @@ export class CdkVirtualForOf implements } } - /** Cache the given detached view. */ - private _cacheView(view: EmbeddedViewRef>) { - if (this._templateCache.length < this.cdkVirtualForTemplateCacheSize) { - this._templateCache.push(view); - } else { - const index = this._viewContainerRef.indexOf(view); - - // It's very unlikely that the index will ever be -1, but just in case, - // destroy the view on its own, otherwise destroy it through the - // container to ensure that all the references are removed. - if (index === -1) { - view.destroy(); - } else { - this._viewContainerRef.remove(index); - } - } - } - - /** Inserts a view for a new item, either from the cache or by creating a new one. */ - private _insertViewForNewItem(index: number): EmbeddedViewRef> { - return this._insertViewFromCache(index) || this._createEmbeddedViewAt(index); - } - /** Update the computed properties on the `CdkVirtualForOfContext`. */ private _updateComputedContextProperties(context: CdkVirtualForOfContext) { context.first = context.index === 0; @@ -359,38 +335,29 @@ export class CdkVirtualForOf implements context.odd = !context.even; } - /** Creates a new embedded view and moves it to the given index */ - private _createEmbeddedViewAt(index: number): EmbeddedViewRef> { + private _getEmbeddedViewArgs(record: IterableChangeRecord, index: number): + _ViewRepeaterItemInsertArgs> { // Note that it's important that we insert the item directly at the proper index, // rather than inserting it and the moving it in place, because if there's a directive // on the same node that injects the `ViewContainerRef`, Angular will insert another // comment node which can throw off the move when it's being repeated for all items. - return this._viewContainerRef.createEmbeddedView(this._template, { - $implicit: null!, - // It's guaranteed that the iterable is not "undefined" or "null" because we only - // generate views for elements if the "cdkVirtualForOf" iterable has elements. - cdkVirtualForOf: this._cdkVirtualForOf!, - index: -1, - count: -1, - first: false, - last: false, - odd: false, - even: false - }, index); - } - - /** Inserts a recycled view from the cache at the given index. */ - private _insertViewFromCache(index: number): EmbeddedViewRef>|null { - const cachedView = this._templateCache.pop(); - if (cachedView) { - this._viewContainerRef.insert(cachedView, index); - } - return cachedView || null; + return { + templateRef: this._template, + context: { + $implicit: record.item, + // It's guaranteed that the iterable is not "undefined" or "null" because we only + // generate views for elements if the "cdkVirtualForOf" iterable has elements. + cdkVirtualForOf: this._cdkVirtualForOf!, + index: -1, + count: -1, + first: false, + last: false, + odd: false, + even: false + }, + index, + }; } - /** Detaches the embedded view at the given index. */ - private _detachView(index: number): EmbeddedViewRef> { - return this._viewContainerRef.detach(index) as - EmbeddedViewRef>; - } + static ngAcceptInputType_cdkVirtualForTemplateCacheSize: NumberInput; } diff --git a/src/cdk/scrolling/virtual-scroll-viewport.spec.ts b/src/cdk/scrolling/virtual-scroll-viewport.spec.ts index 17edffd99cd4..435d84d90d31 100644 --- a/src/cdk/scrolling/virtual-scroll-viewport.spec.ts +++ b/src/cdk/scrolling/virtual-scroll-viewport.spec.ts @@ -559,50 +559,50 @@ describe('CdkVirtualScrollViewport', () => { it('should trackBy value by default', fakeAsync(() => { testComponent.items = []; - spyOn(testComponent.virtualForOf, '_detachView').and.callThrough(); + spyOn(testComponent.virtualForOf._viewContainerRef, 'detach').and.callThrough(); finishInit(fixture); testComponent.items = [0]; fixture.detectChanges(); flush(); - expect(testComponent.virtualForOf._detachView).not.toHaveBeenCalled(); + expect(testComponent.virtualForOf._viewContainerRef.detach).not.toHaveBeenCalled(); testComponent.items = [1]; fixture.detectChanges(); flush(); - expect(testComponent.virtualForOf._detachView).toHaveBeenCalled(); + expect(testComponent.virtualForOf._viewContainerRef.detach).toHaveBeenCalled(); })); it('should trackBy index when specified', fakeAsync(() => { testComponent.trackBy = i => i; testComponent.items = []; - spyOn(testComponent.virtualForOf, '_detachView').and.callThrough(); + spyOn(testComponent.virtualForOf._viewContainerRef, 'detach').and.callThrough(); finishInit(fixture); testComponent.items = [0]; fixture.detectChanges(); flush(); - expect(testComponent.virtualForOf._detachView).not.toHaveBeenCalled(); + expect(testComponent.virtualForOf._viewContainerRef.detach).not.toHaveBeenCalled(); testComponent.items = [1]; fixture.detectChanges(); flush(); - expect(testComponent.virtualForOf._detachView).not.toHaveBeenCalled(); + expect(testComponent.virtualForOf._viewContainerRef.detach).not.toHaveBeenCalled(); })); it('should recycle views when template cache is large enough to accommodate', fakeAsync(() => { testComponent.trackBy = i => i; - const spy = spyOn(testComponent.virtualForOf, '_createEmbeddedViewAt') + const spy = spyOn(testComponent.virtualForOf, '_getEmbeddedViewArgs') .and.callThrough(); finishInit(fixture); // Should create views for the initial rendered items. - expect(testComponent.virtualForOf._createEmbeddedViewAt) + expect(testComponent.virtualForOf._getEmbeddedViewArgs) .toHaveBeenCalledTimes(4); spy.calls.reset(); @@ -613,7 +613,7 @@ describe('CdkVirtualScrollViewport', () => { // As we first start to scroll we need to create one more item. This is because the first item // is still partially on screen and therefore can't be removed yet. At the same time a new // item is now partially on the screen at the bottom and so a new view is needed. - expect(testComponent.virtualForOf._createEmbeddedViewAt) + expect(testComponent.virtualForOf._getEmbeddedViewArgs) .toHaveBeenCalledTimes(1); spy.calls.reset(); @@ -627,20 +627,20 @@ describe('CdkVirtualScrollViewport', () => { // As we scroll through the rest of the items, no new views should be created, our existing 5 // can just be recycled as appropriate. - expect(testComponent.virtualForOf._createEmbeddedViewAt) + expect(testComponent.virtualForOf._getEmbeddedViewArgs) .not.toHaveBeenCalled(); })); it('should not recycle views when template cache is full', fakeAsync(() => { testComponent.trackBy = i => i; testComponent.templateCacheSize = 0; - const spy = spyOn(testComponent.virtualForOf, '_createEmbeddedViewAt') + const spy = spyOn(testComponent.virtualForOf, '_getEmbeddedViewArgs') .and.callThrough(); finishInit(fixture); // Should create views for the initial rendered items. - expect(testComponent.virtualForOf._createEmbeddedViewAt) + expect(testComponent.virtualForOf._getEmbeddedViewArgs) .toHaveBeenCalledTimes(4); spy.calls.reset(); @@ -651,7 +651,7 @@ describe('CdkVirtualScrollViewport', () => { // As we first start to scroll we need to create one more item. This is because the first item // is still partially on screen and therefore can't be removed yet. At the same time a new // item is now partially on the screen at the bottom and so a new view is needed. - expect(testComponent.virtualForOf._createEmbeddedViewAt) + expect(testComponent.virtualForOf._getEmbeddedViewArgs) .toHaveBeenCalledTimes(1); spy.calls.reset(); @@ -665,7 +665,7 @@ describe('CdkVirtualScrollViewport', () => { // Since our template cache size is 0, as we scroll through the rest of the items, we need to // create a new view for each one. - expect(testComponent.virtualForOf._createEmbeddedViewAt) + expect(testComponent.virtualForOf._getEmbeddedViewArgs) .toHaveBeenCalledTimes(5); })); diff --git a/src/cdk/table/BUILD.bazel b/src/cdk/table/BUILD.bazel index a6e04211c1ce..5284718aee7e 100644 --- a/src/cdk/table/BUILD.bazel +++ b/src/cdk/table/BUILD.bazel @@ -20,6 +20,7 @@ ng_module( "//src/cdk/coercion", "//src/cdk/collections", "//src/cdk/platform", + "//src/cdk/scrolling", "@npm//@angular/core", "@npm//rxjs", ], diff --git a/src/cdk/table/table-module.ts b/src/cdk/table/table-module.ts index 45d3b3142978..e4dcfdb1b019 100644 --- a/src/cdk/table/table-module.ts +++ b/src/cdk/table/table-module.ts @@ -18,6 +18,7 @@ import { CdkFooterCellDef, CdkFooterCell } from './cell'; import {CdkTextColumn} from './text-column'; +import {ScrollingModule} from '@angular/cdk/scrolling'; const EXPORTED_DECLARATIONS = [ CdkTable, @@ -45,7 +46,7 @@ const EXPORTED_DECLARATIONS = [ @NgModule({ exports: EXPORTED_DECLARATIONS, - declarations: EXPORTED_DECLARATIONS - + declarations: EXPORTED_DECLARATIONS, + imports: [ScrollingModule] }) export class CdkTableModule { } diff --git a/src/cdk/table/table.ts b/src/cdk/table/table.ts index 9d05ee6fb124..eee175a6aefe 100644 --- a/src/cdk/table/table.ts +++ b/src/cdk/table/table.ts @@ -8,7 +8,17 @@ import {Direction, Directionality} from '@angular/cdk/bidi'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; -import {CollectionViewer, DataSource, isDataSource} from '@angular/cdk/collections'; +import { + CollectionViewer, + DataSource, + _DisposeViewRepeaterStrategy, + isDataSource, + _VIEW_REPEATER_STRATEGY, + _ViewRepeater, + _ViewRepeaterItemChange, + _ViewRepeaterItemInsertArgs, + _ViewRepeaterOperation, +} from '@angular/cdk/collections'; import {Platform} from '@angular/cdk/platform'; import {DOCUMENT} from '@angular/common'; import { @@ -17,6 +27,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + ContentChild, ContentChildren, Directive, ElementRef, @@ -36,15 +47,14 @@ import { ViewChild, ViewContainerRef, ViewEncapsulation, - ContentChild } from '@angular/core'; import { BehaviorSubject, + isObservable, Observable, of as observableOf, Subject, Subscription, - isObservable, } from 'rxjs'; import {takeUntil} from 'rxjs/operators'; import {CdkColumnDef} from './cell'; @@ -56,8 +66,8 @@ import { CdkCellOutletRowContext, CdkFooterRowDef, CdkHeaderRowDef, - CdkRowDef, - CdkNoDataRow + CdkNoDataRow, + CdkRowDef } from './row'; import {StickyStyler} from './sticky-styler'; import { @@ -189,6 +199,7 @@ export interface RenderRow { changeDetection: ChangeDetectionStrategy.Default, providers: [ {provide: CDK_TABLE, useExisting: CdkTable}, + {provide: _VIEW_REPEATER_STRATEGY, useClass: _DisposeViewRepeaterStrategy}, _CoalescedStyleScheduler, ] }) @@ -430,7 +441,11 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes protected readonly _coalescedStyleScheduler: _CoalescedStyleScheduler, protected readonly _elementRef: ElementRef, @Attribute('role') role: string, @Optional() protected readonly _dir: Directionality, @Inject(DOCUMENT) _document: any, - private _platform: Platform) { + private _platform: Platform, + // Optional for backwards compatibility, but a view repeater strategy will always + // be provided. + @Optional() @Inject(_VIEW_REPEATER_STRATEGY) + protected readonly _viewRepeater: _ViewRepeater, RowContext>) { if (!role) { this._elementRef.nativeElement.setAttribute('role', 'grid'); } @@ -521,21 +536,20 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes this._updateNoDataRow(); return; } - const viewContainer = this._rowOutlet.viewContainer; - - changes.forEachOperation( - (record: IterableChangeRecord>, prevIndex: number|null, - currentIndex: number|null) => { - if (record.previousIndex == null) { - this._insertRow(record.item, currentIndex!); - } else if (currentIndex == null) { - viewContainer.remove(prevIndex!); - } else { - const view = >viewContainer.get(prevIndex!); - viewContainer.move(view!, currentIndex); + this._viewRepeater.applyChanges( + changes, + viewContainer, + (record: IterableChangeRecord>, + adjustedPreviousIndex: number|null, + currentIndex: number|null) => this._getEmbeddedViewArgs(record.item, currentIndex!), + (record) => record.item.data, + (change: _ViewRepeaterItemChange, RowContext>) => { + if (change.operation === _ViewRepeaterOperation.INSERTED && change.context) { + this._renderCellTemplateForItem(change.record.item.rowDef, change.context); } - }); + } + ); // Update the meta context of a row's context data (index, count, first, last, ...) this._updateRowIndexContext(); @@ -877,6 +891,7 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes this.updateStickyHeaderRowStyles(); this.updateStickyColumnStyles(); } + /** * Clears any existing content in the footer row outlet and creates a new embedded view * in the outlet using the footer row definition. @@ -947,14 +962,16 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes return rowDefs; } - /** - * Create the embedded view for the data row template and place it in the correct index location - * within the data row view container. - */ - private _insertRow(renderRow: RenderRow, renderIndex: number) { + + private _getEmbeddedViewArgs(renderRow: RenderRow, + index: number): _ViewRepeaterItemInsertArgs> { const rowDef = renderRow.rowDef; const context: RowContext = {$implicit: renderRow.data}; - this._renderRow(this._rowOutlet, rowDef, renderIndex, context); + return { + templateRef: rowDef.template, + context, + index, + }; } /** @@ -963,10 +980,15 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * of where to place the new row template in the outlet. */ private _renderRow( - outlet: RowOutlet, rowDef: BaseRowDef, index: number, context: RowContext = {}) { + outlet: RowOutlet, rowDef: BaseRowDef, index: number, + context: RowContext = {}): EmbeddedViewRef> { // TODO(andrewseguin): enforce that one outlet was instantiated from createEmbeddedView - outlet.viewContainer.createEmbeddedView(rowDef.template, context, index); + const view = outlet.viewContainer.createEmbeddedView(rowDef.template, context, index); + this._renderCellTemplateForItem(rowDef, context); + return view; + } + private _renderCellTemplateForItem(rowDef: BaseRowDef, context: RowContext) { for (let cellTemplate of this._getCellTemplates(rowDef)) { if (CdkCellOutlet.mostRecentCellOutlet) { CdkCellOutlet.mostRecentCellOutlet._viewContainer.createEmbeddedView(cellTemplate, context); @@ -1058,7 +1080,8 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes * during a change detection and after the inputs are settled (after content check). */ private _checkStickyStates() { - const stickyCheckReducer = (acc: boolean, d: CdkHeaderRowDef|CdkFooterRowDef|CdkColumnDef) => { + const stickyCheckReducer = (acc: boolean, + d: CdkHeaderRowDef|CdkFooterRowDef|CdkColumnDef) => { return acc || d.hasStickyChanged(); }; @@ -1090,11 +1113,11 @@ export class CdkTable implements AfterContentChecked, CollectionViewer, OnDes this._isNativeHtmlTable, this.stickyCssClass, direction, this._coalescedStyleScheduler, this._platform.isBrowser); (this._dir ? this._dir.change : observableOf()) - .pipe(takeUntil(this._onDestroy)) - .subscribe(value => { - this._stickyStyler.direction = value; - this.updateStickyColumnStyles(); - }); + .pipe(takeUntil(this._onDestroy)) + .subscribe(value => { + this._stickyStyler.direction = value; + this.updateStickyColumnStyles(); + }); } /** Filters definitions that belong to this table from a QueryList. */ diff --git a/src/material-experimental/mdc-table/table.ts b/src/material-experimental/mdc-table/table.ts index 1e5ae437a5a8..7281c9cc231c 100644 --- a/src/material-experimental/mdc-table/table.ts +++ b/src/material-experimental/mdc-table/table.ts @@ -8,6 +8,7 @@ import {ChangeDetectionStrategy, Component, OnInit, ViewEncapsulation} from '@angular/core'; import {CDK_TABLE_TEMPLATE, CdkTable, _CoalescedStyleScheduler} from '@angular/cdk/table'; +import {_DisposeViewRepeaterStrategy, _VIEW_REPEATER_STRATEGY} from '@angular/cdk/collections'; @Component({ selector: 'table[mat-table]', @@ -20,6 +21,9 @@ import {CDK_TABLE_TEMPLATE, CdkTable, _CoalescedStyleScheduler} from '@angular/c providers: [ {provide: CdkTable, useExisting: MatTable}, _CoalescedStyleScheduler, + // TODO(michaeljamesparsons) Abstract the view repeater strategy to a directive API so this code + // is only included in the build if used. + {provide: _VIEW_REPEATER_STRATEGY, useClass: _DisposeViewRepeaterStrategy}, ], encapsulation: ViewEncapsulation.None, // See note on CdkTable for explanation on why this uses the default change detection strategy. diff --git a/src/material/table/table.ts b/src/material/table/table.ts index f86f0eae9ea8..5e96a279bebc 100644 --- a/src/material/table/table.ts +++ b/src/material/table/table.ts @@ -13,6 +13,7 @@ import { _CoalescedStyleScheduler } from '@angular/cdk/table'; import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core'; +import {_DisposeViewRepeaterStrategy, _VIEW_REPEATER_STRATEGY} from '@angular/cdk/collections'; /** * Wrapper for the CdkTable with Material design styles. @@ -26,6 +27,9 @@ import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/co 'class': 'mat-table', }, providers: [ + // TODO(michaeljamesparsons) Abstract the view repeater strategy to a directive API so this code + // is only included in the build if used. + {provide: _VIEW_REPEATER_STRATEGY, useClass: _DisposeViewRepeaterStrategy}, {provide: CdkTable, useExisting: MatTable}, {provide: CDK_TABLE, useExisting: MatTable}, _CoalescedStyleScheduler, diff --git a/tools/public_api_guard/cdk/collections.d.ts b/tools/public_api_guard/cdk/collections.d.ts index 0b0a3ea41de0..8feb88fc72e6 100644 --- a/tools/public_api_guard/cdk/collections.d.ts +++ b/tools/public_api_guard/cdk/collections.d.ts @@ -1,3 +1,50 @@ +export declare class _DisposeViewRepeaterStrategy> implements _ViewRepeater { + applyChanges(changes: IterableChanges, viewContainerRef: ViewContainerRef, itemContextFactory: _ViewRepeaterItemContextFactory, itemValueResolver: _ViewRepeaterItemValueResolver, itemViewChanged?: _ViewRepeaterItemChanged): void; + detach(): void; +} + +export declare class _RecycleViewRepeaterStrategy> implements _ViewRepeater { + viewCacheSize: number; + applyChanges(changes: IterableChanges, viewContainerRef: ViewContainerRef, itemContextFactory: _ViewRepeaterItemContextFactory, itemValueResolver: _ViewRepeaterItemValueResolver, itemViewChanged?: _ViewRepeaterItemChanged): void; + detach(): void; +} + +export declare const _VIEW_REPEATER_STRATEGY: InjectionToken<_ViewRepeater>>; + +export interface _ViewRepeater> { + applyChanges(changes: IterableChanges, viewContainerRef: ViewContainerRef, itemContextFactory: _ViewRepeaterItemContextFactory, itemValueResolver: _ViewRepeaterItemValueResolver, itemViewChanged?: _ViewRepeaterItemChanged): void; + detach(): void; +} + +export interface _ViewRepeaterItemChange { + context?: C; + operation: _ViewRepeaterOperation; + record: IterableChangeRecord; +} + +export declare type _ViewRepeaterItemChanged = (change: _ViewRepeaterItemChange) => void; + +export interface _ViewRepeaterItemContext { + $implicit?: T; +} + +export declare type _ViewRepeaterItemContextFactory> = (record: IterableChangeRecord, adjustedPreviousIndex: number | null, currentIndex: number | null) => _ViewRepeaterItemInsertArgs; + +export interface _ViewRepeaterItemInsertArgs { + context?: C; + index?: number; + templateRef: TemplateRef; +} + +export declare type _ViewRepeaterItemValueResolver = (record: IterableChangeRecord) => T; + +export declare const enum _ViewRepeaterOperation { + REPLACED = 0, + INSERTED = 1, + MOVED = 2, + REMOVED = 3 +} + export declare class ArrayDataSource extends DataSource { constructor(_data: T[] | ReadonlyArray | Observable>); connect(): Observable>; diff --git a/tools/public_api_guard/cdk/scrolling.d.ts b/tools/public_api_guard/cdk/scrolling.d.ts index 230f8ccbefce..4ec5472693f2 100644 --- a/tools/public_api_guard/cdk/scrolling.d.ts +++ b/tools/public_api_guard/cdk/scrolling.d.ts @@ -79,7 +79,8 @@ export declare class CdkVirtualForOf implements CdkVirtualScrollRepeater, get cdkVirtualForOf(): DataSource | Observable | NgIterable | null | undefined; set cdkVirtualForOf(value: DataSource | Observable | NgIterable | null | undefined); set cdkVirtualForTemplate(value: TemplateRef>); - cdkVirtualForTemplateCacheSize: number; + get cdkVirtualForTemplateCacheSize(): number; + set cdkVirtualForTemplateCacheSize(size: number); get cdkVirtualForTrackBy(): TrackByFunction | undefined; set cdkVirtualForTrackBy(fn: TrackByFunction | undefined); dataStream: Observable>; @@ -88,12 +89,14 @@ export declare class CdkVirtualForOf implements CdkVirtualScrollRepeater, _viewContainerRef: ViewContainerRef, _template: TemplateRef>, _differs: IterableDiffers, + _viewRepeater: _RecycleViewRepeaterStrategy>, _viewport: CdkVirtualScrollViewport, ngZone: NgZone); measureRangeSize(range: ListRange, orientation: 'horizontal' | 'vertical'): number; ngDoCheck(): void; ngOnDestroy(): void; + static ngAcceptInputType_cdkVirtualForTemplateCacheSize: NumberInput; static ɵdir: i0.ɵɵDirectiveDefWithMeta, "[cdkVirtualFor][cdkVirtualForOf]", never, { "cdkVirtualForOf": "cdkVirtualForOf"; "cdkVirtualForTrackBy": "cdkVirtualForTrackBy"; "cdkVirtualForTemplate": "cdkVirtualForTemplate"; "cdkVirtualForTemplateCacheSize": "cdkVirtualForTemplateCacheSize"; }, {}, never>; - static ɵfac: i0.ɵɵFactoryDef, [null, null, null, { skipSelf: true; }, null]>; + static ɵfac: i0.ɵɵFactoryDef, [null, null, null, null, { skipSelf: true; }, null]>; } export declare type CdkVirtualForOfContext = { diff --git a/tools/public_api_guard/cdk/table.d.ts b/tools/public_api_guard/cdk/table.d.ts index 7d68d6eb4810..439265d8df79 100644 --- a/tools/public_api_guard/cdk/table.d.ts +++ b/tools/public_api_guard/cdk/table.d.ts @@ -198,6 +198,7 @@ export declare class CdkTable implements AfterContentChecked, CollectionViewe _noDataRow: CdkNoDataRow; _noDataRowOutlet: NoDataRowOutlet; _rowOutlet: DataRowOutlet; + protected readonly _viewRepeater: _ViewRepeater, RowContext>; get dataSource(): CdkTableDataSourceInput; set dataSource(dataSource: CdkTableDataSourceInput); get multiTemplateDataRows(): boolean; @@ -209,7 +210,7 @@ export declare class CdkTable implements AfterContentChecked, CollectionViewe start: number; end: number; }>; - constructor(_differs: IterableDiffers, _changeDetectorRef: ChangeDetectorRef, _coalescedStyleScheduler: _CoalescedStyleScheduler, _elementRef: ElementRef, role: string, _dir: Directionality, _document: any, _platform: Platform); + constructor(_differs: IterableDiffers, _changeDetectorRef: ChangeDetectorRef, _coalescedStyleScheduler: _CoalescedStyleScheduler, _elementRef: ElementRef, role: string, _dir: Directionality, _document: any, _platform: Platform, _viewRepeater: _ViewRepeater, RowContext>); _getRenderedRows(rowOutlet: RowOutlet): HTMLElement[]; _getRowDefs(data: T, dataIndex: number): CdkRowDef[]; addColumnDef(columnDef: CdkColumnDef): void; @@ -229,12 +230,12 @@ export declare class CdkTable implements AfterContentChecked, CollectionViewe updateStickyHeaderRowStyles(): void; static ngAcceptInputType_multiTemplateDataRows: BooleanInput; static ɵcmp: i0.ɵɵComponentDefWithMeta, "cdk-table, table[cdk-table]", ["cdkTable"], { "trackBy": "trackBy"; "dataSource": "dataSource"; "multiTemplateDataRows": "multiTemplateDataRows"; }, {}, ["_noDataRow", "_contentColumnDefs", "_contentRowDefs", "_contentHeaderRowDefs", "_contentFooterRowDefs"], ["caption", "colgroup, col"]>; - static ɵfac: i0.ɵɵFactoryDef, [null, null, null, null, { attribute: "role"; }, { optional: true; }, null, null]>; + static ɵfac: i0.ɵɵFactoryDef, [null, null, null, null, { attribute: "role"; }, { optional: true; }, null, null, { optional: true; }]>; } export declare class CdkTableModule { static ɵinj: i0.ɵɵInjectorDef; - static ɵmod: i0.ɵɵNgModuleDefWithMeta; + static ɵmod: i0.ɵɵNgModuleDefWithMeta; } export declare class CdkTextColumn implements OnDestroy, OnInit {