feat(virtual-scroll): move from cdk-experimental to cdk (#12438)

This commit is contained in:
mmalerba
2018-08-01 14:36:02 -07:00
committed by Jeremy Elbourn
parent d392a8dc0b
commit dcd2282295
32 changed files with 874 additions and 840 deletions
+2 -12
View File
@@ -1,36 +1,28 @@
package(default_visibility=["//visibility:public"])
load("@angular//:index.bzl", "ng_module")
load("@build_bazel_rules_typescript//:defs.bzl", "ts_library", "ts_web_test")
load("@io_bazel_rules_sass//sass:sass.bzl", "sass_binary")
ng_module(
name = "scrolling",
srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]),
module_name = "@angular/cdk-experimental/scrolling",
assets = [":virtual-scroll-viewport.css"] + glob(["**/*.html"]),
deps = [
"//src/cdk/coercion",
"//src/cdk/collections",
"//src/cdk/platform",
"//src/cdk/scrolling",
"@rxjs",
],
tsconfig = "//src/cdk-experimental:tsconfig-build.json",
)
sass_binary(
name = "virtual_scroll_viewport_scss",
src = "virtual-scroll-viewport.scss",
)
ts_library(
name = "scrolling_test_sources",
testonly = 1,
srcs = glob(["**/*.spec.ts"]),
deps = [
":scrolling",
"//src/cdk/collections",
"//src/cdk/scrolling",
"//src/cdk/testing",
"@rxjs",
],
@@ -42,8 +34,6 @@ ts_web_test(
bootstrap = [
"//:web_test_bootstrap_scripts",
],
tags = ["manual"],
# Do not sort
deps = [
"//:tslib_bundle",
@@ -8,10 +8,13 @@
import {coerceNumberProperty} from '@angular/cdk/coercion';
import {ListRange} from '@angular/cdk/collections';
import {
CdkVirtualScrollViewport,
VIRTUAL_SCROLL_STRATEGY,
VirtualScrollStrategy
} from '@angular/cdk/scrolling';
import {Directive, forwardRef, Input, OnChanges} from '@angular/core';
import {Observable} from 'rxjs';
import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy';
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
/**
@@ -7,8 +7,4 @@
*/
export * from './auto-size-virtual-scroll';
export * from './fixed-size-virtual-scroll';
export * from './scrolling-module';
export * from './virtual-for-of';
export * from './virtual-scroll-strategy';
export * from './virtual-scroll-viewport';
@@ -8,23 +8,10 @@
import {NgModule} from '@angular/core';
import {CdkAutoSizeVirtualScroll} from './auto-size-virtual-scroll';
import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll';
import {CdkVirtualForOf} from './virtual-for-of';
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
@NgModule({
exports: [
CdkAutoSizeVirtualScroll,
CdkFixedSizeVirtualScroll,
CdkVirtualForOf,
CdkVirtualScrollViewport,
],
declarations: [
CdkAutoSizeVirtualScroll,
CdkFixedSizeVirtualScroll,
CdkVirtualForOf,
CdkVirtualScrollViewport,
],
exports: [CdkAutoSizeVirtualScroll],
declarations: [CdkAutoSizeVirtualScroll],
})
export class ScrollingModule {}
@@ -0,0 +1,32 @@
**Warning: this component is still experimental. It may have bugs and the API may change at any
time**
### Scrolling over items with different sizes
When the items have different or unknown sizes, you can use the `AutoSizeVirtualScrollStrategy`.
This can be added to your viewport by using the `autosize` directive.
```html
<cdk-virtual-scroll-viewport autosize>
...
</cdk-virtual-scroll-viewport>
```
The `autosize` strategy is configured through two inputs: `minBufferPx` and `addBufferPx`.
**`minBufferPx`** determines the minimum space outside virtual scrolling viewport that will be
filled with content. Increasing this will increase the amount of content a user will see before more
content must be rendered. However, too large a value will cause more content to be rendered than is
necessary.
**`addBufferPx`** determines the amount of content that will be added incrementally as the viewport
is scrolled. This should be greater than the size of `minBufferPx` so that one "render" is needed at
a time.
```html
<cdk-virtual-scroll-viewport autosize minBufferPx="50" addBufferPx="100">
...
</cdk-virtual-scroll-viewport>
```
Because the auto size strategy needs to measure the size of the elements, its performance may not
be as good as the fixed size strategy.
@@ -1,8 +1,7 @@
{
"extends": "../tsconfig-build",
"files": [
"public-api.ts",
"./typings.d.ts"
"public-api.ts"
],
"angularCompilerOptions": {
"annotateForClosureCompiler": true,
@@ -1,558 +1,10 @@
import {ArrayDataSource} from '@angular/cdk/collections';
import {dispatchFakeEvent} from '@angular/cdk/testing';
import {Component, Input, ViewChild, ViewContainerRef, ViewEncapsulation} from '@angular/core';
import {CdkVirtualScrollViewport, ScrollingModule} from '@angular/cdk/scrolling';
import {Component, Input, ViewChild, ViewEncapsulation} from '@angular/core';
import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing';
import {animationFrameScheduler, Subject} from 'rxjs';
import {CdkVirtualScrollViewport, CdkVirtualForOf, ScrollingModule} from './index';
import {ScrollingModule as ExperimentalScrollingModule} from './scrolling-module';
describe('CdkVirtualScrollViewport', () => {
describe ('with FixedSizeVirtualScrollStrategy', () => {
let fixture: ComponentFixture<FixedSizeVirtualScroll>;
let testComponent: FixedSizeVirtualScroll;
let viewport: CdkVirtualScrollViewport;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ScrollingModule],
declarations: [FixedSizeVirtualScroll],
}).compileComponents();
fixture = TestBed.createComponent(FixedSizeVirtualScroll);
testComponent = fixture.componentInstance;
viewport = testComponent.viewport;
});
it('should render initial state', fakeAsync(() => {
finishInit(fixture);
const contentWrapper =
viewport.elementRef.nativeElement.querySelector('.cdk-virtual-scroll-content-wrapper')!;
expect(contentWrapper.children.length)
.toBe(4, 'should render 4 50px items to fill 200px space');
}));
it('should get the data length', fakeAsync(() => {
finishInit(fixture);
expect(viewport.getDataLength()).toBe(testComponent.items.length);
}));
it('should get the viewport size', fakeAsync(() => {
finishInit(fixture);
expect(viewport.getViewportSize()).toBe(testComponent.viewportSize);
}));
it('should update viewport size', fakeAsync(() => {
testComponent.viewportSize = 300;
fixture.detectChanges();
flush();
viewport.checkViewportSize();
expect(viewport.getViewportSize()).toBe(300);
testComponent.viewportSize = 500;
fixture.detectChanges();
flush();
viewport.checkViewportSize();
expect(viewport.getViewportSize()).toBe(500);
}));
it('should get the rendered range', fakeAsync(() => {
finishInit(fixture);
expect(viewport.getRenderedRange())
.toEqual({start: 0, end: 4}, 'should render the first 4 50px items to fill 200px space');
}));
it('should get the rendered content offset', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize + 5);
fixture.detectChanges();
flush();
expect(viewport.getOffsetToRenderedContentStart()).toBe(testComponent.itemSize,
'should have 50px offset since first 50px item is not rendered');
}));
it('should get the scroll offset', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize + 5);
fixture.detectChanges();
flush();
expect(viewport.measureScrollOffset()).toBe(testComponent.itemSize + 5);
}));
it('should get the rendered content size', fakeAsync(() => {
finishInit(fixture);
expect(viewport.measureRenderedContentSize())
.toBe(testComponent.viewportSize,
'should render 4 50px items with combined size of 200px to fill 200px space');
}));
it('should measure range size', fakeAsync(() => {
finishInit(fixture);
expect(viewport.measureRangeSize({start: 1, end: 3}))
.toBe(testComponent.itemSize * 2, 'combined size of 2 50px items should be 100px');
}));
it('should set total content size', fakeAsync(() => {
finishInit(fixture);
viewport.setTotalContentSize(10000);
flush();
fixture.detectChanges();
expect(viewport.elementRef.nativeElement.scrollHeight).toBe(10000);
}));
it('should set rendered range', fakeAsync(() => {
finishInit(fixture);
viewport.setRenderedRange({start: 2, end: 3});
fixture.detectChanges();
flush();
const items = fixture.elementRef.nativeElement.querySelectorAll('.item');
expect(items.length).toBe(1, 'Expected 1 item to be rendered');
expect(items[0].innerText.trim()).toBe('2 - 2', 'Expected item with index 2 to be rendered');
}));
it('should set content offset to top of content', fakeAsync(() => {
finishInit(fixture);
viewport.setRenderedContentOffset(10, 'to-start');
fixture.detectChanges();
flush();
expect(viewport.getOffsetToRenderedContentStart()).toBe(10);
}));
it('should set content offset to bottom of content', fakeAsync(() => {
finishInit(fixture);
const contentSize = viewport.measureRenderedContentSize();
expect(contentSize).toBeGreaterThan(0);
viewport.setRenderedContentOffset(contentSize + 10, 'to-end');
fixture.detectChanges();
flush();
expect(viewport.getOffsetToRenderedContentStart()).toBe(10);
}));
it('should set scroll offset', fakeAsync(() => {
finishInit(fixture);
viewport.setScrollOffset(testComponent.itemSize * 2);
fixture.detectChanges();
flush();
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.elementRef.nativeElement.scrollTop).toBe(testComponent.itemSize * 2);
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
}));
it('should scroll to offset', fakeAsync(() => {
finishInit(fixture);
viewport.scrollToOffset(testComponent.itemSize * 2);
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.elementRef.nativeElement.scrollTop).toBe(testComponent.itemSize * 2);
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
}));
it('should scroll to index', fakeAsync(() => {
finishInit(fixture);
viewport.scrollToIndex(2);
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.elementRef.nativeElement.scrollTop).toBe(testComponent.itemSize * 2);
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
}));
it('should scroll to offset in horizontal mode', fakeAsync(() => {
testComponent.orientation = 'horizontal';
finishInit(fixture);
viewport.scrollToOffset(testComponent.itemSize * 2);
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.elementRef.nativeElement.scrollLeft).toBe(testComponent.itemSize * 2);
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
}));
it('should scroll to index in horizontal mode', fakeAsync(() => {
testComponent.orientation = 'horizontal';
finishInit(fixture);
viewport.scrollToIndex(2);
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.elementRef.nativeElement.scrollLeft).toBe(testComponent.itemSize * 2);
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
}));
it('should output scrolled index', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 2 - 1);
fixture.detectChanges();
flush();
expect(testComponent.scrolledToIndex).toBe(1);
triggerScroll(viewport, testComponent.itemSize * 2);
fixture.detectChanges();
flush();
expect(testComponent.scrolledToIndex).toBe(2);
}));
it('should update viewport as user scrolls down', fakeAsync(() => {
finishInit(fixture);
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = 0; offset <= maxOffset; offset += 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
const expectedRange = {
start: Math.floor(offset / testComponent.itemSize),
end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize)
};
expect(viewport.getRenderedRange())
.toEqual(expectedRange,
`rendered range should match expected value at scroll offset ${offset}`);
expect(viewport.getOffsetToRenderedContentStart())
.toBe(expectedRange.start * testComponent.itemSize,
`rendered content offset should match expected value at scroll offset ${offset}`);
expect(viewport.measureRenderedContentSize())
.toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize,
`rendered content size should match expected value at offset ${offset}`);
}
}));
it('should update viewport as user scrolls up', fakeAsync(() => {
finishInit(fixture);
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = maxOffset; offset >= 0; offset -= 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
const expectedRange = {
start: Math.floor(offset / testComponent.itemSize),
end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize)
};
expect(viewport.getRenderedRange())
.toEqual(expectedRange,
`rendered range should match expected value at scroll offset ${offset}`);
expect(viewport.getOffsetToRenderedContentStart())
.toBe(expectedRange.start * testComponent.itemSize,
`rendered content offset should match expected value at scroll offset ${offset}`);
expect(viewport.measureRenderedContentSize())
.toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize,
`rendered content size should match expected value at offset ${offset}`);
}
}));
it('should render buffer element at the end when scrolled to the top', fakeAsync(() => {
testComponent.bufferSize = 1;
finishInit(fixture);
expect(viewport.getRenderedRange()).toEqual({start: 0, end: 5},
'should render the first 5 50px items to fill 200px space, plus one buffer element at' +
' the end');
}));
it('should render buffer element at the start and end when scrolled to the middle',
fakeAsync(() => {
testComponent.bufferSize = 1;
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 2);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange()).toEqual({start: 1, end: 7},
'should render 6 50px items to fill 200px space, plus one buffer element at the' +
' start and end');
}));
it('should render buffer element at the start when scrolled to the bottom', fakeAsync(() => {
testComponent.bufferSize = 1;
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 6);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange()).toEqual({start: 5, end: 10},
'should render the last 5 50px items to fill 200px space, plus one buffer element at' +
' the start');
}));
it('should handle dynamic item size', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 2);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 2, end: 6}, 'should render 4 50px items to fill 200px space');
testComponent.itemSize *= 2;
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 1, end: 3}, 'should render 2 100px items to fill 200px space');
}));
it('should handle dynamic buffer size', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 2);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 2, end: 6}, 'should render 4 50px items to fill 200px space');
testComponent.bufferSize = 1;
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 1, end: 7}, 'should expand to 1 buffer element on each side');
}));
it('should handle dynamic item array', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 6);
fixture.detectChanges();
flush();
expect(viewport.getOffsetToRenderedContentStart())
.toBe(testComponent.itemSize * 6, 'should be scrolled to bottom of 10 item list');
testComponent.items = Array(5).fill(0);
fixture.detectChanges();
flush();
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.getOffsetToRenderedContentStart())
.toBe(testComponent.itemSize, 'should be scrolled to bottom of 5 item list');
}));
it('should update viewport as user scrolls right in horizontal mode', fakeAsync(() => {
testComponent.orientation = 'horizontal';
finishInit(fixture);
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = 0; offset <= maxOffset; offset += 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
const expectedRange = {
start: Math.floor(offset / testComponent.itemSize),
end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize)
};
expect(viewport.getRenderedRange())
.toEqual(expectedRange,
`rendered range should match expected value at scroll offset ${offset}`);
expect(viewport.getOffsetToRenderedContentStart())
.toBe(expectedRange.start * testComponent.itemSize,
`rendered content offset should match expected value at scroll offset ${offset}`);
expect(viewport.measureRenderedContentSize())
.toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize,
`rendered content size should match expected value at offset ${offset}`);
}
}));
it('should update viewport as user scrolls left in horizontal mode', fakeAsync(() => {
testComponent.orientation = 'horizontal';
finishInit(fixture);
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = maxOffset; offset >= 0; offset -= 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
const expectedRange = {
start: Math.floor(offset / testComponent.itemSize),
end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize)
};
expect(viewport.getRenderedRange())
.toEqual(expectedRange,
`rendered range should match expected value at scroll offset ${offset}`);
expect(viewport.getOffsetToRenderedContentStart())
.toBe(expectedRange.start * testComponent.itemSize,
`rendered content offset should match expected value at scroll offset ${offset}`);
expect(viewport.measureRenderedContentSize())
.toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize,
`rendered content size should match expected value at offset ${offset}`);
}
}));
it('should work with an Observable', fakeAsync(() => {
const data = new Subject<number[]>();
testComponent.items = data as any;
finishInit(fixture);
expect(viewport.getRenderedRange())
.toEqual({start: 0, end: 0}, 'no items should be rendered');
data.next([1, 2, 3]);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 0, end: 3}, 'newly emitted items should be rendered');
}));
it('should work with a DataSource', fakeAsync(() => {
const data = new Subject<number[]>();
testComponent.items = new ArrayDataSource(data) as any;
finishInit(fixture);
expect(viewport.getRenderedRange())
.toEqual({start: 0, end: 0}, 'no items should be rendered');
data.next([1, 2, 3]);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 0, end: 3}, 'newly emitted items should be rendered');
}));
it('should trackBy value by default', fakeAsync(() => {
testComponent.items = [];
spyOn(testComponent.virtualForViewContainer, 'detach').and.callThrough();
finishInit(fixture);
testComponent.items = [0];
fixture.detectChanges();
flush();
expect(testComponent.virtualForViewContainer.detach).not.toHaveBeenCalled();
testComponent.items = [1];
fixture.detectChanges();
flush();
expect(testComponent.virtualForViewContainer.detach).toHaveBeenCalled();
}));
it('should trackBy index when specified', fakeAsync(() => {
testComponent.trackBy = i => i;
testComponent.items = [];
spyOn(testComponent.virtualForViewContainer, 'detach').and.callThrough();
finishInit(fixture);
testComponent.items = [0];
fixture.detectChanges();
flush();
expect(testComponent.virtualForViewContainer.detach).not.toHaveBeenCalled();
testComponent.items = [1];
fixture.detectChanges();
flush();
expect(testComponent.virtualForViewContainer.detach).not.toHaveBeenCalled();
}));
it('should recycle views when template cache is large enough to accommodate', fakeAsync(() => {
testComponent.trackBy = i => i;
const spy =
spyOn(testComponent.virtualForViewContainer, 'createEmbeddedView').and.callThrough();
finishInit(fixture);
// Should create views for the initial rendered items.
expect(testComponent.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(4);
spy.calls.reset();
triggerScroll(viewport, 10);
fixture.detectChanges();
flush();
// 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.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(1);
spy.calls.reset();
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = 10; offset <= maxOffset; offset += 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
}
// 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.virtualForViewContainer.createEmbeddedView).not.toHaveBeenCalled();
}));
it('should not recycle views when template cache is full', fakeAsync(() => {
testComponent.trackBy = i => i;
testComponent.templateCacheSize = 0;
const spy =
spyOn(testComponent.virtualForViewContainer, 'createEmbeddedView').and.callThrough();
finishInit(fixture);
// Should create views for the initial rendered items.
expect(testComponent.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(4);
spy.calls.reset();
triggerScroll(viewport, 10);
fixture.detectChanges();
flush();
// 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.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(1);
spy.calls.reset();
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = 10; offset <= maxOffset; offset += 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
}
// 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.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(5);
}));
});
describe ('with AutoSizeVirtualScrollStrategy', () => {
let fixture: ComponentFixture<AutoSizeVirtualScroll>;
let testComponent: AutoSizeVirtualScroll;
@@ -560,7 +12,7 @@ describe('CdkVirtualScrollViewport', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ScrollingModule],
imports: [ScrollingModule, ExperimentalScrollingModule],
declarations: [AutoSizeVirtualScroll],
}).compileComponents();
@@ -605,69 +57,6 @@ function finishInit(fixture: ComponentFixture<any>) {
flush();
}
/** Trigger a scroll event on the viewport (optionally setting a new scroll offset). */
function triggerScroll(viewport: CdkVirtualScrollViewport, offset?: number) {
if (offset !== undefined) {
if (viewport.orientation == 'horizontal') {
viewport.elementRef.nativeElement.scrollLeft = offset;
} else {
viewport.elementRef.nativeElement.scrollTop = offset;
}
}
dispatchFakeEvent(viewport.elementRef.nativeElement, 'scroll');
animationFrameScheduler.flush();
}
@Component({
template: `
<cdk-virtual-scroll-viewport
[itemSize]="itemSize" [bufferSize]="bufferSize" [orientation]="orientation"
[style.height.px]="viewportHeight" [style.width.px]="viewportWidth"
(scrolledIndexChange)="scrolledToIndex = $event">
<div class="item"
*cdkVirtualFor="let item of items; let i = index; trackBy: trackBy; \
templateCacheSize: templateCacheSize"
[style.height.px]="itemSize" [style.width.px]="itemSize">
{{i}} - {{item}}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`
.cdk-virtual-scroll-content-wrapper {
display: flex;
flex-direction: column;
}
.cdk-virtual-scroll-orientation-horizontal .cdk-virtual-scroll-content-wrapper {
flex-direction: row;
}
`],
encapsulation: ViewEncapsulation.None,
})
class FixedSizeVirtualScroll {
@ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport;
@ViewChild(CdkVirtualForOf, {read: ViewContainerRef}) virtualForViewContainer: ViewContainerRef;
@Input() orientation = 'vertical';
@Input() viewportSize = 200;
@Input() viewportCrossSize = 100;
@Input() itemSize = 50;
@Input() bufferSize = 0;
@Input() items = Array(10).fill(0).map((_, i) => i);
@Input() trackBy;
@Input() templateCacheSize = 20;
scrolledToIndex = 0;
get viewportWidth() {
return this.orientation == 'horizontal' ? this.viewportSize : this.viewportCrossSize;
}
get viewportHeight() {
return this.orientation == 'horizontal' ? this.viewportCrossSize : this.viewportSize;
}
}
@Component({
template: `
@@ -1,162 +0,0 @@
**Warning: this component is still experimental. It may have bugs and the API may change at any
time**
`<cdk-virtual-scroll-viewport>` displays large lists of elements performantly by only
rendering the items that fit on-screen. Loading hundreds of elements can be slow in any browser;
virtual scrolling enables a performant way to simulate all items being rendered by making the
height of the container element the same as the height of total number of elements to be rendered,
and then only rendering the items in view. Virtual scrolling is different from strategies like
infinite scroll where it renders a set amount of elements and then when you hit the end renders the
rest.
<!-- TODO(mmalerba): Add embedded examples once this is available in @angular/cdk -->
For some example usages,
[see the demo app](https://github.com/angular/material2/tree/master/src/demo-app/virtual-scroll).
### Creating items in the viewport
`*cdkVirtualFor` replaces `*ngFor` inside of a `<cdk-virtual-scroll-viewport>`, supporting the exact
same API as [`*ngFor`](https://angular.io/api/common/NgForOf).
The simplest usage just specifies the list of items:
```html
<cdk-virtual-scroll-viewport itemSize="50">
<div *cdkVirtualFor="let item of items">{{item}}</div>
</cdk-virtual-scroll-viewport>
```
`*cdkVirtualFor` makes the following context variables available to the template:
| Context variable | Description |
|------------------|----------------------------------------------------|
| `index` | The index of the item in the data source. |
| `count` | The total number of items in the data source. |
| `first` | Whether this is the first item in the data source. |
| `last` | Whether this is the last item in the data source. |
| `even` | Whether the `index` is even. |
| `odd` | Whether the `index` is odd. |
All of these apply to the index of the item in the data source, not the index in the rendered
portion of the data.
```html
<cdk-virtual-scroll-viewport itemSize="50">
<div *cdkVirtualFor="let item of items;
let index = index;
let count = count">
{{item}} ({{index}} of {{count}})
</div>
</cdk-virtual-scroll-viewport>
```
A `trackBy` function can be specified and works the same as the `*ngFor` `trackBy`. The `index`
passed to the tracking function will be the index in the data source, not the index in the rendered
portion.
#### View recycling
To improve rendering performance, `*cdkVirtualFor` caches previously created views after
they are no longer needed. When a new view would normally be created, a cached view
is reused instead. The size of the view cache can be adjusted via the `templateCacheSize`
property; setting this size to `0` disables caching. If your templates are expensive in terms of
memory you may wish to reduce this number to avoid spending too much memory on the template cache.
```html
<cdk-virtual-scroll-viewport itemSize="50">
<div *cdkVirtualFor="let item of items; templateCacheSize: 0">{{item}}</div>
</cdk-virtual-scroll-viewport>
```
#### Specifying data
`*cdkVirtualFor` accepts data from an `Array`, `Observable<Array>`, or `DataSource`. The
`DataSource` for the virtual scroll is the same one used by the table and tree components. A
`DataSource` is simply an abstract class that has two methods: `connect` and `disconnect`. The
`connect` method will be called by the virtual scroll viewport to receive a stream that emits the
data array that should be rendered. The viewport will call `disconnect` when the viewport is
destroyed, which may be the right time to clean up any subscriptions that were registered during the
connect process.
### Scrolling over fixed size items
When all items are the same fixed size, you can use the `FixedSizeVirtualScrollStrategy`. This can
be easily added to your viewport using the `itemSize` directive. The fixed size viewport strategy is
less flexible than the autosize strategy because it requires all items to be the same size, but the
advantage of this constraint is that it allows for better performance, since items do not need to be
measured as they are rendered.
```html
<cdk-virtual-scroll-viewport itemSize="50">
...
</cdk-virtual-scroll-viewport>
```
The fixed size strategy also supports setting the buffer size, i.e. the number of items rendered
beyond the edge of the viewport. This can be adjusted by setting the `bufferSize` input. If
`bufferSize` is not specified it defaults to 5 items.
```html
<cdk-virtual-scroll-viewport itemSize="50" bufferSize="1">
...
</cdk-virtual-scroll-viewport>
```
**Note: The fixed size strategy will likely be changed to allow specifying a separate
`minBufferPx` and `addBufferPx` like the autosize strategy**
### Scrolling over items with different sizes
When the items have different or unknown sizes, you can use the `AutoSizeVirtualScrollStrategy`.
This can be added to your viewport by using the `autosize` directive.
```html
<cdk-virtual-scroll-viewport autosize>
...
</cdk-virtual-scroll-viewport>
```
The `autosize` strategy is configured through two inputs: `minBufferPx` and `addBufferPx`.
**`minBufferPx`** determines the minimum space outside virtual scrolling viewport that will be
filled with content. Increasing this will increase the amount of content a user will see before more
content must be rendered. However, too large a value will cause more content to be rendered than is
necessary.
**`addBufferPx`** determines the amount of content that will be added incrementally as the viewport
is scrolled. This should be greater than the size of `minBufferPx` so that one "render" is needed at
a time.
```html
<cdk-virtual-scroll-viewport autosize minBufferPx="50" addBufferPx="100">
...
</cdk-virtual-scroll-viewport>
```
Because the auto size strategy needs to measure the size of the elements, its performance may not
be as good as the fixed size strategy.
### Viewport orientation
The virtual-scroll viewport defaults to a vertical orientation, but can also be set to
`orientation="horizontal"`. When changing the orientation, ensure that the item are laid
out horizontally via CSS. To do this you may want to target CSS at
`.cdk-virtual-scroll-content-wrapper` which is the wrapper element that contains the rendered
content.
```html
<cdk-virtual-scroll-viewport itemSize="50" orientation="horizontal">
...
</cdk-virtual-scroll-viewport>
```
### Elements with parent tag requirements
Some HTML elements such as `<tr>` and `<li>` have limitations on the kinds of parent elements they
can be placed inside. To enable virtual scrolling over these type of elements, place the elements in
their proper parent, and then wrap the whole thing in a `cdk-virtual-scroll-viewport`. Be careful
that the parent does not introduce additional space (e.g. via `margin` or `padding`) as it will
interfere with the scrolling.
```html
<cdk-virtual-scroll-viewport itemSize="50">
<table>
<tr *cdkVirtualFor="let row of rows">
<td>{{row.first}}</td>
<td>{{row.second}}</td>
</tr>
</table>
</cdk-virtual-scroll-viewport>
```
+3 -3
View File
@@ -8,7 +8,7 @@
import {BidiModule} from '@angular/cdk/bidi';
import {PortalModule} from '@angular/cdk/portal';
import {ScrollDispatchModule, VIEWPORT_RULER_PROVIDER} from '@angular/cdk/scrolling';
import {ScrollingModule, VIEWPORT_RULER_PROVIDER} from '@angular/cdk/scrolling';
import {NgModule, Provider} from '@angular/core';
import {OVERLAY_KEYBOARD_DISPATCHER_PROVIDER} from './keyboard/overlay-keyboard-dispatcher';
import {Overlay} from './overlay';
@@ -22,8 +22,8 @@ import {OverlayPositionBuilder} from './position/overlay-position-builder';
@NgModule({
imports: [BidiModule, PortalModule, ScrollDispatchModule],
exports: [CdkConnectedOverlay, CdkOverlayOrigin, ScrollDispatchModule],
imports: [BidiModule, PortalModule, ScrollingModule],
exports: [CdkConnectedOverlay, CdkOverlayOrigin, ScrollingModule],
declarations: [CdkConnectedOverlay, CdkOverlayOrigin],
providers: [
Overlay,
@@ -1,5 +1,5 @@
import {ComponentPortal, PortalModule} from '@angular/cdk/portal';
import {CdkScrollable, ScrollDispatchModule} from '@angular/cdk/scrolling';
import {CdkScrollable, ScrollingModule} from '@angular/cdk/scrolling';
import {MockNgZone} from '@angular/cdk/testing';
import {Component, ElementRef, NgModule, NgZone} from '@angular/core';
import {inject, TestBed} from '@angular/core/testing';
@@ -31,7 +31,7 @@ describe('ConnectedPositionStrategy', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ScrollDispatchModule, OverlayModule, OverlayTestModule],
imports: [ScrollingModule, OverlayModule, OverlayTestModule],
providers: [{provide: NgZone, useFactory: () => zone = new MockNgZone()}]
});
@@ -1,5 +1,5 @@
import {ComponentPortal, PortalModule} from '@angular/cdk/portal';
import {CdkScrollable, ScrollDispatchModule} from '@angular/cdk/scrolling';
import {CdkScrollable, ScrollingModule} from '@angular/cdk/scrolling';
import {MockNgZone} from '@angular/cdk/testing';
import {Component, ElementRef, NgModule, NgZone} from '@angular/core';
import {inject, TestBed} from '@angular/core/testing';
@@ -30,7 +30,7 @@ describe('FlexibleConnectedPositionStrategy', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ScrollDispatchModule, OverlayModule, OverlayTestModule],
imports: [ScrollingModule, OverlayModule, OverlayTestModule],
providers: [{provide: NgZone, useFactory: () => zone = new MockNgZone()}]
});
+10
View File
@@ -1,25 +1,35 @@
package(default_visibility=["//visibility:public"])
load("@angular//:index.bzl", "ng_module")
load("@build_bazel_rules_typescript//:defs.bzl", "ts_library", "ts_web_test")
load("@io_bazel_rules_sass//sass:sass.bzl", "sass_binary")
ng_module(
name = "scrolling",
srcs = glob(["**/*.ts"], exclude=["**/*.spec.ts"]),
module_name = "@angular/cdk/scrolling",
assets = [":virtual-scroll-viewport.css"] + glob(["**/*.html"]),
deps = [
"//src/cdk/coercion",
"//src/cdk/collections",
"//src/cdk/platform",
"@rxjs",
],
tsconfig = "//src/cdk:tsconfig-build.json",
)
sass_binary(
name = "virtual_scroll_viewport_scss",
src = "virtual-scroll-viewport.scss",
)
ts_library(
name = "scrolling_test_sources",
testonly = 1,
srcs = glob(["**/*.spec.ts"]),
deps = [
":scrolling",
"//src/cdk/collections",
"//src/cdk/testing",
"@rxjs",
],
+5 -1
View File
@@ -6,7 +6,11 @@
* found in the LICENSE file at https://angular.io/license
*/
export * from './fixed-size-virtual-scroll';
export * from './scroll-dispatcher';
export * from './scrollable';
export * from './viewport-ruler';
export * from './scrolling-module';
export * from './viewport-ruler';
export * from './virtual-for-of';
export * from './virtual-scroll-strategy';
export * from './virtual-scroll-viewport';
+2 -2
View File
@@ -1,6 +1,6 @@
import {inject, TestBed, async, fakeAsync, ComponentFixture, tick} from '@angular/core/testing';
import {NgModule, Component, ViewChild, ElementRef} from '@angular/core';
import {CdkScrollable, ScrollDispatcher, ScrollDispatchModule} from './public-api';
import {CdkScrollable, ScrollDispatcher, ScrollingModule} from './public-api';
import {dispatchFakeEvent} from '@angular/cdk/testing';
describe('ScrollDispatcher', () => {
@@ -250,7 +250,7 @@ class NestedScrollingComponent {
const TEST_COMPONENTS = [ScrollingComponent, NestedScrollingComponent];
@NgModule({
imports: [ScrollDispatchModule],
imports: [ScrollingModule],
providers: [ScrollDispatcher],
exports: TEST_COMPONENTS,
declarations: TEST_COMPONENTS,
+25 -2
View File
@@ -8,11 +8,34 @@
import {PlatformModule} from '@angular/cdk/platform';
import {NgModule} from '@angular/core';
import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll';
import {CdkScrollable} from './scrollable';
import {CdkVirtualForOf} from './virtual-for-of';
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
@NgModule({
imports: [PlatformModule],
exports: [CdkScrollable],
declarations: [CdkScrollable],
exports: [
CdkFixedSizeVirtualScroll,
CdkScrollable,
CdkVirtualForOf,
CdkVirtualScrollViewport,
],
declarations: [
CdkFixedSizeVirtualScroll,
CdkScrollable,
CdkVirtualForOf,
CdkVirtualScrollViewport,
],
})
export class ScrollingModule {}
/**
* @deprecated ScrollDispatchModule has been renamed to ScrollingModule.
* @breaking-change 8.0.0 delete this alias
*/
@NgModule({
imports: [ScrollingModule],
exports: [ScrollingModule],
})
export class ScrollDispatchModule {}
+130
View File
@@ -8,3 +8,133 @@ The `cdkScrollable` directive should be applied to any element that acts as a sc
This marks the element as a `Scrollable` and registers it with the `ScrollDispatcher`. The
dispatcher, then, allows components to share both event listeners and knowledge of all of the
scrollable containers in the application.
### ViewportRuler
The `ViewportRuler` is a service that can be injected and used to measure the bounds of the browser
viewport.
### Virtual scrolling
The `<cdk-virtual-scroll-viewport>` displays large lists of elements performantly by only
rendering the items that fit on-screen. Loading hundreds of elements can be slow in any browser;
virtual scrolling enables a performant way to simulate all items being rendered by making the
height of the container element the same as the height of total number of elements to be rendered,
and then only rendering the items in view. Virtual scrolling is different from strategies like
infinite scroll where it renders a set amount of elements and then when you hit the end renders the
rest.
#### Creating items in the viewport
`*cdkVirtualFor` replaces `*ngFor` inside of a `<cdk-virtual-scroll-viewport>`, supporting the exact
same API as [`*ngFor`](https://angular.io/api/common/NgForOf). The simplest usage just specifies the
list of items:
```html
<cdk-virtual-scroll-viewport itemSize="50">
<div *cdkVirtualFor="let item of items">{{item}}</div>
</cdk-virtual-scroll-viewport>
```
`*cdkVirtualFor` makes the following context variables available to the template:
| Context variable | Description |
|------------------|----------------------------------------------------|
| `index` | The index of the item in the data source. |
| `count` | The total number of items in the data source. |
| `first` | Whether this is the first item in the data source. |
| `last` | Whether this is the last item in the data source. |
| `even` | Whether the `index` is even. |
| `odd` | Whether the `index` is odd. |
All of these apply to the index of the item in the data source, not the index in the rendered
portion of the data.
```html
<cdk-virtual-scroll-viewport itemSize="50">
<div *cdkVirtualFor="let item of items;
let index = index;
let count = count">
{{item}} ({{index}} of {{count}})
</div>
</cdk-virtual-scroll-viewport>
```
A `trackBy` function can be specified and works the same as the `*ngFor` `trackBy`. The `index`
passed to the tracking function will be the index in the data source, not the index in the rendered
portion.
##### View recycling
To improve rendering performance, `*cdkVirtualFor` caches previously created views after
they are no longer needed. When a new view would normally be created, a cached view
is reused instead. The size of the view cache can be adjusted via the `templateCacheSize`
property; setting this size to `0` disables caching. If your templates are expensive in terms of
memory you may wish to reduce this number to avoid spending too much memory on the template cache.
```html
<cdk-virtual-scroll-viewport itemSize="50">
<div *cdkVirtualFor="let item of items; templateCacheSize: 0">{{item}}</div>
</cdk-virtual-scroll-viewport>
```
##### Specifying data
`*cdkVirtualFor` accepts data from an `Array`, `Observable<Array>`, or `DataSource`. The
`DataSource` for the virtual scroll is the same one used by the table and tree components. A
`DataSource` is simply an abstract class that has two methods: `connect` and `disconnect`. The
`connect` method will be called by the virtual scroll viewport to receive a stream that emits the
data array that should be rendered. The viewport will call `disconnect` when the viewport is
destroyed, which may be the right time to clean up any subscriptions that were registered during the
connect process.
#### Scrolling over fixed size items
When all items are the same fixed size, you can use the `FixedSizeVirtualScrollStrategy`. This can
be easily added to your viewport using the `itemSize` directive. The advantage of this constraint is
that it allows for better performance, since items do not need to be measured as they are rendered.
```html
<cdk-virtual-scroll-viewport itemSize="50">
...
</cdk-virtual-scroll-viewport>
```
The fixed size strategy also supports setting the buffer size, i.e. the number of items rendered
beyond the edge of the viewport. This can be adjusted by setting the `bufferSize` input. If
`bufferSize` is not specified it defaults to 5 items.
```html
<cdk-virtual-scroll-viewport itemSize="50" bufferSize="1">
...
</cdk-virtual-scroll-viewport>
```
Other virtual scrolling strategies can be implemented by extending `VirtualScrollStrategy`. An
autosize strategy that works on elements of differing sizes is currently being developed in
`@angular/cdk-experimental`, but it is not ready for production use yet.
### Viewport orientation
The virtual-scroll viewport defaults to a vertical orientation, but can also be set to
`orientation="horizontal"`. When changing the orientation, ensure that the item are laid
out horizontally via CSS. To do this you may want to target CSS at
`.cdk-virtual-scroll-content-wrapper` which is the wrapper element that contains the rendered
content.
```html
<cdk-virtual-scroll-viewport itemSize="50" orientation="horizontal">
...
</cdk-virtual-scroll-viewport>
```
### Elements with parent tag requirements
Some HTML elements such as `<tr>` and `<li>` have limitations on the kinds of parent elements they
can be placed inside. To enable virtual scrolling over these type of elements, place the elements in
their proper parent, and then wrap the whole thing in a `cdk-virtual-scroll-viewport`. Be careful
that the parent does not introduce additional space (e.g. via `margin` or `padding`) as it will
interfere with the scrolling.
```html
<cdk-virtual-scroll-viewport itemSize="50">
<table>
<tr *cdkVirtualFor="let row of rows">
<td>{{row.first}}</td>
<td>{{row.second}}</td>
</tr>
</table>
</cdk-virtual-scroll-viewport>
```
+2 -1
View File
@@ -1,7 +1,8 @@
{
"extends": "../tsconfig-build",
"files": [
"public-api.ts"
"public-api.ts",
"./typings.d.ts"
],
"angularCompilerOptions": {
"annotateForClosureCompiler": true,
+2 -2
View File
@@ -1,5 +1,5 @@
import {TestBed, inject, fakeAsync, tick} from '@angular/core/testing';
import {ScrollDispatchModule} from './public-api';
import {ScrollingModule} from './public-api';
import {ViewportRuler} from './viewport-ruler';
import {dispatchFakeEvent} from '@angular/cdk/testing';
@@ -24,7 +24,7 @@ describe('ViewportRuler', () => {
veryLargeElement.style.height = '6000px';
beforeEach(() => TestBed.configureTestingModule({
imports: [ScrollDispatchModule],
imports: [ScrollingModule],
providers: [ViewportRuler]
}));
-3
View File
@@ -1,3 +0,0 @@
### Viewport
A utility for getting the bounds of the browser viewport.
@@ -0,0 +1,631 @@
import {ArrayDataSource} from '@angular/cdk/collections';
import {CdkVirtualForOf, CdkVirtualScrollViewport, ScrollingModule} from '@angular/cdk/scrolling';
import {dispatchFakeEvent} from '@angular/cdk/testing';
import {Component, Input, ViewChild, ViewContainerRef, ViewEncapsulation} from '@angular/core';
import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing';
import {animationFrameScheduler, Subject} from 'rxjs';
describe('CdkVirtualScrollViewport', () => {
describe ('with FixedSizeVirtualScrollStrategy', () => {
let fixture: ComponentFixture<FixedSizeVirtualScroll>;
let testComponent: FixedSizeVirtualScroll;
let viewport: CdkVirtualScrollViewport;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ScrollingModule],
declarations: [FixedSizeVirtualScroll],
}).compileComponents();
fixture = TestBed.createComponent(FixedSizeVirtualScroll);
testComponent = fixture.componentInstance;
viewport = testComponent.viewport;
});
it('should render initial state', fakeAsync(() => {
finishInit(fixture);
const contentWrapper =
viewport.elementRef.nativeElement.querySelector('.cdk-virtual-scroll-content-wrapper')!;
expect(contentWrapper.children.length)
.toBe(4, 'should render 4 50px items to fill 200px space');
}));
it('should get the data length', fakeAsync(() => {
finishInit(fixture);
expect(viewport.getDataLength()).toBe(testComponent.items.length);
}));
it('should get the viewport size', fakeAsync(() => {
finishInit(fixture);
expect(viewport.getViewportSize()).toBe(testComponent.viewportSize);
}));
it('should update viewport size', fakeAsync(() => {
testComponent.viewportSize = 300;
fixture.detectChanges();
flush();
viewport.checkViewportSize();
expect(viewport.getViewportSize()).toBe(300);
testComponent.viewportSize = 500;
fixture.detectChanges();
flush();
viewport.checkViewportSize();
expect(viewport.getViewportSize()).toBe(500);
}));
it('should get the rendered range', fakeAsync(() => {
finishInit(fixture);
expect(viewport.getRenderedRange())
.toEqual({start: 0, end: 4}, 'should render the first 4 50px items to fill 200px space');
}));
it('should get the rendered content offset', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize + 5);
fixture.detectChanges();
flush();
expect(viewport.getOffsetToRenderedContentStart()).toBe(testComponent.itemSize,
'should have 50px offset since first 50px item is not rendered');
}));
it('should get the scroll offset', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize + 5);
fixture.detectChanges();
flush();
expect(viewport.measureScrollOffset()).toBe(testComponent.itemSize + 5);
}));
it('should get the rendered content size', fakeAsync(() => {
finishInit(fixture);
expect(viewport.measureRenderedContentSize())
.toBe(testComponent.viewportSize,
'should render 4 50px items with combined size of 200px to fill 200px space');
}));
it('should measure range size', fakeAsync(() => {
finishInit(fixture);
expect(viewport.measureRangeSize({start: 1, end: 3}))
.toBe(testComponent.itemSize * 2, 'combined size of 2 50px items should be 100px');
}));
it('should set total content size', fakeAsync(() => {
finishInit(fixture);
viewport.setTotalContentSize(10000);
flush();
fixture.detectChanges();
expect(viewport.elementRef.nativeElement.scrollHeight).toBe(10000);
}));
it('should set rendered range', fakeAsync(() => {
finishInit(fixture);
viewport.setRenderedRange({start: 2, end: 3});
fixture.detectChanges();
flush();
const items = fixture.elementRef.nativeElement.querySelectorAll('.item');
expect(items.length).toBe(1, 'Expected 1 item to be rendered');
expect(items[0].innerText.trim()).toBe('2 - 2', 'Expected item with index 2 to be rendered');
}));
it('should set content offset to top of content', fakeAsync(() => {
finishInit(fixture);
viewport.setRenderedContentOffset(10, 'to-start');
fixture.detectChanges();
flush();
expect(viewport.getOffsetToRenderedContentStart()).toBe(10);
}));
it('should set content offset to bottom of content', fakeAsync(() => {
finishInit(fixture);
const contentSize = viewport.measureRenderedContentSize();
expect(contentSize).toBeGreaterThan(0);
viewport.setRenderedContentOffset(contentSize + 10, 'to-end');
fixture.detectChanges();
flush();
expect(viewport.getOffsetToRenderedContentStart()).toBe(10);
}));
it('should set scroll offset', fakeAsync(() => {
finishInit(fixture);
viewport.setScrollOffset(testComponent.itemSize * 2);
fixture.detectChanges();
flush();
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.elementRef.nativeElement.scrollTop).toBe(testComponent.itemSize * 2);
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
}));
it('should scroll to offset', fakeAsync(() => {
finishInit(fixture);
viewport.scrollToOffset(testComponent.itemSize * 2);
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.elementRef.nativeElement.scrollTop).toBe(testComponent.itemSize * 2);
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
}));
it('should scroll to index', fakeAsync(() => {
finishInit(fixture);
viewport.scrollToIndex(2);
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.elementRef.nativeElement.scrollTop).toBe(testComponent.itemSize * 2);
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
}));
it('should scroll to offset in horizontal mode', fakeAsync(() => {
testComponent.orientation = 'horizontal';
finishInit(fixture);
viewport.scrollToOffset(testComponent.itemSize * 2);
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.elementRef.nativeElement.scrollLeft).toBe(testComponent.itemSize * 2);
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
}));
it('should scroll to index in horizontal mode', fakeAsync(() => {
testComponent.orientation = 'horizontal';
finishInit(fixture);
viewport.scrollToIndex(2);
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.elementRef.nativeElement.scrollLeft).toBe(testComponent.itemSize * 2);
expect(viewport.getRenderedRange()).toEqual({start: 2, end: 6});
}));
it('should output scrolled index', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 2 - 1);
fixture.detectChanges();
flush();
expect(testComponent.scrolledToIndex).toBe(1);
triggerScroll(viewport, testComponent.itemSize * 2);
fixture.detectChanges();
flush();
expect(testComponent.scrolledToIndex).toBe(2);
}));
it('should update viewport as user scrolls down', fakeAsync(() => {
finishInit(fixture);
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = 0; offset <= maxOffset; offset += 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
const expectedRange = {
start: Math.floor(offset / testComponent.itemSize),
end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize)
};
expect(viewport.getRenderedRange())
.toEqual(expectedRange,
`rendered range should match expected value at scroll offset ${offset}`);
expect(viewport.getOffsetToRenderedContentStart())
.toBe(expectedRange.start * testComponent.itemSize,
`rendered content offset should match expected value at scroll offset ${offset}`);
expect(viewport.measureRenderedContentSize())
.toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize,
`rendered content size should match expected value at offset ${offset}`);
}
}));
it('should update viewport as user scrolls up', fakeAsync(() => {
finishInit(fixture);
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = maxOffset; offset >= 0; offset -= 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
const expectedRange = {
start: Math.floor(offset / testComponent.itemSize),
end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize)
};
expect(viewport.getRenderedRange())
.toEqual(expectedRange,
`rendered range should match expected value at scroll offset ${offset}`);
expect(viewport.getOffsetToRenderedContentStart())
.toBe(expectedRange.start * testComponent.itemSize,
`rendered content offset should match expected value at scroll offset ${offset}`);
expect(viewport.measureRenderedContentSize())
.toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize,
`rendered content size should match expected value at offset ${offset}`);
}
}));
it('should render buffer element at the end when scrolled to the top', fakeAsync(() => {
testComponent.bufferSize = 1;
finishInit(fixture);
expect(viewport.getRenderedRange()).toEqual({start: 0, end: 5},
'should render the first 5 50px items to fill 200px space, plus one buffer element at' +
' the end');
}));
it('should render buffer element at the start and end when scrolled to the middle',
fakeAsync(() => {
testComponent.bufferSize = 1;
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 2);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange()).toEqual({start: 1, end: 7},
'should render 6 50px items to fill 200px space, plus one buffer element at the' +
' start and end');
}));
it('should render buffer element at the start when scrolled to the bottom', fakeAsync(() => {
testComponent.bufferSize = 1;
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 6);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange()).toEqual({start: 5, end: 10},
'should render the last 5 50px items to fill 200px space, plus one buffer element at' +
' the start');
}));
it('should handle dynamic item size', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 2);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 2, end: 6}, 'should render 4 50px items to fill 200px space');
testComponent.itemSize *= 2;
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 1, end: 3}, 'should render 2 100px items to fill 200px space');
}));
it('should handle dynamic buffer size', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 2);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 2, end: 6}, 'should render 4 50px items to fill 200px space');
testComponent.bufferSize = 1;
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 1, end: 7}, 'should expand to 1 buffer element on each side');
}));
it('should handle dynamic item array', fakeAsync(() => {
finishInit(fixture);
triggerScroll(viewport, testComponent.itemSize * 6);
fixture.detectChanges();
flush();
expect(viewport.getOffsetToRenderedContentStart())
.toBe(testComponent.itemSize * 6, 'should be scrolled to bottom of 10 item list');
testComponent.items = Array(5).fill(0);
fixture.detectChanges();
flush();
triggerScroll(viewport);
fixture.detectChanges();
flush();
expect(viewport.getOffsetToRenderedContentStart())
.toBe(testComponent.itemSize, 'should be scrolled to bottom of 5 item list');
}));
it('should update viewport as user scrolls right in horizontal mode', fakeAsync(() => {
testComponent.orientation = 'horizontal';
finishInit(fixture);
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = 0; offset <= maxOffset; offset += 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
const expectedRange = {
start: Math.floor(offset / testComponent.itemSize),
end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize)
};
expect(viewport.getRenderedRange())
.toEqual(expectedRange,
`rendered range should match expected value at scroll offset ${offset}`);
expect(viewport.getOffsetToRenderedContentStart())
.toBe(expectedRange.start * testComponent.itemSize,
`rendered content offset should match expected value at scroll offset ${offset}`);
expect(viewport.measureRenderedContentSize())
.toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize,
`rendered content size should match expected value at offset ${offset}`);
}
}));
it('should update viewport as user scrolls left in horizontal mode', fakeAsync(() => {
testComponent.orientation = 'horizontal';
finishInit(fixture);
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = maxOffset; offset >= 0; offset -= 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
const expectedRange = {
start: Math.floor(offset / testComponent.itemSize),
end: Math.ceil((offset + testComponent.viewportSize) / testComponent.itemSize)
};
expect(viewport.getRenderedRange())
.toEqual(expectedRange,
`rendered range should match expected value at scroll offset ${offset}`);
expect(viewport.getOffsetToRenderedContentStart())
.toBe(expectedRange.start * testComponent.itemSize,
`rendered content offset should match expected value at scroll offset ${offset}`);
expect(viewport.measureRenderedContentSize())
.toBe((expectedRange.end - expectedRange.start) * testComponent.itemSize,
`rendered content size should match expected value at offset ${offset}`);
}
}));
it('should work with an Observable', fakeAsync(() => {
const data = new Subject<number[]>();
testComponent.items = data as any;
finishInit(fixture);
expect(viewport.getRenderedRange())
.toEqual({start: 0, end: 0}, 'no items should be rendered');
data.next([1, 2, 3]);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 0, end: 3}, 'newly emitted items should be rendered');
}));
it('should work with a DataSource', fakeAsync(() => {
const data = new Subject<number[]>();
testComponent.items = new ArrayDataSource(data) as any;
finishInit(fixture);
expect(viewport.getRenderedRange())
.toEqual({start: 0, end: 0}, 'no items should be rendered');
data.next([1, 2, 3]);
fixture.detectChanges();
flush();
expect(viewport.getRenderedRange())
.toEqual({start: 0, end: 3}, 'newly emitted items should be rendered');
}));
it('should trackBy value by default', fakeAsync(() => {
testComponent.items = [];
spyOn(testComponent.virtualForViewContainer, 'detach').and.callThrough();
finishInit(fixture);
testComponent.items = [0];
fixture.detectChanges();
flush();
expect(testComponent.virtualForViewContainer.detach).not.toHaveBeenCalled();
testComponent.items = [1];
fixture.detectChanges();
flush();
expect(testComponent.virtualForViewContainer.detach).toHaveBeenCalled();
}));
it('should trackBy index when specified', fakeAsync(() => {
testComponent.trackBy = i => i;
testComponent.items = [];
spyOn(testComponent.virtualForViewContainer, 'detach').and.callThrough();
finishInit(fixture);
testComponent.items = [0];
fixture.detectChanges();
flush();
expect(testComponent.virtualForViewContainer.detach).not.toHaveBeenCalled();
testComponent.items = [1];
fixture.detectChanges();
flush();
expect(testComponent.virtualForViewContainer.detach).not.toHaveBeenCalled();
}));
it('should recycle views when template cache is large enough to accommodate', fakeAsync(() => {
testComponent.trackBy = i => i;
const spy =
spyOn(testComponent.virtualForViewContainer, 'createEmbeddedView').and.callThrough();
finishInit(fixture);
// Should create views for the initial rendered items.
expect(testComponent.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(4);
spy.calls.reset();
triggerScroll(viewport, 10);
fixture.detectChanges();
flush();
// 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.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(1);
spy.calls.reset();
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = 10; offset <= maxOffset; offset += 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
}
// 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.virtualForViewContainer.createEmbeddedView).not.toHaveBeenCalled();
}));
it('should not recycle views when template cache is full', fakeAsync(() => {
testComponent.trackBy = i => i;
testComponent.templateCacheSize = 0;
const spy =
spyOn(testComponent.virtualForViewContainer, 'createEmbeddedView').and.callThrough();
finishInit(fixture);
// Should create views for the initial rendered items.
expect(testComponent.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(4);
spy.calls.reset();
triggerScroll(viewport, 10);
fixture.detectChanges();
flush();
// 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.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(1);
spy.calls.reset();
const maxOffset =
testComponent.itemSize * testComponent.items.length - testComponent.viewportSize;
for (let offset = 10; offset <= maxOffset; offset += 10) {
triggerScroll(viewport, offset);
fixture.detectChanges();
flush();
}
// 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.virtualForViewContainer.createEmbeddedView).toHaveBeenCalledTimes(5);
}));
});
});
/** Finish initializing the virtual scroll component at the beginning of a test. */
function finishInit(fixture: ComponentFixture<any>) {
// On the first cycle we render and measure the viewport.
fixture.detectChanges();
flush();
// On the second cycle we render the items.
fixture.detectChanges();
flush();
}
/** Trigger a scroll event on the viewport (optionally setting a new scroll offset). */
function triggerScroll(viewport: CdkVirtualScrollViewport, offset?: number) {
if (offset !== undefined) {
if (viewport.orientation == 'horizontal') {
viewport.elementRef.nativeElement.scrollLeft = offset;
} else {
viewport.elementRef.nativeElement.scrollTop = offset;
}
}
dispatchFakeEvent(viewport.elementRef.nativeElement, 'scroll');
animationFrameScheduler.flush();
}
@Component({
template: `
<cdk-virtual-scroll-viewport
[itemSize]="itemSize" [bufferSize]="bufferSize" [orientation]="orientation"
[style.height.px]="viewportHeight" [style.width.px]="viewportWidth"
(scrolledIndexChange)="scrolledToIndex = $event">
<div class="item"
*cdkVirtualFor="let item of items; let i = index; trackBy: trackBy; \
templateCacheSize: templateCacheSize"
[style.height.px]="itemSize" [style.width.px]="itemSize">
{{i}} - {{item}}
</div>
</cdk-virtual-scroll-viewport>
`,
styles: [`
.cdk-virtual-scroll-content-wrapper {
display: flex;
flex-direction: column;
}
.cdk-virtual-scroll-orientation-horizontal .cdk-virtual-scroll-content-wrapper {
flex-direction: row;
}
`],
encapsulation: ViewEncapsulation.None,
})
class FixedSizeVirtualScroll {
@ViewChild(CdkVirtualScrollViewport) viewport: CdkVirtualScrollViewport;
@ViewChild(CdkVirtualForOf, {read: ViewContainerRef}) virtualForViewContainer: ViewContainerRef;
@Input() orientation = 'vertical';
@Input() viewportSize = 200;
@Input() viewportCrossSize = 100;
@Input() itemSize = 50;
@Input() bufferSize = 0;
@Input() items = Array(10).fill(0).map((_, i) => i);
@Input() trackBy;
@Input() templateCacheSize = 20;
scrolledToIndex = 0;
get viewportWidth() {
return this.orientation == 'horizontal' ? this.viewportSize : this.viewportCrossSize;
}
get viewportHeight() {
return this.orientation == 'horizontal' ? this.viewportCrossSize : this.viewportSize;
}
}
+4 -2
View File
@@ -6,18 +6,19 @@
* found in the LICENSE file at https://angular.io/license
*/
import {ScrollingModule} from '@angular/cdk-experimental/scrolling';
import {DialogModule} from '@angular/cdk-experimental/dialog';
import {DragDropModule} from '@angular/cdk-experimental/drag-drop';
import {ScrollingModule as ExperimentalScrollingModule} from '@angular/cdk-experimental/scrolling';
import {A11yModule} from '@angular/cdk/a11y';
import {CdkAccordionModule} from '@angular/cdk/accordion';
import {BidiModule} from '@angular/cdk/bidi';
import {TextFieldModule} from '@angular/cdk/text-field';
import {ObserversModule} from '@angular/cdk/observers';
import {OverlayModule} from '@angular/cdk/overlay';
import {PlatformModule} from '@angular/cdk/platform';
import {PortalModule} from '@angular/cdk/portal';
import {ScrollingModule} from '@angular/cdk/scrolling';
import {CdkTableModule} from '@angular/cdk/table';
import {TextFieldModule} from '@angular/cdk/text-field';
import {CdkTreeModule} from '@angular/cdk/tree';
import {NgModule} from '@angular/core';
import {
@@ -112,6 +113,7 @@ import {
PlatformModule,
PortalModule,
ScrollingModule,
ExperimentalScrollingModule,
DialogModule,
DragDropModule,
]
+3 -1
View File
@@ -1,7 +1,8 @@
import {ScrollingModule} from '@angular/cdk-experimental/scrolling';
import {DialogModule} from '@angular/cdk-experimental/dialog';
import {DragDropModule} from '@angular/cdk-experimental/drag-drop';
import {ScrollingModule as ExperimentalScrollingModule} from '@angular/cdk-experimental/scrolling';
import {FullscreenOverlayContainer, OverlayContainer} from '@angular/cdk/overlay';
import {ScrollingModule} from '@angular/cdk/scrolling';
import {NgModule} from '@angular/core';
import {ReactiveFormsModule} from '@angular/forms';
import {
@@ -71,6 +72,7 @@ import {VirtualScrollE2E} from './virtual-scroll/virtual-scroll-e2e';
MatTabsModule,
MatNativeDateModule,
ScrollingModule,
ExperimentalScrollingModule,
DialogModule,
DragDropModule,
]
+2 -2
View File
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {PlatformModule} from '@angular/cdk/platform';
import {ScrollDispatchModule} from '@angular/cdk/scrolling';
import {ScrollingModule} from '@angular/cdk/scrolling';
import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
import {MatCommonModule} from '@angular/material/core';
@@ -18,7 +18,7 @@ import {MatSidenav, MatSidenavContainer, MatSidenavContent} from './sidenav';
imports: [
CommonModule,
MatCommonModule,
ScrollDispatchModule,
ScrollingModule,
PlatformModule,
],
exports: [
+2 -2
View File
@@ -1,7 +1,7 @@
import {Direction, Directionality} from '@angular/cdk/bidi';
import {END, ENTER, HOME, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes';
import {PortalModule} from '@angular/cdk/portal';
import {ScrollDispatchModule, ViewportRuler} from '@angular/cdk/scrolling';
import {ScrollingModule, ViewportRuler} from '@angular/cdk/scrolling';
import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing';
import {CommonModule} from '@angular/common';
import {Component, ViewChild} from '@angular/core';
@@ -30,7 +30,7 @@ describe('MatTabHeader', () => {
beforeEach(async(() => {
dir = 'ltr';
TestBed.configureTestingModule({
imports: [CommonModule, PortalModule, MatRippleModule, ScrollDispatchModule],
imports: [CommonModule, PortalModule, MatRippleModule, ScrollingModule],
declarations: [
MatTabHeader,
MatInkBar,
+3 -3
View File
@@ -1,6 +1,6 @@
import {NgModule} from '@angular/core';
import {ScrollDispatchModule} from '@angular/cdk/scrolling';
import {ScrollingModule} from '@angular/cdk/scrolling';
import {A11yModule} from '@angular/cdk/a11y';
import {CdkTableModule} from '@angular/cdk/table';
import {CdkTreeModule} from '@angular/cdk/tree';
@@ -54,7 +54,7 @@ import {
MatToolbarModule,
MatTooltipModule,
MatTreeModule,
ScrollDispatchModule,
ScrollingModule,
],
exports: [
A11yModule,
@@ -95,7 +95,7 @@ import {
MatToolbarModule,
MatTooltipModule,
MatTreeModule,
ScrollDispatchModule,
ScrollingModule,
]
})
export class ExampleMaterialModule {}