Skip to content

Commit 2fa56ed

Browse files
committed
feat(cdk/scrolling): make scroller element configurable for virtual scrolling
Decouples the scroller from the virtual-scroll-viewport which allows library consumers to use any parent element as a scroller. This is especially helpful when building SPAs with a single global scrollbar. Closes #13862
1 parent 4f13d25 commit 2fa56ed

11 files changed

+232
-27
lines changed

src/cdk/scrolling/public-api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,6 @@ export * from './virtual-for-of';
1515
export * from './virtual-scroll-strategy';
1616
export * from './virtual-scroll-viewport';
1717
export * from './virtual-scroll-repeater';
18+
export * from './virtual-scrollable';
19+
export * from './virtual-scrollable-element';
20+
export * from './virtual-scrollable-window';

src/cdk/scrolling/scrollable.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ export class CdkScrollable implements OnInit, OnDestroy {
4949

5050
private _elementScrolled: Observable<Event> = new Observable((observer: Observer<Event>) =>
5151
this.ngZone.runOutsideAngular(() =>
52-
fromEvent(this.elementRef.nativeElement, 'scroll')
52+
/* it seems like scroll-events are not fired on the documentElement, event if it's the actual scrolling element */
53+
fromEvent(
54+
this.elementRef.nativeElement === document.documentElement
55+
? document
56+
: this.elementRef.nativeElement,
57+
'scroll',
58+
)
5359
.pipe(takeUntil(this._destroyed))
5460
.subscribe(observer),
5561
),

src/cdk/scrolling/scrolling-module.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll';
1212
import {CdkScrollable} from './scrollable';
1313
import {CdkVirtualForOf} from './virtual-for-of';
1414
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
15+
import {CdkVirtualScrollableElement} from './virtual-scrollable-element';
16+
import {CdkVirtualScrollableWindow} from './virtual-scrollable-window';
1517

1618
@NgModule({
1719
exports: [CdkScrollable],
@@ -30,7 +32,15 @@ export class CdkScrollableModule {}
3032
CdkFixedSizeVirtualScroll,
3133
CdkVirtualForOf,
3234
CdkVirtualScrollViewport,
35+
CdkVirtualScrollableWindow,
36+
CdkVirtualScrollableElement,
37+
],
38+
declarations: [
39+
CdkFixedSizeVirtualScroll,
40+
CdkVirtualForOf,
41+
CdkVirtualScrollViewport,
42+
CdkVirtualScrollableWindow,
43+
CdkVirtualScrollableElement,
3344
],
34-
declarations: [CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport],
3545
})
3646
export class ScrollingModule {}

src/cdk/scrolling/virtual-scroll-viewport.scss

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,18 @@
2727
}
2828

2929

30-
// Scrolling container.
30+
// viewport
3131
cdk-virtual-scroll-viewport {
3232
display: block;
3333
position: relative;
34-
overflow: auto;
35-
contain: strict;
3634
transform: translateZ(0);
35+
}
36+
37+
// Scrolling container.
38+
.cdk-virtual-scrollable {
39+
overflow: auto;
3740
will-change: scroll-position;
41+
contain: strict;
3842
-webkit-overflow-scrolling: touch;
3943
}
4044

@@ -69,11 +73,7 @@ cdk-virtual-scroll-viewport {
6973
// set if it were rendered all at once. This ensures that the scrollable content region is the
7074
// correct size.
7175
.cdk-virtual-scroll-spacer {
72-
position: absolute;
73-
top: 0;
74-
left: 0;
7576
height: 1px;
76-
width: 1px;
7777
transform-origin: 0 0;
7878

7979
// Note: We can't put `will-change: transform;` here because it causes Safari to not update the

src/cdk/scrolling/virtual-scroll-viewport.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -757,9 +757,9 @@ describe('CdkVirtualScrollViewport', () => {
757757
spyOn(dispatcher, 'register').and.callThrough();
758758
spyOn(dispatcher, 'deregister').and.callThrough();
759759
finishInit(fixture);
760-
expect(dispatcher.register).toHaveBeenCalledWith(testComponent.viewport);
760+
expect(dispatcher.register).toHaveBeenCalledWith(testComponent.viewport.scrollable);
761761
fixture.destroy();
762-
expect(dispatcher.deregister).toHaveBeenCalledWith(testComponent.viewport);
762+
expect(dispatcher.deregister).toHaveBeenCalledWith(testComponent.viewport.scrollable);
763763
}),
764764
));
765765

src/cdk/scrolling/virtual-scroll-viewport.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
OnInit,
2121
Optional,
2222
Output,
23+
Renderer2,
2324
ViewChild,
2425
ViewEncapsulation,
2526
} from '@angular/core';
@@ -38,6 +39,7 @@ import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-s
3839
import {ViewportRuler} from './viewport-ruler';
3940
import {CdkVirtualScrollRepeater} from './virtual-scroll-repeater';
4041
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
42+
import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable';
4143

