How to Build an Infinite Scroll Experience With the History Web API

In this tutorial we are going to reinforce our History Web API skills. We’re going to build a UX pattern on the Web which is loved and loathed in equal measure: infinite scrolling.

Infinite scrolling is an interface pattern which loads new content as we reach the end of a given web page. Infinite scrolling can arguably retain users’ engagement when implemented thoughtfully; some of the best examples being on social platforms like Facebook, Twitter, and Pinterest.

It is worth noting, however, that we are taking things up a significant notch from what we built in our previous tutorial, Lovely, Smooth Page Transitions With the History Web API. In this tutorial we will be dealing with the users’ scrolling interaction, which can happen at a very frequent rate. If we are not being careful with our code it will, in turn, impact our website performance detrimentally. Make sure you read the previous tutorials before attempting this one, just to give you a proper understanding of what we’re doing.

Still, if you are thrilled with the idea of a challenge, fasten your seatbelt, get prepared, and let’s get started!

Building the Demo Website

Our site is a static blog. You can build it from plain HTML, or leverage a static site generator such as JekyllMiddleman, or Hexo. Our demo for this tutorial looks as follows:

The website post
Plain old white.

There are a couple things with regard to the HTML structure that require your attention.

<!-- site header -->
<div class="content" id="main">
	<article class="article" id="article-5" data-article-id="5">
		<!-- content -->
	</article>
<!-- site footer -->
  1. As you can see from the above code snippet, the article should be wrapped within an HTML element with a unique ID. You may use a div or a section element, with no restraint in term of naming the id for the element. 
  2. Also, on the article itself, you will need to add a data-article-id attribute which contains the corresponding id number of the article.

Feel free to elaborate in term of the website styles; making it more colorful, engaging, or adding more content.

Load the JavaScript

To begin with, load the following JavaScript libraries in the following order to every page of your blog.

  • jquery.js: the library that we will use for selecting elements, appending new content, adding new class, and performing AJAX requests.
  • history.js: a polyfill which shims the native history API of the browsers.

Our Custom jQuery Plugin

In addition to these two, we will need to load our own JavaScript file where we can write the scripts to perform infinite scrolling. The approach we’ll take is to wrap our JavaScript into a jQuery plugin, rather than writing it straight as we’ve done in the previous tutorials.

We will start off the plugin with jQuery Plugin Boilerplate. This is akin to the HTML5 Boilerplate in that it provides a collection of templates, patterns and best practices on which to build a jQuery plugin.

Download the Boilerplate, place it in the directory of your website where all the JavaScript files reside (such as /assets/js/) and rename the file to “keepscrolling.jquery.js” (this name was inspired by Dory from Finding Nemo and her famous line “Keep Swimming”).

assets/js
??? keepscrolling.jquery.js
??? keepscrolling.jquery.js.map
??? src
    ??? keepscrolling.jquery.js

The plugin will allow us to introduce flexibility with Options or Settings.

Observing the jQuery Plugin Structure

Writing a jQuery plugin requires a slightly different way of thinking, so we will first examine how our jQuery plugin is structured before we add in any code. As you can see below, I’ve split the code into four sections:

;( function( $, window, document, undefined ) {

	"use strict";

	// 1.
	var pluginName = "keepScrolling",
		 defaults = {};

	// 2.
	function Plugin ( element, options ) {

		this.element = element;
		this.settings = $.extend( {}, defaults, options );

		this._defaults = defaults;
		this._name = pluginName;

		this.init();
	}

	// 3.
	$.extend( Plugin.prototype, {
		init: function() {
			console.log( "Plugin initialized" );
		},
	} );

	// 4.
	$.fn[ pluginName ] = function( options ) {
		return this.each( function() {
			if ( !$.data( this, "plugin_" + pluginName ) ) {
				$.data( this, "plugin_" +
					pluginName, new Plugin( this, options ) );
			}
		} );
	};

} )( jQuery, window, document );
  1. In the first section of the code, we specify our plugin name, keepScrolling, with “camel case” according to JavaScript common naming conventions. We also have a variable, defaults, which will contain the plugin default settings.
  2. Next, we have the plugin’s main function, Plugin(). This function can be compared to a “constructor” which, in this case, is to initialize the plugin and merge the default settings with any passed when instantiating the plugin.
  3. The third section is where we will compose our own functions to serve the infinite scrolling functionality. 
  4. Lastly, the fourth section is one which wraps the whole thing into a jQuery plugin.

With all these set, we can now compose our JavaScript. And we start off by defining our plugin’s default options.

The Options

