Skip to main content

Example: Scroll Service

An inspector, transmitter, receiver, and related service used to take action upon scrolling a specific DOM element. For example, you can cause one element to scroll when scrolling another element.

ScrollInfo

export interface ScrollInfo {
scrollTop?: number;
scrollLeft?: number;
maxHScroll?: number;
maxVScroll?: number;
}

Scroll Service

import { Injectable } from '@angular/core';
import { fromEvent, Observable } from "rxjs";
import { map } from "rxjs/operators";


import { ScrollInfo } from '../model/scrollInfo';


type Target = Document | Element;


/**
* This service is based on the service example from
* https://www.bennadel.com/blog/3446-monitoring-document-and-element-scroll-percentages-using-rxjs-in-angular-6-0-2.htm
*/
@Injectable({
providedIn: 'root'
})
export class ScrollService {


constructor() { }


public getScroll(node: Target = document): ScrollInfo {
return this.getCurrentScroll(node);
}


/**
* Return the current scroll info of the given DOM node as a STREAM.
* NOTE: The resultant STREAM is a COLD stream, which means that it won't actually
* subscribe to the underlying DOM events unless something in the calling context
* subscribes to the COLD stream.
*/
public getScrollAsStream(node: Target = document): Observable<ScrollInfo> {
if (node instanceof Document) {
// When we watch the DOCUMENT, we need to pull the scroll event from the
// WINDOW, but then check the scroll offsets of the DOCUMENT.
var stream = fromEvent(window, 'scroll').pipe(
map((event: UIEvent): ScrollInfo => {
return this.getScroll(node);
})
);
} else {
// When we watch an ELEMENT node, we can pull the scroll event and the scroll
// offsets from the same ELEMENT node (unlike the Document version).
var stream = fromEvent(node, "scroll").pipe(
map((event: UIEvent): ScrollInfo => {
return (this.getScroll(node));
})
);
}


return (stream);
}


private getCurrentScroll(node: Target): ScrollInfo {
let maxScroll = this.getMaxScroll(node);


if (node instanceof Document) {
return {
scrollTop: window.pageYOffset,
scrollLeft: window.pageXOffset,
maxHScroll: maxScroll.maxHScroll,
maxVScroll: maxScroll.maxVScroll
};


} else {
return {
scrollTop: node.scrollTop,
scrollLeft: node.scrollLeft,
maxHScroll: maxScroll.maxHScroll,
maxVScroll: maxScroll.maxVScroll
};
}
}


private getMaxScroll(node: Target): any {
if (node instanceof Document) {
let scrollHeight = Math.max(
node.body.scrollHeight,
node.body.offsetHeight,
node.body.clientHeight,
node.documentElement.scrollHeight,
node.documentElement.offsetHeight,
node.documentElement.clientHeight
);


let scrollWidth = Math.max(
node.body.scrollWidth,
node.body.offsetWidth,
node.body.clientWidth,
node.documentElement.scrollWidth,
node.documentElement.offsetWidth,
node.documentElement.clientWidth
);


let clientHeight = node.documentElement.clientHeight;
let clientWidth = node.documentElement.clientWidth;


return { maxVScroll: (scrollHeight - clientHeight), maxHScroll: (scrollWidth - clientWidth) };


} else {
return { maxVScroll: (node.scrollHeight - node.clientHeight), maxHScroll: (node.scrollWidth - node.clientWidth) };
}
}
}

Directive: ScrollInspector

import { Directive, ElementRef, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output, AfterViewInit, ContentChild, Renderer2 } from '@angular/core';
import { Subject, Subscription } from "rxjs";
import _ from 'lodash';

import { ScrollService } from '../services/scroll.service';
import { ScrollInfo } from '../model/scrollInfo';
import { ScrollTransmitterDirective } from './scroll-transmitter.directive';
import { ScrollToStartDirective } from './scroll-to-start.directive';
import { ScrollToEndDirective } from './scroll-to-end.directive';

