Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at CFUNITED 2009 (Lansdowne, VA) with: Randy Brown
Ben Nadel at CFUNITED 2009 (Lansdowne, VA) with: Randy Brown

Monitoring Document And Element Scroll Percentages Using RxJS In Angular 6.0.2

By Ben Nadel on

Lately, Alligator.io has been coming up a lot in my Google searches when researching the Angular web development framework. Not only does Alligator.io have quality content, they also have a nice, clean site design. One of the little delighters that they have on their site is a progress bar the indicates how far through an article the user has scrolled. I thought that this would be an interesting feature to try and replicate in Angular 6.0.2 using an RxJS stream that emits percentage values based on scroll events.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

While scrolling primarily occurs on the main Document, there's nothing to say that scrolling can't happen on an embedded overflow:auto Element. As such, I wanted to create an abstraction in Angular that would work for both the browser viewport and any arbitrary Element contained within the Angular application.

That said, in Angular, it's not possible to attach a Directive to the main document since the root component is rendered inside of the document Body. As such, I needed to be able to provide a Service class that would expose an RxJS stream for the Document while, at the same time, also making it easy to monitor the scroll position of any embedded element.

To do this, I created a Service that would generate an RxJS stream of scroll percentages for a given node. The given node could be of type Document (by default) or of type Element. Then, I created a Directive that would simply "glue" the Service to the ElementRef associated with Directive's host container.

