Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at dev.Objective() 2015 (Bloomington, MN) with: Joseph Lamoree
Ben Nadel at dev.Objective() 2015 (Bloomington, MN) with: Joseph Lamoree@jlamoree )

Maintaining Scroll Offsets When Adding Content Above The User's Viewport In Angular 9.0.0-rc.2

By Ben Nadel on

Last week, inspired by a wonderful GitHub user experience (UX), I took a look at maintaining scroll offsets when collapsing sticky elements in Angular. This week, I wanted to follow-up with the somewhat-related concept of maintaining the scroll offset of the user's browser when new content is added above the user's viewport in Angular 9.0.0-rc.2. This way, when new content is added, the user's viewing experience isn't unexpectedly "scrolled up". Instead, the viewport is programmatically scrolled down in order to keep the previous content "in view" for the user.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

To understand the problematic user experience (UX) that is created when new content is added above the user's viewport, consider the following illustration that shows where the user's current reading experience (red outline) ends-up:

When content is added above the viewport, the user's experience is scrolled-up unintentionally.

As you can see, when new content is added to the top of the content container - above the user's viewport - the user's current reading experience is actually pushed down below the fold of the user's browser. Or, to put it another way, the user's viewport was implicitly "scrolled up" by the change in the content height.

To create a more pleasant user experience (UX), what we'd like to do is add the new content while simultaneously scrolling the user's viewport down so that the user maintains the same reading experience. To do this, we can follow the general algorithm:

  • Step 1: Before adding the new content, get the current scroll height and offset of the user's viewport.

  • Step 2: Add the new content to the content container and then force Angular to reconcile the updated View Model with the Document Object Model (DOM).

  • Step 3: Get the new scroll height and offset of the user's viewport.

  • Step 4: If necessary, scroll the user down by an offset that adjusts for the height of the new content (ie, the delta in pre-and-post scroll height).

Now, one thing that is really cool is that, while working on this demo, I discovered that Chrome and Firefox actually do this for you (at least on my Mac)! Meaning, when you add content above the user's viewport, Chrome and Firefox automatically scroll the viewport down in order to maintain the same user experience.

Of course, Safari and Microsoft Edge do not do this. As such, we still have an opportunity to step in and programmatically create a better user experience.

To explore this concept, I've created a demo News Feed. Every 500ms, a new news item is added to the top of the content container, forcing all the content below it to shift down. The demo can be run unconstrained, using the Window as the "viewport"; or, it can be run within an overflow:auto container, which uses the overflow container as the "viewport".

Depending on which container we are using, methods for getting and setting the various scroll properties are slightly different. As such, I've encapsulated the logic in some private methods like .getContainerScrollTop() and .setScrollTop(). This way, we can keep the same algorithm, but change the data-access based on the type of demo.

In the following App component, the meat of the logic takes place in .addNewsItem(), which walks through the algorithm steps as outlined above:

// Import the core angular services.
import { ChangeDetectorRef } from "@angular/core";
import { Component } from "@angular/core";
import { ElementRef } from "@angular/core";
import { ViewChild } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