/**
* This directive is based on the directive example from
* https://www.bennadel.com/blog/3446-monitoring-document-and-element-scroll-percentages-using-rxjs-in-angular-6-0-2.htm
*/
@Directive({
selector: '[scrollInspector]'
})
export class ScrollInspectorDirective implements OnInit, OnDestroy, AfterViewInit {
// We need to use ContentChild instead of ViewChild to access a child directive
@ContentChild(ScrollTransmitterDirective, { read: ElementRef }) scrollTransmitter: ElementRef;
@ContentChild(ScrollToStartDirective, { read: ElementRef }) scrollToStartBtn: ElementRef;
@ContentChild(ScrollToEndDirective, { read: ElementRef }) scrollToEndBtn: ElementRef;

// This is a mechanism for an external element to request that the scroll information be re-evaluated.
@Input() externalRefreshSubject: Subject<any>;

@Output() scrollPositionChanged: EventEmitter<ScrollInfo>;

private scrollSource: ElementRef;
private subscription: Subscription;
private externalRefreshSubscription: Subscription;
private scrollToStartBtnCleanup: any;
private scrollToEndBtnCleanup: any;
private lastScrollInfo: ScrollInfo;
private debounceGetFreshScrollInfo: any;

constructor(private elementRef: ElementRef, private renderer: Renderer2, private scrollService: ScrollService) {
this.scrollPositionChanged = new EventEmitter();
this.subscription = null;
}

ngOnInit() {
// Determine which element to use as the scroll source.
// Use the host element (this.elementRef) if a child ScrollTransmitter instance is not set.
this.scrollSource = this.scrollTransmitter || this.elementRef;

this.debounceGetFreshScrollInfo = _.debounce(this.getElementScrollInfo, 100);

// Hide the scroll controls until we have a little more data to determine if they should be visible.
this.setScrollControls(null);

// Subscribe to the scroll event of the scroll source element and pipe the scroll event to the output event emitter.
// This will fire on every scroll event.
if (this.scrollSource) {
this.subscription = this.scrollService.getScrollAsStream(this.scrollSource.nativeElement)
.subscribe((scrollInfo: ScrollInfo): void => {
this.applyNewScrollInfo(scrollInfo);
});
}

if(this.externalRefreshSubject) {
this.externalRefreshSubscription = this.externalRefreshSubject.subscribe(() => {
this.getElementScrollInfo(this);
});
}
}

ngAfterViewInit() {
if (this.scrollSource) {
// Dispatch a ScrollInfo event for listeners to indicate initial scroll ranges.
let currentScrollInfo: ScrollInfo = this.scrollService.getScroll(this.scrollSource.nativeElement);
setTimeout(() => {
this.applyNewScrollInfo(currentScrollInfo);
});

// Set up a click handler for the Scroll to Start button if it is present.
if (this.scrollToStartBtn) {

this.scrollToStartBtnCleanup = this.renderer.listen(this.scrollToStartBtn.nativeElement, 'click', () => {
const pixelsToScroll = this.scrollSource.nativeElement.scrollLeft - Math.round(this.lastScrollInfo.maxHScroll * (10/100));
this.scrollSource.nativeElement.scrollLeft = pixelsToScroll < 0 ? 0: pixelsToScroll;
});
}

// Set up a click handler for the Scroll to End button if it is present.
if (this.scrollToEndBtn) {
this.scrollToEndBtnCleanup = this.renderer.listen(this.scrollToEndBtn.nativeElement, 'click', () => {
const pixelsToScroll = this.scrollSource.nativeElement.scrollLeft + Math.round(this.lastScrollInfo.maxHScroll * (10/100));
this.scrollSource.nativeElement.scrollLeft = pixelsToScroll > this.lastScrollInfo.maxHScroll ? this.lastScrollInfo.maxHScroll: pixelsToScroll;
});
}
}
}

@HostListener('window:resize', ['$event'])
onWindowResize(/*event*/) {
if (this.debounceGetFreshScrollInfo) {
// Call debounced function and pass appropriate param values.
// See where sharedService.debounce is called and getElementScrollInfo function.
this.debounceGetFreshScrollInfo(this);
}
}

getElementScrollInfo(self) {
if (!(self.scrollService && self.scrollSource)) {
return;
}

let currentScrollInfo: ScrollInfo = self.scrollService.getScroll(self.scrollSource.nativeElement);
self.applyNewScrollInfo(currentScrollInfo);
}

applyNewScrollInfo(scrollInfo: ScrollInfo) {
this.lastScrollInfo = scrollInfo;
this.setScrollControls(this.lastScrollInfo);
this.scrollPositionChanged.next(this.lastScrollInfo);
}

setScrollControls(scrollInfo: ScrollInfo = null) {
if (!scrollInfo && this.lastScrollInfo) {
scrollInfo = this.lastScrollInfo;
}

// TODO: This is heavy handed. We may just want to dispatch an event with the suggested visibility of each button and let the host view control the buttons.

// Turn off the start and end scroll control buttons if they have been included
if (this.scrollToStartBtn) {
this.scrollToStartBtn.nativeElement.style.visibility = scrollInfo && scrollInfo.scrollLeft > 0 ? 'visible' : 'hidden';
}


if (this.scrollToEndBtn) {
this.scrollToEndBtn.nativeElement.style.visibility = scrollInfo && scrollInfo.scrollLeft < scrollInfo.maxHScroll ? 'visible' : 'hidden';
}
}

ngOnDestroy() {
if (this.subscription) {
this.subscription.unsubscribe();
}

if(this.externalRefreshSubscription) {
this.externalRefreshSubscription.unsubscribe();
}

if (this.scrollToStartBtnCleanup) {
this.scrollToStartBtnCleanup();
}

if (this.scrollToEndBtnCleanup) {
this.scrollToEndBtnCleanup();
}
}
}

Directive: ScrollTransmitter

import { Directive } from '@angular/core';

@Directive({
selector: '[scrollTransmitter]'
})
export class ScrollTransmitterDirective {
constructor() { }
}

