Skip to content

Commit

Permalink
feat(cdk/scrolling): make scroller element configurable for virtual s…
Browse files Browse the repository at this point in the history
…crolling

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 angular#13862
  • Loading branch information
spike-rabbit committed May 13, 2022
1 parent c7e5f65 commit 4bac438
Show file tree
Hide file tree
Showing 11 changed files with 232 additions and 27 deletions.
3 changes: 3 additions & 0 deletions src/cdk/scrolling/public-api.ts
Expand Up @@ -15,3 +15,6 @@ export * from './virtual-for-of';
export * from './virtual-scroll-strategy';
export * from './virtual-scroll-viewport';
export * from './virtual-scroll-repeater';
export * from './virtual-scrollable';
export * from './virtual-scrollable-element';
export * from './virtual-scrollable-window';
8 changes: 7 additions & 1 deletion src/cdk/scrolling/scrollable.ts
Expand Up @@ -49,7 +49,13 @@ export class CdkScrollable implements OnInit, OnDestroy {

private _elementScrolled: Observable<Event> = new Observable((observer: Observer<Event>) =>
this.ngZone.runOutsideAngular(() =>
fromEvent(this.elementRef.nativeElement, 'scroll')
/* it seems like scroll-events are not fired on the documentElement, event if it's the actual scrolling element */
fromEvent(
this.elementRef.nativeElement === document.documentElement
? document
: this.elementRef.nativeElement,
'scroll',
)
.pipe(takeUntil(this._destroyed))
.subscribe(observer),
),
Expand Down
12 changes: 11 additions & 1 deletion src/cdk/scrolling/scrolling-module.ts
Expand Up @@ -12,6 +12,8 @@ import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll';
import {CdkScrollable} from './scrollable';
import {CdkVirtualForOf} from './virtual-for-of';
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
import {CdkVirtualScrollableElement} from './virtual-scrollable-element';
import {CdkVirtualScrollableWindow} from './virtual-scrollable-window';

@NgModule({
exports: [CdkScrollable],
Expand All @@ -30,7 +32,15 @@ export class CdkScrollableModule {}
CdkFixedSizeVirtualScroll,
CdkVirtualForOf,
CdkVirtualScrollViewport,
CdkVirtualScrollableWindow,
CdkVirtualScrollableElement,
],
declarations: [
CdkFixedSizeVirtualScroll,
CdkVirtualForOf,
CdkVirtualScrollViewport,
CdkVirtualScrollableWindow,
CdkVirtualScrollableElement,
],
declarations: [CdkFixedSizeVirtualScroll, CdkVirtualForOf, CdkVirtualScrollViewport],
})
export class ScrollingModule {}
14 changes: 7 additions & 7 deletions src/cdk/scrolling/virtual-scroll-viewport.scss
Expand Up @@ -27,14 +27,18 @@
}


// Scrolling container.
// viewport
cdk-virtual-scroll-viewport {
display: block;
position: relative;
overflow: auto;
contain: strict;
transform: translateZ(0);
}

// Scrolling container.
.cdk-virtual-scrollable {
overflow: auto;
will-change: scroll-position;
contain: strict;
-webkit-overflow-scrolling: touch;
}

Expand Down Expand Up @@ -69,11 +73,7 @@ cdk-virtual-scroll-viewport {
// set if it were rendered all at once. This ensures that the scrollable content region is the
// correct size.
.cdk-virtual-scroll-spacer {
position: absolute;
top: 0;
left: 0;
height: 1px;
width: 1px;
transform-origin: 0 0;

// Note: We can't put `will-change: transform;` here because it causes Safari to not update the
Expand Down
4 changes: 2 additions & 2 deletions src/cdk/scrolling/virtual-scroll-viewport.spec.ts
Expand Up @@ -772,9 +772,9 @@ describe('CdkVirtualScrollViewport', () => {
spyOn(dispatcher, 'register').and.callThrough();
spyOn(dispatcher, 'deregister').and.callThrough();
finishInit(fixture);
expect(dispatcher.register).toHaveBeenCalledWith(testComponent.viewport);
expect(dispatcher.register).toHaveBeenCalledWith(testComponent.viewport.scrollable);
fixture.destroy();
expect(dispatcher.deregister).toHaveBeenCalledWith(testComponent.viewport);
expect(dispatcher.deregister).toHaveBeenCalledWith(testComponent.viewport.scrollable);
}),
));