;( function( $, window, document, undefined ) {

	"use strict";

	var pluginName = "keepScrolling",
		 defaults = {
		 	floor: null,
			article: null,
			data: {}
		 };
	...

} )( jQuery, window, document );

As you can see above, we have set three options laid out:

  • floor: an id selector–such as #floor or #footer–which we consider the end of the website or the content. Typically, it would be the site footer.
  • article: a class selector which wraps the article.
  • data: since we do not have access to any external APIs (our website is static) we need to pass a collection of article data such as the article URL, ID, and title in JSON format as this option argument.

The Functions

Here we have the init(). In this function, we will add in a number of functions which have to be run immediately during site initialization. For example, we select the site floor.

$.extend( Plugin.prototype, {

	// The `init()` function.
	init: function() {
		this.siteFloor = $( this.settings.floor ); // select the element set as the site floor.
	},
} );

There are also a few functions that we will run outside the initialization. We add these functions to create and add them after the init function.

The first set of functions we will write are ones we use to retrieve or return a “thing”; anything from a String, an Object, or a Number that will be reusable throughout the other functions in the plugin. These include things to:

Get all articles on the page:

/**
 * Find and returns list of articles on the page.
 * @return {jQuery Object} List of selected articles.
 */
getArticles: function() {
	return $( this.element ).find( this.settings.article );
},

Get the the article address. In WordPress, this is popularly known as the “post slug”.

/**
 * Returns the article Address.
 * @param  {Integer} i The article index.
 * @return {String}    The article address, e.g. `post-two.html`
 */
getArticleAddr: function( i ) {

	var href = window.location.href;
	var root = href.substr( 0, href.lastIndexOf( "/" ) );

	return root + "/" + this.settings.data[ i ].address + ".html";
},

Get the next article id and address to retrieve.

/**
 * Return the "next" article.
 * @return {Object} The `id` and `url` of the next article.
 */
getNextArticle: function() {

	// Select the last article.
	var $last = this.getArticles().last();

	var articlePrevURL;

	/**
	 * This is a simplified way to determine the content ID.
	 *
	 * Herein, we substract the last post ID by `1`.
	 * Ideally, we should be calling call an API endpoint, for example:
	 * https://www.techinasia.com/wp-json/techinasia/2.0/posts/329951/previous/
	 */
	var articleID = $last.data( "article-id" );
	var articlePrevID = parseInt( articleID, 10 ) - 1; // Previous ID

	// Loop into the Option `data`, and get the correspending Address.
	for ( var i = this.settings.data.length - 1; i >= 0; i-- ) {
		if ( this.settings.data[ i ].id === articlePrevID ) {
			articlePrevURL = this.getArticleAddr( i ) ;
		}
	}

	return {
		id: articlePrevID,
		url: articlePrevURL
	};
},

Following are the utility functions of the plugin; a function that is responsible for doing one particular “thing”. These include:

A function which tells whether an element is entering the viewport. We use it mainly to tell if the defined site “floor” is visible within the viewport.

/**
 * Detect whether the target element is visible.
 * http://stackoverflow.com/q/123999/
 *
 * @return {Boolean} `true` if the element in viewport, and `false` if not.
 */
isVisible: function() {
	if ( target instanceof jQuery ) {
		target = target[ 0 ];
	}

	var rect = target.getBoundingClientRect();

	return rect.bottom > 0 &&
		rect.right > 0 &&
		rect.left < ( window.innerWidth || document.documentElement.clientWidth ) &&
		rect.top < ( window.innerHeight || document.documentElement.clientHeight );
}, 

A function which halts a function execution; known as debounce. As mentioned earlier, we will be dealing with user scrolling activity which will happen at very frequent rate. Thus a function within the scroll event will run frequently, following the user scrolling, which will turn the scrolling experience on the site sluggish or laggy.

The above debounce function will reduce the execution frequency. It will wait for the specified time, through the wait parameter, after the user stops scrolling before running the function.

/**
 * Returns a function, that, as long as it continues to be invoked, will not b
 * triggered.
 * The function will be called after it stops being called for N milliseconds.
 * If immediate is passed, trigger the function on the leading edge, instead of
 * the trailing.
 *
 * @link https://davidwalsh.name/function-debounce
 * @link http://underscorejs.org/docs/underscore.html#section-83
 *
 * @param  {Function} func   	  Function to debounce
 * @param  {Integer}  wait      The time in ms before the Function run
 * @param  {Boolean}  immediate
 * @return {Void}
 */