4244
/** Checks if the given ranges are equal. */
4345
function rangesEqual(r1: ListRange, r2: ListRange): boolean {
@@ -67,11 +69,12 @@ const SCROLL_SCHEDULER =
6769
providers: [
6870
{
6971
provide: CdkScrollable,
70-
useExisting: CdkVirtualScrollViewport,
72+
useFactory: (scrollViewport: CdkVirtualScrollViewport) => scrollViewport.scrollable,
73+
deps: [CdkVirtualScrollViewport],
7174
},
7275
],
7376
})
74-
export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, OnDestroy {
77+
export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements OnInit, OnDestroy {
7578
/** Emits when the viewport is detached from a CdkVirtualForOf. */
7679
private readonly _detachedSubject = new Subject<void>();
7780

@@ -179,6 +182,8 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
179182
@Optional() dir: Directionality,
180183
scrollDispatcher: ScrollDispatcher,
181184
viewportRuler: ViewportRuler,
185+
renderer: Renderer2,
186+
@Optional() @Inject(VIRTUAL_SCROLLABLE) public scrollable: CdkVirtualScrollable,
182187
) {
183188
super(elementRef, scrollDispatcher, ngZone, dir);
184189

@@ -189,11 +194,18 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
189194
this._viewportChanges = viewportRuler.change().subscribe(() => {
190195
this.checkViewportSize();
191196
});
197+
198+
if (!this.scrollable) {
199+
// No scrollable is provided, so the virtual-scroll-viewport needs to become a scrollable
200+
renderer.addClass(this.elementRef.nativeElement, 'cdk-virtual-scrollable');
201+
this.scrollable = this;
202+
}
192203
}
193204

194205
override ngOnInit() {
195-
super.ngOnInit();
196-
206+
if (this.scrollable === this) {
207+
super.ngOnInit();
208+
}
197209
// It's still too early to measure the viewport at this point. Deferring with a promise allows
198210
// the Viewport to be rendered with the correct size before we measure. We run this outside the
199211
// zone to avoid causing more change detection cycles. We handle the change detection loop
@@ -203,7 +215,8 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
203215
this._measureViewportSize();
204216
this._scrollStrategy.attach(this);
205217

206-
this.elementScrolled()
218+
this.scrollable
219+
.elementScrolled()
207220
.pipe(
208221
// Start off with a fake scroll event so we properly detect our initial position.
209222
startWith(null),
@@ -361,7 +374,7 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
361374
} else {
362375
options.top = offset;
363376
}
364-
this.scrollTo(options);
377+
this.scrollable.scrollTo(options);
365378
}
366379

367380
/**
@@ -374,16 +387,50 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
374387
}
375388

376389
/**
377-
* Gets the current scroll offset from the start of the viewport (in pixels).
390+
* Gets the current scroll offset from the start of the scrollable (in pixels).
378391
* @param from The edge to measure the offset from. Defaults to 'top' in vertical mode and 'start'
379392
* in horizontal mode.
380393
*/
381394
override measureScrollOffset(
382395
from?: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end',
383396
): number {
384-
return from
385-
? super.measureScrollOffset(from)
386-
: super.measureScrollOffset(this.orientation === 'horizontal' ? 'start' : 'top');
397+
// This is to break the call cycle
398+
let measureScrollOffset: InstanceType<typeof CdkVirtualScrollable>['measureScrollOffset'];
399+
if (this.scrollable == this) {
400+
measureScrollOffset = (_from: NonNullable<typeof from>) => super.measureScrollOffset(_from);
401+
} else {
402+
measureScrollOffset = (_from: NonNullable<typeof from>) =>
403+
this.scrollable.measureScrollOffset(_from);
404+
}
405+
406+
return Math.max(
407+
0,
408+
measureScrollOffset(from ?? (this.orientation === 'horizontal' ? 'start' : 'top')) -
409+
this.measureViewportOffset(),
410+
);
411+
}
412+
413+
measureViewportOffset(from?: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end') {
414+
let fromRect: 'left' | 'top' | 'right' | 'bottom';
415+
const LEFT = 'left';
416+
const RIGHT = 'right';
417+
const isRtl = this.dir?.value == 'rtl';
418+
if (from == 'start') {
419+
fromRect = isRtl ? RIGHT : LEFT;
420+
} else if (from == 'end') {
421+
fromRect = isRtl ? LEFT : RIGHT;
422+
} else if (from) {
423+
fromRect = from;
424+
} else {
425+
fromRect = this.orientation === 'horizontal' ? 'left' : 'top';
426+
}
427+
428+
const scrollerClientRect = this.scrollable
429+
.getElementRef()
430+
.nativeElement.getBoundingClientRect()[fromRect];
431+
const viewportClientRect = this.elementRef.nativeElement.getBoundingClientRect()[fromRect];
432+
433+
return viewportClientRect - scrollerClientRect;
387434
}
388435

389436
/** Measure the combined size of all of the rendered items. */
@@ -412,9 +459,7 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
412459

413460
/** Measure the viewport size. */
414461
private _measureViewportSize() {
415-
const viewportEl = this.elementRef.nativeElement;
416-
this._viewportSize =
417-
this.orientation === 'horizontal' ? viewportEl.clientWidth : viewportEl.clientHeight;
462+
this._viewportSize = this.scrollable.measureViewportSize(this.orientation);
418463
}
419464

420465
/** Queue up change detection to run. */
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Directionality} from '@angular/cdk/bidi';
10+
import {Directive, ElementRef, NgZone, Optional} from '@angular/core';
11+
import {ScrollDispatcher} from './scroll-dispatcher';
12+
import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable';
13+
14+
@Directive({
15+
selector: '[cdk-virtual-scrollable-element], [cdkVirtualScrollableElement]',
16+
providers: [{provide: VIRTUAL_SCROLLABLE, useExisting: CdkVirtualScrollableElement}],
17+
host: {
18+
'class': 'cdk-virtual-scrollable',
19+
},
20+
})
21+
export class CdkVirtualScrollableElement extends CdkVirtualScrollable {
22+
constructor(
23+
elementRef: ElementRef,
24+
scrollDispatcher: ScrollDispatcher,
25+
ngZone: NgZone,
26+
@Optional() dir: Directionality,
27+
) {
28+
super(elementRef, scrollDispatcher, ngZone, dir);
29+
}
30+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Directionality} from '@angular/cdk/bidi';
10+
import {Directive, ElementRef, NgZone, Optional} from '@angular/core';
11+
import {ScrollDispatcher} from './scroll-dispatcher';
12+
import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable';
13+
14+
@Directive({
15+
selector: 'cdk-virtual-scroll-viewport[scrollable-window]',
16+
providers: [{provide: VIRTUAL_SCROLLABLE, useExisting: CdkVirtualScrollableWindow}],
17+
})
18+
export class CdkVirtualScrollableWindow extends CdkVirtualScrollable {
19+
constructor(scrollDispatcher: ScrollDispatcher, ngZone: NgZone, @Optional() dir: Directionality) {
20+
super(new ElementRef(document.documentElement), scrollDispatcher, ngZone, dir);
21+
}
22+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Directionality} from '@angular/cdk/bidi';
10+
import {Directive, ElementRef, InjectionToken, NgZone, Optional} from '@angular/core';
11+
import {ScrollDispatcher} from './scroll-dispatcher';
12+
import {CdkScrollable} from './scrollable';
13+
14+
export const VIRTUAL_SCROLLABLE = new InjectionToken<CdkVirtualScrollable>('VIRTUAL_SCROLLABLE');
15+
16+
@Directive()
17+
export abstract class CdkVirtualScrollable extends CdkScrollable {
18+
constructor(
19+
elementRef: ElementRef<HTMLElement>,
20+
scrollDispatcher: ScrollDispatcher,
21+
ngZone: NgZone,
22+
@Optional() dir?: Directionality,
23+
) {
24+
super(elementRef, scrollDispatcher, ngZone, dir);
25+
}
26+
27+
measureViewportSize(orientation: 'horizontal' | 'vertical') {
28+
const viewportEl = this.elementRef.nativeElement;
29+
return orientation === 'horizontal' ? viewportEl.clientWidth : viewportEl.clientHeight;
30+
}
31+
}

src/dev-app/virtual-scroll/virtual-scroll-demo.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,25 @@ <h2>Append only</h2>
178178
Item #{{i}} - ({{size}}px)
179179
</div>
180180
</cdk-virtual-scroll-viewport>
181+
182+
<h2>Custom virtual scroller</h2>
183+
184+
<div class="demo-viewport" cdk-virtual-scrollable-element>
185+
<p>Content before virtual scrolling items</p>
186+
<cdk-virtual-scroll-viewport itemSize="50">
187+
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
188+
[style.height.px]="size">
189+
Item #{{i}} - ({{size}}px)
190+
</div>
191+
</cdk-virtual-scroll-viewport>
192+
<p>Content after virtual scrolling items</p>
193+
</div>
194+
195+
<h2>Window virtual scroller</h2>
196+
197+
<cdk-virtual-scroll-viewport scrollable-window itemSize="50">
198+
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
199+
[style.height.px]="size">
200+
Item #{{i}} - ({{size}}px)
201+
</div>
202+
</cdk-virtual-scroll-viewport>

0 commit comments

Comments
 (0)