First, let's look at how this Service and Directive can be consumed in our root component. In this demo, I am monitoring the scroll position of both the parent document and of an embedded overflow element:

  • // Import the core angular services.
  • import { Component } from "@angular/core";
  • import { OnInit } from "@angular/core";
  • import range = require( "lodash/range" );
  •  
  • // Import the application components and services.
  • import { ElementScrollPercentage } from "./element-scroll-percentage";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Component({
  • selector: "my-app",
  • styleUrls: [ "./app.component.less" ],
  • template:
  • `
  • <div class="scroll-progress scroll-progress--fixed">
  • <div class="scroll-progress__indicator" [style.width.%]="pageScroll">
  • <br />
  • </div>
  • </div>
  •  
  • <p *ngFor="let i of demoRange">
  • Content goes here ...
  • </p>
  •  
  • <div class="inner">
  • <div class="scroll-progress scroll-progress--absolute">
  • <div class="scroll-progress__indicator" [style.width.%]="innerScroll">
  • <br />
  • </div>
  • </div>
  •  
  • <div
  • (scrollPercentage)="recordInnerScroll( $event )"
  • class="inner__content">
  •  
  • <p *ngFor="let i of demoRange">
  • Content goes here ...
  • </p>
  •  
  • </div>
  • </div>
  •  
  • <p *ngFor="let i of demoRange">
  • Content goes here ...
  • </p>
  • `
  • })
  • export class AppComponent implements OnInit {
  •  
  • public demoRange: number[];
  • public innerScroll: number;
  • public pageScroll: number;
  •  
  • private elementScrollPercentage: ElementScrollPercentage;
  •  
  • // I initialize the app-component.
  • constructor( elementScrollPercentage: ElementScrollPercentage ) {
  •  
  • this.elementScrollPercentage = elementScrollPercentage;
  •  
  • this.demoRange = range( 15 );
  • this.innerScroll = 0;
  • this.pageScroll = 0;
  •  
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called once after the inputs have been bound for the first time.
  • public ngOnInit() : void {
  •  
  • this.elementScrollPercentage
  • .getScrollAsStream() // Defaults to Document if no Element supplied.
  • .subscribe(
  • ( percent: number ) : void => {
  •  
  • this.pageScroll = percent;
  •  
  • }
  • )
  • ;
  •  
  • }
  •  
  •  
  • // I record the element scroll percentage of the inner content area, applying it
  • // to the inner status bar.
  • public recordInnerScroll( percent: number ) : void {
  •  
  • this.innerScroll = percent;
  •  
  • }
  •  
  • }

As you can see, I'm injecting the ElementScrollPercentage service into the App component. And, once the app component has initialized, I'm calling:

elementScrollPercentage.getScrollAsStream().subscribe( handler )

When we don't provide any argument to .getScrollAsStream(), the ElementScrollPercentage service will assume that you want to monitor the Document; and, it will return a Cold RxJS stream that emits percentage values as the user scrolls through the document. We are then using that percentage value stream to drive the [style.width.%] of a scroll indicator in the user interface.

In addition to explicitly binding to the ElementScrollPercentage service, we're also binding to a Directive output within our app component's template:

(scrollPercentage)="recordInnerScroll( $event )"

The use of [scrollPercentage] implicitly binds a Directive to the host Div and glues the Div DOM Element to the same ElementScrollPercentage service that the app component is using. Then, rather than explicitly subscribing to the RxJS stream, the Directive is piping emitting values to its own (scrollPercentage) output event.

Altogether, if we now run this Angular app in the browser and scroll the page and the overflow Element, we get the following output:


 
 
 

 
 Monitoring the element scroll percentage of the Document or a given Element using RxJS streams in Angular 6.0.2. 
 
 
 

As you can see, when the user scrolls through the page (or the embedded overflow:auto Element), our app component receives new percentage values from the explicit and implicit RxJS streams. We then use those emitted values to change the width of the relevant scroll indicators.

Now that we see what the ElementScrollPercentage service is doing, let's take a look at how it is doing it. Under the hood, the ElementScrollPercentage service is simply inspecting a given node, figuring out how much it can be scrolled, and then comparing that to the amount it is currently scrolled:

  • // Import the core angular services.
  • import { fromEvent } from "rxjs";
  • import { Injectable } from "@angular/core";
  • import { map } from "rxjs/operators";
  • import { Observable } from "rxjs";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • type Target = Document | Element;
  •  
  • @Injectable({
  • providedIn: "root"
  • })
  • export class ElementScrollPercentage {
  •  
  • // I initialize the element scroll percentage service.
  • constructor() {
  • // ...
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I return the current scroll percentage (0,100) of the given DOM node.
  • public getScroll( node: Target = document ) : number {
  •  
  • var currentScroll = this.getCurrentScroll( node );
  • var maxScroll = this.getMaxScroll( node );
  •  
  • // Ensure that the percentage falls strictly within (0,1).
  • var percent = ( currentScroll / Math.max( maxScroll, 1 ) );
  • percent = Math.max( percent, 0 );
  • percent = Math.min( percent, 1 );
  •  
  • // Return the percentage in a more human-consumable format.
  • return( percent * 100 );
  •  
  • }
  •  
  •  
  • // I return the current scroll percentage (0,100) 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<number> {
  •  
  • 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 ) : number => {
  •  
  • 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 ) : number => {
  •  
  • return( this.getScroll( node ) );
  •  
  • }
  • )
  • );
  •  
  • }
  •  
  • return( stream );
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I return the current scroll offset (in pixels) of the given DOM node.
  • private getCurrentScroll( node: Target ) : number {
  •  
  • if ( node instanceof Document ) {
  •  
  • return( window.pageYOffset );
  •  
  • } else {
  •  
  • return( node.scrollTop );
  •  
  • }
  •  
  • }
  •  
  •  
  • // I return the maximum scroll offset (in pixels) of the given DOM node.
  • private getMaxScroll( node: Target ) : number {
  •  
  • // When we want to get the available scroll height of the DOCUMENT, things get
  • // a little peculiar from a cross-browser consistency standpoint. As such, when
  • // dealing with the Document node, we have to look in a few different places.
  • // --
  • // READ MORE: https://javascript.info/size-and-scroll-window
  • if ( node instanceof Document ) {
  •  
  • var scrollHeight = Math.max(
  • node.body.scrollHeight,
  • node.body.offsetHeight,
  • node.body.clientHeight,
  • node.documentElement.scrollHeight,
  • node.documentElement.offsetHeight,
  • node.documentElement.clientHeight
  • );
  •  
  • var clientHeight = node.documentElement.clientHeight;
  •  
  • return( scrollHeight - clientHeight );
  •  
  • } else {
  •  
  • return( node.scrollHeight - node.clientHeight );
  •  
  • }
  •  
  • }
  •  
  • }

As you can see, I'm using RxJS to create a cold stream from the source of scroll events. Then, when observing the Document, I watch for the Window's scroll events. And, when observing an overflow Element, I watch for the Element's scroll events. With the way the code is written now, a new percentage is calculated with every single scroll event. Since the calculation requires an inspection of the DOM state, it is very likely that this is forcing repaints (though, I suppose the scroll event is doing that also). Regardless, I tried to add a throttleTime() operator to the RxJS stream; but, I couldn't get it to work. It never seemed to be emitting the last value. As such, I'm just transforming the scroll events as they are emitting.

Also, special thanks to JavaScript.info for their article on measuring the document height. It has some cross-browser wonkiness.