interface NewsItem {
	hook: string;
	content: string;
}

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p class="controls">
			<strong>Use</strong>:
			<a (click)="use( 'window' )">Window</a> or
			<a (click)="use( 'container' )">Overflow Container</a>
		</p>

		<div
			#viewportRef
			class="viewport"
			[class.is-constrained]="( demoType === 'container' )">

			<ng-template ngFor let-item [ngForOf]="newsItems">

				<div class="news-item" [style.padding-bottom.px]="item.hook.length">
					<strong class="news-item__hook">
						{{ item.hook }}
					</strong>
					<span class="news-item__content">
						{{ item.content }}
					</span>
				</div>

			</ng-template>

		</div>
	`
})
export class AppComponent {

	public demoType: "window" | "container";
	public newsItems: NewsItem[];

	@ViewChild( "viewportRef" )
	public viewportRef!: ElementRef;

	private changeDetectionRef: ChangeDetectorRef;

	// I initialize the app component.
	constructor( changeDetectionRef: ChangeDetectorRef ) {

		this.changeDetectionRef = changeDetectionRef;
		this.demoType = "window";
		this.newsItems = [];	

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I get called once, after all of the component inputs have been bound.
	public ngOnInit() : void {

		window.setInterval(
			() => {

				this.addNewsItem();

			},
			500
		);	

	}


	// I determine which container will be used to constrain the demo.
	public use( newDemoType: "window" | "container" ) : void {

		this.demoType = newDemoType;
		// Reset the news feed when the demo type changes.
		this.newsItems = [];

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I add a new news item, using the abstracted DOM manipulation methods.
	private addNewsItem() : void {

		// NOTE: Depending on the type of demo, the constrained container is different.
		// And, the different containers use slightly different DOM methods for getting
		// and setting the current scroll heights and offsets. As such, the getters and
		// setters have been abstracted into other private methods. That said, the
		// algorithm is the same in both cases:
		// --
		// STEP 1: Get current scroll conditions.
		// STEP 2: Add new content and force DOM reconciliation.
		// STEP 3: Check new scroll conditions.
		// STEP 4: Update scroll settings to account for new content.
		// --

		// STEP ONE: Get the current scroll conditions for the container.
		var preScrollHeight = this.getContainerScrollHeight();
		var preScrollOffset = this.getContainerScrollTop();

		// STEP TWO: Add the content that will change the scroll-height of the container.
		this.newsItems.unshift({
			hook: this.getRandomHook(),
			content: "Something something something something..."
		});

		// Force Angular to reconcile the DOM with the View Model. This call tells
		// Angular to trigger a change-detection so that our new news item will be
		// rendered to the browser, allowing us to inspect the scroll changes.
		this.changeDetectionRef.detectChanges();

		// STEP THREE: Now that Angular has rendered the changes in the browser, we have
		// to examine the state of the browser to see how the changes were handled.
		var postScrollOffset = this.getContainerScrollTop();

		// In modern Chrome and Firefox, the scroll-offset will be HANDLED AUTOMATICALLY.
		// Meaning, Chrome and Firefox will UPDATE THE SCROLL OFFSET in order to maintain
		// the "current" experience for the user (how great is that?!?!). However, Safari
		// does not do this. As such, if the pre/post scroll offsets are the same, we
		// have to step-in and manually SCROLL THE USER DOWN to compensate for the change
		// in document height.
		if (
			preScrollOffset &&
			postScrollOffset &&
			( preScrollOffset === postScrollOffset ) // The browser did NOT help us.
			) {

			// STEP FOUR: The browser didn't adjust the scroll offset automatically. As
			// such, we have to step in and scroll the user down imperatively.
			var postScrollHeight = this.getContainerScrollHeight();
			var deltaHeight = ( postScrollHeight - preScrollHeight );

			this.setScrollTop( postScrollOffset, deltaHeight );

			console.warn( "Scrolling by", deltaHeight, "px" );

		}

	}


	// I get the current scroll height of the container.
	private getContainerScrollHeight() : number {

		if ( this.demoType === "container" ) {

			return( this.viewportRef.nativeElement.scrollHeight );

		}

		// For the window, the scroll height is a bit more complicated. In order to get
		// cross-browser compatibility, we need to check a few different elements.
		// --
		// NOTE: I am not entirely sure how necessary this is. I am getting this
		// information from: https://javascript.info/size-and-scroll-window . It's
		// possible that this is only needed for older browser; and, that modern browsers
		// have more consistent support???
		return(
			Math.max(
				document.body.scrollHeight,
				document.body.offsetHeight,
				document.body.clientHeight,
				document.documentElement.scrollHeight,
				document.documentElement.offsetHeight,
				document.documentElement.clientHeight
			)
		);

	}


	// I get the current scroll offset of the container.
	private getContainerScrollTop() : number {

		if ( this.demoType === "container" ) {

			return( this.viewportRef.nativeElement.scrollTop );

		}

		return( window.pageYOffset );

	}


	// I get a random hook for a news item.
	private getRandomHook() : string {

		var hooks = [
			"Breaking News!",
			"Extra Extra!",
			"New Study Turns Industry On Its Head!",
			"You Won't Believe This!",
			"News At 11!",
			"Scandalous Behavior!",
			"Florida Man likes to garden in the nude!"
		];

		var index = Math.floor( Math.random() * hooks.length );

		return( hooks[ index ] );

	}


	// I update the container to use the new scroll offset.
	private setScrollTop(
		currentScrollTop: number,
		delta: number
		) : void {

		if ( this.demoType === "container" ) {

			this.viewportRef.nativeElement.scrollTop = ( currentScrollTop + delta );

		}

		window.scrollBy( 0, delta );

	}

}

As I mentioned earlier, Chrome and Firefox automatically create a good user experience. We can tell when this happens if the "scroll offset" automatically changes after the new content is added. In browsers like Safari, the "scroll offset" remains the same because the browser doesn't do anything. However, in Chrome and Firefox, the "scroll offset" is different after the new content is injected because these browsers are scrolling the user down in order to account of the new content.

Because of this, the demo is really only meaningful in other browsers like Safari and Edge. And, when it comes to both of these browser, I couldn't quite get the demo to be seamless. When we use the "window" as the constrained container, Safari shows a "flash" of the new content before we have time to scroll the user down. However, if we use the overflow container as the constraint, Safari works well:

Programmatically scrolling the user down to adjust for new content added above the viewport in Angular 9.0.0-rc.2.

As you can see (it is easier to see in the video), when we use the "window" as the content container, the content flash is a bit janky. However, when we use the overflow container as the constraint, the demo runs smooth like butter.

When I started this demo, I expected to have to do something programmatically in all browser. However, I was delighted to find out that Chrome and Firefox inherently create a great user experience (UX). As such, this demo only applies to a subset of browsers like Safari and Edge. That said, even in those browsers, we can still create a better user experience by programmatically scrolling the user down in order to account for new content in Angular 9.0.0-rc.2.



Reader Comments

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.