Directive: ScrollReceiver

import { Directive } from '@angular/core';

@Directive({
selector: '[scrollReceiver]'
})
export class ScrollReceiverDirective {
constructor() { }
}

Directive: ScrollToStart

import { Directive } from '@angular/core';

@Directive({
selector: '[scrollToStart]'
})
export class ScrollToStartDirective {
constructor() { }
}

Directive: ScrollToEnd

import { Directive } from '@angular/core';

@Directive({
selector: '[scrollToEnd]'
})
export class ScrollToEndDirective {
constructor() { }
}

Example Usage

component.ts

import { Component, Input, ViewChild, ElementRef } from '@angular/core';
import { ListComponent } from './list.component';
import _ from 'lodash';

@Component({
selector: 'list-component-fixed-header',
templateUrl: './list-component-fixed-header.component.html',
styleUrls: ['./list-component-fixed-header.component.scss']
})
export class ListComponentFixedHeaderComponent extends ListComponent {

@Input() getRowStyle = () => null;
@Input() showCustomScrollButtons = true;

headerEl;
bodyEl;

constructor(private elRef: ElementRef) {
super();
}

ngOnInit() {
super.ngOnInit();
}

getNativeEletements() {
if (!this.headerEl && this.elRef) {
this.headerEl = this.elRef.nativeElement.querySelector(".header-container") || {};
this.bodyEl = this.elRef.nativeElement.querySelector(".body-container") || {};
}

return {headerEl: this.headerEl, bodyEl: this.bodyEl};
}

updateScroll(hScroll:number = null, vScroll: number = null) {
this.getNativeEletements();
if(_.isNumber(hScroll)) {

this.headerEl.scrollLeft = this.bodyEl.scrollLeft = hScroll;
}

if(_.isNumber(vScroll)) {
this.headerEl.scrollTop = this.bodyEl.scrollTop = vScroll;
}
}

onTableScroll(scrollInfo: any = null) {
super.onTableScroll(scrollInfo);
this.getNativeEletements();
this.headerEl.scrollLeft = this.bodyEl.scrollLeft;
}
}

component.html

<!-- Default template with selectable row -->
<ng-template #defaultTPML let-tmplHost="view">
<div class="fixed-header-list-container" scrollInspector (scrollPositionChanged)="tmplHost.onTableScroll($event)" >
<div class="header-container" scrollReceiver>
<table class="table list-component-table">
<thead>
<tr>
<th *ngFor="let headerCol of tmplHost.columns" [ngClass]="headerCol.getHeaderStyle ? headerCol.getHeaderStyle(headerCol) : null" scope="col">
<div [ngClass]="headerCol.getHeaderLabelStyle ? headerCol.getHeaderLabelStyle(headerCol) : null">{{ headerCol.name }}</div>
</th>

</tr>
</thead>
</table>
</div>

<div class="body-container" #scrollTransmitterElem scrollTransmitter >
<table class="table list-component-table">
<thead>
<tr>
<th *ngFor="let headerCol of tmplHost.columns" [ngClass]="headerCol.getHeaderStyle ? headerCol.getHeaderStyle(headerCol) : null" scope="col">
<!--empty header to set the column with-->
<div [ngClass]="headerCol.getHeaderLabelStyle ? headerCol.getHeaderLabelStyle(headerCol) : null"></div>
</th>

</tr>
</thead>
<tbody>

<tr *ngFor="let rowData of tmplHost.items" [ngClass]="tmplHost.getRowStyle ? tmplHost.getRowStyle(rowData) :null">

<td *ngFor="let rowCol of tmplHost.columns" [ngClass]="rowCol.getCellStyle ? rowCol.getCellStyle(rowData) : null">

<div [ngClass]="rowCol.getCellLabelStyle ? rowCol.getCellLabelStyle(rowData) :null">{{ tmplHost.getValueFromPath(rowData, rowCol.keyPath, rowCol) }}</div>
</td>
</tr>
</tbody>
</table>
</div>

<button *ngIf="showCustomScrollButtons" id="fnTableScrollToStartBtn" type="button" class="btn btn-clear scroll-btn-l" title="Jump to Start" scrollToStart>
<i class="d-inline-block svg-arrow-right-blue svg-arrow-right-blue-dims left-arrow"></i>
</button>


<button *ngIf="showCustomScrollButtons" id="fnTableScrollToEndBtn" type="button" class="btn btn-clear scroll-btn-r" title="Jump to End" scrollToEnd>
<i class="d-inline-block svg-arrow-right-blue svg-arrow-right-blue-dims"></i>
</button>

</div>
</ng-template>

<ng-container *ngTemplateOutlet="viewTemplate || defaultTPML;context: templateCtx"></ng-container>

<!-- Empty message-->
<div *ngIf="!items || items.length === 0" class="border rounded mh-10 d-flex justify-content-center" >
<h6 class="p-3 d-inline-block font-weight-bold">{{emptyMessage}}</h6>
</div>