Now that we see what the ElementScrollPercentage service is doing, let's look at the Directive that glues the service to an Element in the Angular application:

  • // Import the core angular services.
  • import { Directive } from "@angular/core";
  • import { ElementRef } from "@angular/core";
  • import { EventEmitter } from "@angular/core";
  • import { Observable } from "rxjs";
  • import { OnDestroy } from "@angular/core";
  • import { OnInit } from "@angular/core";
  • import { Subscription } from "rxjs";
  •  
  • // Import the application components and services.
  • import { ElementScrollPercentage } from "./element-scroll-percentage";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @Directive({
  • selector: "[scrollPercentage]",
  • outputs: [ "scrollPercentageEvent: scrollPercentage" ]
  • })
  • export class ElementScrollPercentageDirective implements OnInit, OnDestroy {
  •  
  • public scrollPercentageEvent: EventEmitter<number>;
  •  
  • private elementRef: ElementRef;
  • private elementScrollPercentage: ElementScrollPercentage;
  • private subscription: Subscription;
  •  
  • // I initialize the element scroll percentage directive.
  • constructor(
  • elementRef: ElementRef,
  • elementScrollPercentage: ElementScrollPercentage
  • ) {
  •  
  • this.elementRef = elementRef;
  • this.elementScrollPercentage = elementScrollPercentage;
  •  
  • this.scrollPercentageEvent = new EventEmitter();
  • this.subscription = null;
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called once when the directive is being unmounted.
  • public ngOnDestroy() : void {
  •  
  • ( this.subscription ) && this.subscription.unsubscribe();
  •  
  • }
  •  
  •  
  • // I get called once after the inputs have been bound for the first time.
  • public ngOnInit() : void {
  •  
  • // The purpose of the directive is to act as the GLUE between the element scroll
  • // service and the host element for this directive. Let's subscribe to the scroll
  • // events and then pipe them into the output event for this directive.
  • this.subscription = this.elementScrollPercentage
  • .getScrollAsStream( this.elementRef.nativeElement )
  • .subscribe(
  • ( percent: number ) : void => {
  •  
  • this.scrollPercentageEvent.next( percent );
  •  
  • }
  • )
  • ;
  •  
  • }
  •  
  • }

Most of this file is just boilerplate. Ultimately, if you can look through the noise, this Directive is doing exactly what the App component is doing. It's calling:

elementScrollPercentage.getScrollAsStream( element ).subscribe( handler )

The only difference is that it's providing the ElementRef.nativeElement as the RxJS stream target rather than allowing the stream to default to the Document. The Directive then takes the values emitted by the RxJS stream and pipes them into the EventEmitter exposed by the Directive. It's this EventEmitter that is then consumed by the App component template.

When I was building this Directive, I was tempted to try and expose the RxJS stream as the Directive's output EventEmitter. But, the timing of the instantiation and initialization left me feeling uncomfortable. It may be something that I explore later on.

That said, the most complicated part of this ended up being the maths of calculating the scroll percentage. The rest was really just plain Angular and RxJS. Now, I'm not very experienced with RxJS; so, to be clear, I am not saying this was "easy" to create - only that it was simple. And fun!



Looking For A New Job?

Ooops, there are no jobs. Post one now for only $29 and own this real estate!

100% of job board revenue is donated to Kiva. Loans that change livesFind out more »

Reader Comments

@All,

This post got me thinking about RxJS and its interaction with Zone.js (or, more specifically, the NgZone). As it turns out, Zone.js isn't quite as simple as I thought it was. And, RxJS isn't Zone-compatible right out of the box. That said, if you need it, Zone.js provides a "patch" file to make RxJS "zone aware":

https://www.bennadel.com/blog/3448-binding-rxjs-observable-sources-outside-of-the-ngzone-in-angular-6-0-2.htm

I am not recommending that this should be used ^^ this is just an exploration to improve my own mental model.

Reply to this Comment

@Chaz,

Very interesting -- what does it do exactly? The documentation doesn't offer up too much information. It seems to just emit a scroll event? What can you use it for?

Reply to this Comment

I see that you still use this way of emiting events values this.scrollPercentageEvent.next( percent ), but should it be this.scrollPercentageEvent.emit( percent );

Reply to this Comment

@Zlati,

Yes, you are correct. I didn't notice that until I was already making the video (and I make the video after I commit and push the code) :) Thanks for pointing it out explicitly - .emit(), not .next().

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.