Skip to content

Commit 3bfb3fe

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 7a6549f commit 3bfb3fe

11 files changed

+236
-30
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
@@ -13,6 +13,8 @@ import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll';
1313
import {CdkScrollable} from './scrollable';
1414
import {CdkVirtualForOf} from './virtual-for-of';
1515
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
16+
import {CdkVirtualScrollableElement} from './virtual-scrollable-element';
17+
import {CdkVirtualScrollableWindow} from './virtual-scrollable-window';
1618

1719
@NgModule({
1820
exports: [CdkScrollable],
@@ -31,7 +33,15 @@ export class CdkScrollableModule {}
3133
CdkFixedSizeVirtualScroll,
3234
CdkVirtualForOf,
3335
CdkVirtualScrollViewport,
36+
CdkVirtualScrollableWindow,
37+
CdkVirtualScrollableElement,
38+
],
39+
declarations: [
40+
CdkFixedSizeVirtualScroll,
41+
CdkVirtualForOf,
42+
CdkVirtualScrollViewport,
43+
CdkVirtualScrollableWindow,
44+
CdkVirtualScrollableElement,
3445
],
35-
declarations: [CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport],
3646
})
3747
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),
@@ -359,7 +372,7 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
359372
} else {
360373
options.top = offset;
361374
}
362-
this.scrollTo(options);
375+
this.scrollable.scrollTo(options);
363376
}
364377

365378
/**
@@ -372,16 +385,50 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
372385
}
373386

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

387434
/** Measure the combined size of all of the rendered items. */
@@ -410,9 +457,7 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
410457

411458
/** Measure the viewport size. */
412459
private _measureViewportSize() {
413-
const viewportEl = this.elementRef.nativeElement;
414-
this._viewportSize =
415-
this.orientation === 'horizontal' ? viewportEl.clientWidth : viewportEl.clientHeight;
460+
this._viewportSize = this.scrollable.measureViewportSize(this.orientation);
416461
}
417462

418463
/** 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)