isDebounced: function( func, wait, immediate ) {
	var timeout;

	return function() {

		var context = this,
		args = arguments;

		var later = function() {
			timeout = null;
			if ( !immediate ) {
				func.apply( context, args );
			}
		};

		var callNow = immediate && !timeout;

		clearTimeout( timeout );
		timeout = setTimeout( later, wait );

		if ( callNow ) {
			func.apply( context, args );
		}
	};
}, 

A function which determines whether to proceed or abort an operation.

/**
 * Whether to proceed ( or not to ) fetching a new article.
 * @return {Boolean} [description]
 */
isProceed: function() {

	if ( articleFetching // check if we are currently fetching a new content.
		  || articleEnding // check if no more article to load.
		  || !this.isVisible( this.siteFloor ) // check if the defined "floor" is visible.
		  ) {
		return;
	}

	if ( this.getNextArticle().id <= 0 ) {
		articleEnding = true;
		return;
	}

	return true;
},

We will use the preceding utility function, isProceed(), to examine whether all the conditions are met to proceed pulling out new content. If so, the function which follows will run, fetching the new content and appending it after the last article.

/**
 * Function to fetch and append a new article.
 * @return {Void}
 */
fetch: function() {

	// Shall proceed or not?
	if ( !this.isProceed() ) {
		return;
	}

	var main = this.element;
	var $articleLast = this.getArticles().last();

	$.ajax( {
		url: this.getNextArticle().url,
		type: "GET",
		dataType: "html",
		beforeSend: function() {
			articleFetching = true;
		}
	} )

	/**
	 * When the request is complete and it successly
	 * retrieves the content, we append the content.
	 */
	.done( function( res ) {
		$articleLast
			.after( function() {
				if ( !res ) {
					return;
				}
				return $( res ).find( "#" + main.id ).html();
			} );
	} )

	/**
	 * When the function is complete, whether it `fail` or `done`,
	 * always set the `articleFetching` to false.
	 * It specifies that we are done fetching the new content.
	 */
	.always( function() {
		articleFetching = false;
	} );
},

Add this function within the init. So the function will run as soon as the plugin is initialized, and then retrieve the new content when the conditions are met.

init: function() {
	this.siteFloor = $( this.settings.floor ); // select the element set as the site floor.
	this.fetch();
},

Next, we will add a function to change the browser history with the History Web API. This particular function is rather more complex than our preceding functions. The tricky part here is when exactly we should change the history during the user scroll, the document title, as well as the URL. The following is an illustration to help simplify the idea behind the function:

As you can see from the figure, we have three lines: “roof-line”, “mid-line”, and “floor-line” which illustrate the article position within the viewport. The image shows that the bottom of the first article, as well as the top of the second article, is now at the mid-line. It does not specific the user’s intention as to which article they are looking at; is it the first post or is it the second post? Therefore, we would not change the browser history when two articles are at this position.

We will record the history to the subsequent post when the article top reaches the the “roof-line”, as it takes most of most of the visible part of the viewport.

We record the history of the previous post when its bottom hits the “floor-line”, similarly, as it now takes most of the visible part of the viewport.

These is the “while” code you will need to add in:

init: function() {
	this.roofLine = Math.ceil( window.innerHeight * 0.4 ); // set the roofLine;
	this.siteFloor = $( this.settings.floor );
	this.fetch();
},
/**
 * Change the browser history.
 * @return {Void}
 */
history: function() {

	if ( !window.History.enabled ) {
		return;
	}

	this.getArticles()
		.each( function( index, article ) {

			var scrollTop = $( window ).scrollTop();
			var articleOffset = Math.floor( article.offsetTop - scrollTop );

			if ( articleOffset > this.threshold ) {
				return;
			}

			var articleFloor = ( article.clientHeight - ( this.threshold * 1.4 ) );
				 articleFloor = Math.floor( articleFloor * -1 );

			if ( articleOffset < articleFloor ) {
				return;
			}

			var articleID = $( article ).data( "article-id" );
				 articleID = parseInt( articleID, 10 );

			var articleIndex;
			for ( var i = this.settings.data.length - 1; i >= 0; i-- ) {
				if ( this.settings.data[ i ].id === articleID ) {
					articleIndex = i;
				}
			}

			var articleURL = this.getArticleAddr( articleIndex );

			if ( window.location.href !== articleURL ) {
				var articleTitle = this.settings.data[ articleIndex ].title;
				window.History.pushState( null, articleTitle, articleURL );
			}
		}.bind( this ) );
},

Lastly, we create a function that will run the fetch() and the history() when the user is scrolling the page. To do so we create a new function called scroller(), and run it on the plugin initialization.

/**
 * Functions to run during the scroll.
 * @return {Void}
 */