Expand Down
71 changes: 58 additions & 13 deletions src/cdk/scrolling/virtual-scroll-viewport.ts
Expand Up @@ -20,6 +20,7 @@ import {
OnInit,
Optional,
Output,
Renderer2,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
Expand All @@ -38,6 +39,7 @@ import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-s
import {ViewportRuler} from './viewport-ruler';
import {CdkVirtualScrollRepeater} from './virtual-scroll-repeater';
import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion';
import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable';

/** Checks if the given ranges are equal. */
function rangesEqual(r1: ListRange, r2: ListRange): boolean {
Expand Down Expand Up @@ -67,11 +69,12 @@ const SCROLL_SCHEDULER =
providers: [
{
provide: CdkScrollable,
useExisting: CdkVirtualScrollViewport,
useFactory: (scrollViewport: CdkVirtualScrollViewport) => scrollViewport.scrollable,
deps: [CdkVirtualScrollViewport],
},
],
})
export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, OnDestroy {
export class CdkVirtualScrollViewport extends CdkVirtualScrollable implements OnInit, OnDestroy {
/** Emits when the viewport is detached from a CdkVirtualForOf. */
private readonly _detachedSubject = new Subject<void>();

Expand Down Expand Up @@ -179,6 +182,8 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
@Optional() dir: Directionality,
scrollDispatcher: ScrollDispatcher,
viewportRuler: ViewportRuler,
renderer: Renderer2,
@Optional() @Inject(VIRTUAL_SCROLLABLE) public scrollable: CdkVirtualScrollable,
) {
super(elementRef, scrollDispatcher, ngZone, dir);

Expand All @@ -189,11 +194,18 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
this._viewportChanges = viewportRuler.change().subscribe(() => {
this.checkViewportSize();
});

if (!this.scrollable) {
// No scrollable is provided, so the virtual-scroll-viewport needs to become a scrollable
renderer.addClass(this.elementRef.nativeElement, 'cdk-virtual-scrollable');
this.scrollable = this;
}
}

override ngOnInit() {
super.ngOnInit();

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

this.elementScrolled()
this.scrollable
.elementScrolled()
.pipe(
// Start off with a fake scroll event so we properly detect our initial position.
startWith(null),
Expand Down Expand Up @@ -361,7 +374,7 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
} else {
options.top = offset;
}
this.scrollTo(options);
this.scrollable.scrollTo(options);
}

/**
Expand All @@ -374,16 +387,50 @@ export class CdkVirtualScrollViewport extends CdkScrollable implements OnInit, O
}

/**
* Gets the current scroll offset from the start of the viewport (in pixels).
* Gets the current scroll offset from the start of the scrollable (in pixels).
* @param from The edge to measure the offset from. Defaults to 'top' in vertical mode and 'start'
* in horizontal mode.
*/
override measureScrollOffset(
from?: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end',
): number {
return from
? super.measureScrollOffset(from)
: super.measureScrollOffset(this.orientation === 'horizontal' ? 'start' : 'top');
// This is to break the call cycle
let measureScrollOffset: InstanceType<typeof CdkVirtualScrollable>['measureScrollOffset'];
if (this.scrollable == this) {
measureScrollOffset = (_from: NonNullable<typeof from>) => super.measureScrollOffset(_from);
} else {
measureScrollOffset = (_from: NonNullable<typeof from>) =>
this.scrollable.measureScrollOffset(_from);
}

return Math.max(
0,
measureScrollOffset(from ?? (this.orientation === 'horizontal' ? 'start' : 'top')) -
this.measureViewportOffset(),
);
}

measureViewportOffset(from?: 'top' | 'left' | 'right' | 'bottom' | 'start' | 'end') {
let fromRect: 'left' | 'top' | 'right' | 'bottom';
const LEFT = 'left';
const RIGHT = 'right';
const isRtl = this.dir?.value == 'rtl';
if (from == 'start') {
fromRect = isRtl ? RIGHT : LEFT;
} else if (from == 'end') {
fromRect = isRtl ? LEFT : RIGHT;
} else if (from) {
fromRect = from;
} else {
fromRect = this.orientation === 'horizontal' ? 'left' : 'top';
}

const scrollerClientRect = this.scrollable
.getElementRef()
.nativeElement.getBoundingClientRect()[fromRect];
const viewportClientRect = this.elementRef.nativeElement.getBoundingClientRect()[fromRect];

return viewportClientRect - scrollerClientRect;
}

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

/** Measure the viewport size. */
private _measureViewportSize() {
const viewportEl = this.elementRef.nativeElement;
this._viewportSize =
this.orientation === 'horizontal' ? viewportEl.clientWidth : viewportEl.clientHeight;
this._viewportSize = this.scrollable.measureViewportSize(this.orientation);
}

/** Queue up change detection to run. */
Expand Down
30 changes: 30 additions & 0 deletions src/cdk/scrolling/virtual-scrollable-element.ts
@@ -0,0 +1,30 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Directionality} from '@angular/cdk/bidi';
import {Directive, ElementRef, NgZone, Optional} from '@angular/core';
import {ScrollDispatcher} from './scroll-dispatcher';
import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable';

