mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(virtual-scroll): move from cdk-experimental to cdk (#12438)
This commit is contained in:
@@ -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>
|
||||
```
|
||||
@@ -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()}]
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig-build",
|
||||
"files": [
|
||||
"public-api.ts"
|
||||
"public-api.ts",
|
||||
"./typings.d.ts"
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"annotateForClosureCompiler": true,
|
||||
|
||||
@@ -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]
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user