scroller: function() {
	window.addEventListener( "scroll", this.isDebounced( function() {
		this.fetch();
		this.history();
	}, 300 ).bind( this ), false );
}

And as you can see above, we debounce these as both performing AJAX and changing the browser history are an expensive operation.

Adding a Content Placeholder

This is optional, but recommended in order to respect the user experience. The placeholder provides feedback to the user, signalling that a new article is on its way.

First, we create the placeholder template. Commonly this kind of template is put after the site footer.

<script type="text/template" id="tmpl-placeholder">
	<div class="placeholder placeholder--article" id="placeholder-article">
		<div class="container">
			<div class="placeholder__header animated">
				<h1></h1>
			</div>
			<div>
				<p class="placeholder__p-1 animated"></p>
				<p class="placeholder__p-2 animated"></p>
			</div>
		</div>
	</div>
</script>

Keep in mind that the placeholder article, its structure, should resemble the real content of your blog. Adjust the HTML structure accordingly.

The placeholder styles is simpler. It comprises all the basic styles to lay it out like the actual article, the animation @keyframe that simulates the loading sense, and the style to toggle the visibility (the placeholder is initially hidden; it is shown only when the parent element has the fetching class).

.placeholder {
	color: @gray-light;
	padding-top: 60px;
	padding-bottom: 60px;
	border-top: 6px solid @gray-lighter;
	display: none;
	.fetching & {
		display: block;
	}
	p {
		display: block;
		height: 20px;
		background: @gray-light;
	}
	&__header {
		animation-delay:.1s;
		h1 {
			height: 30px;
			background-color: @gray-light;
		}
	}
	&__p-1 {
		animation-delay:.2s;
		width: 80%;
	}
	&__p-2 {
		animation-delay:.3s;
		width: 70%;
	}
}

Then we update a few lines to show the placeholder during the AJAX request, as follows.

/**
 * Initialize.
 * @return {Void}
 */
init: function() {

	this.roofLine = Math.ceil( window.innerHeight * 0.4 );
	this.siteFloor = $( this.settings.floor );

	this.addPlaceholder();

	this.fetch();
	this.scroller();
},

/**
 * Append the addPlaceholder.
 * Placeholder is used to indicate a new post is being loaded.
 * @return {Void}
 */
addPlaceholder: function() {

	var tmplPlaceholder = document.getElementById( "tmpl-placeholder" );
		 tmplPlaceholder = tmplPlaceholder.innerHTML;

		$( this.element ).append( tmplPlaceholder );
},

/**
 * Function to fetch and append a new article.
 * @return {Void}
 */
fetch: function() {
	...

	// Select the element wrapping the article.
	var main = this.element;

	$.ajax( {

		...

		beforeSend: function() {

			...
			// Add the 'fetching' class.
			$( main ).addClass( function() {
				return "fetching";
			} );
		}
	} )

	...

	.always( function() {

		...
		// Remove the 'fetching' class.
		$( main ).removeClass( function() {
			return "fetching";
		} );
	} );

That’s how we handle the placeholder! Our plugin is complete, and it is the time to deploy the plugin.

Deployment

Deploying the plugin is quite straightforward. We designate the element which wraps our blog article, and call our plugin with the options set, as follows.

$( document ).ready( function() {
	$( "#main" ).keepScrolling({
		floor: "#footer",
		article: ".article",
		data : [{
			"id": 1,
			"address": "post-one",
			"title": "Post One"
		}, {
			"id": 2,
			"address": "post-two",
			"title": "Post Two"
		}, {
			"id": 3,
			"address": "post-three",
			"title": "Post Three"
		}, {
			"id": 4,
			"address": "post-four",
			"title": "Post Four"
		}, {
			"id": 5,
			"address": "post-five",
			"title": "Post Five"
		}]
	});
} );

The infinite scroll should now be functioning.

Caveat: the Back Button

In this tutorial, we have built an infinite scroll experience; something that we commonly seen on news sites like Quartz, TechInAsia and in many mobile applications.

Although it is proven to be an effective way to retain user engagement, it also has a downside: it breaks the “Back” button in the browser. When you click on the button, it won’t always accurately scroll you back to the previous visited content or page.

Websites address this issue in various ways; Quartz, for example, will redirect you to the referred URL; the URL you’ve previously visited, but not the one recorded through the Web History API. TechInAsia will simply take you back to the homepage.

Wrapping Up

This tutorial is lengthy, covering many things! Some of them are easy to understand, while some pieces may be not so easy to digest. To help, I’ve put together a list of references as a supplement to this article.

Lastly, look into the full source code and view the demo!

{excerpt}
Read More

Leave a Reply

Your email address will not be published. Required fields are marked *