@Directive({
selector: '[cdk-virtual-scrollable-element], [cdkVirtualScrollableElement]',
providers: [{provide: VIRTUAL_SCROLLABLE, useExisting: CdkVirtualScrollableElement}],
host: {
'class': 'cdk-virtual-scrollable',
},
})
export class CdkVirtualScrollableElement extends CdkVirtualScrollable {
constructor(
elementRef: ElementRef,
scrollDispatcher: ScrollDispatcher,
ngZone: NgZone,
@Optional() dir: Directionality,
) {
super(elementRef, scrollDispatcher, ngZone, dir);
}
}
22 changes: 22 additions & 0 deletions src/cdk/scrolling/virtual-scrollable-window.ts
@@ -0,0 +1,22 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Directionality} from '@angular/cdk/bidi';
import {Directive, ElementRef, NgZone, Optional} from '@angular/core';
import {ScrollDispatcher} from './scroll-dispatcher';
import {CdkVirtualScrollable, VIRTUAL_SCROLLABLE} from './virtual-scrollable';

@Directive({
selector: 'cdk-virtual-scroll-viewport[scrollable-window]',
providers: [{provide: VIRTUAL_SCROLLABLE, useExisting: CdkVirtualScrollableWindow}],
})
export class CdkVirtualScrollableWindow extends CdkVirtualScrollable {
constructor(scrollDispatcher: ScrollDispatcher, ngZone: NgZone, @Optional() dir: Directionality) {
super(new ElementRef(document.documentElement), scrollDispatcher, ngZone, dir);
}
}
31 changes: 31 additions & 0 deletions src/cdk/scrolling/virtual-scrollable.ts
@@ -0,0 +1,31 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Directionality} from '@angular/cdk/bidi';
import {Directive, ElementRef, InjectionToken, NgZone, Optional} from '@angular/core';
import {ScrollDispatcher} from './scroll-dispatcher';
import {CdkScrollable} from './scrollable';

export const VIRTUAL_SCROLLABLE = new InjectionToken<CdkVirtualScrollable>('VIRTUAL_SCROLLABLE');

@Directive()
export abstract class CdkVirtualScrollable extends CdkScrollable {
constructor(
elementRef: ElementRef<HTMLElement>,
scrollDispatcher: ScrollDispatcher,
ngZone: NgZone,
@Optional() dir?: Directionality,
) {
super(elementRef, scrollDispatcher, ngZone, dir);
}

measureViewportSize(orientation: 'horizontal' | 'vertical') {
const viewportEl = this.elementRef.nativeElement;
return orientation === 'horizontal' ? viewportEl.clientWidth : viewportEl.clientHeight;
}
}
22 changes: 22 additions & 0 deletions src/dev-app/virtual-scroll/virtual-scroll-demo.html
Expand Up @@ -178,3 +178,25 @@ <h2>Append only</h2>
Item #{{i}} - ({{size}}px)
</div>
</cdk-virtual-scroll-viewport>

<h2>Custom virtual scroller</h2>

<div class="demo-viewport" cdk-virtual-scrollable-element>
<p>Content before virtual scrolling items</p>
<cdk-virtual-scroll-viewport itemSize="50">
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
[style.height.px]="size">
Item #{{i}} - ({{size}}px)
</div>
</cdk-virtual-scroll-viewport>
<p>Content after virtual scrolling items</p>
</div>

<h2>Window virtual scroller</h2>

<cdk-virtual-scroll-viewport scrollable-window itemSize="50">
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-item"
[style.height.px]="size">
Item #{{i}} - ({{size}}px)
</div>
</cdk-virtual-scroll-viewport>

0 comments on commit 4bac438

Please sign in to comment.