AngularJS Scroll to Top for New Pages on Single-Page-App

20 Oct

As promised in my last post, I’m going to try to keep updating with things I learn on my trek down the long road to Angular-ville. As far as I could tell, there isn’t much info about the issue I was facing online, so either I’m doing something wrong (and if I am, for heaven’s sake, don’t just sit there; please tell me!) or there aren’t as many people concerned with what I felt was a noticeable UX flaw in single page apps (SPA). To try to make clear my explanation of the issue, I’ll just walk through some steps that an end-user might experience with an SPA and how the page responds by default:

  1. User loads the home page. Angular fetches the template and inserts it into the ng-view.
  2. User scrolls down the page a bit, say 600px.
  3. User clicks a link to another page. Angular fetches the requested page’s template and inserts it into the ng-view, but the browser stays scrolled 600px, so the user doesn’t actually see the content they loaded.
  4. User clicks the browser’s back button and sees again the content they were at before clicking the link.

The quick, dirty, incomplete “solution” to this might be to simply detect when the route is changing and auto-scroll to the top of the window in all cases. However, if we did that, upon clicking back in step 4 above, the browser would scroll back to the top of the page instead of landing the user back where they came from. So what we need is a real solution that will auto-scroll to the top of the page when a *new* page is loaded, but allow the browser to restore the scroll position for any page that was navigated away from.

My first thought was to just store the most recently viewed page and detect a new page load, compare the new page to the most recent page, and if it’s different, scroll to the top. This doesn’t work, however, when the user clicks back more than one page.

My second thought was to just store the history of pages viewed and only scroll to top if the newly loaded page is not in that history. This, however, doesn’t take into consideration that a user may go back in the browser then go forward again, and if they go forward to a page they just came from, we don’t want that scrolling away on them.

No, what we need is to store the history of all pages loaded both backward and forward. Below is the solution I implemented which stores the current page, a stack for all backward pages, and another stack for all forward pages. When the location changes, the new URL is compared to the top of the backward stack and the forward stack. If it’s either one, the matching element is popped and we leave the scroll position where it is. If it’s neither, the user must be linking forward into a new path, so the forward stack is emptied.

Overall the solution works pretty nicely. The only less-than-usual thing to be aware of is that if you click a link that takes you backward or forward one page (i.e., instead of clicking the back button back to the homepage you click a “home” link), it will look to the code like you used your back button and leave the scroll position unchanged, but I actually like that behavior. It makes the whole SPA thing feel much more solid.

[GitHUB »]

// first thing's first; initialize the app
var app = angular.module('yourAppName', ['ngRoute']);// '

// use $rootScope since this is for application-wide behavior _bwdFwdDetect($rootScope, $location) {
  $rootScope.path = $location.path();// stores only the current page being viewed
  $rootScope.pathBwd = [];// stores up to the last page viewed before the current one
  $rootScope.pathFwd = [];// stores starting with the page viewed before coming back to the current one

  // detect that the location is about to change
  $rootScope.$on('$locationChangeStart', function(event) {
    var newPath = $location.path();// take note of the new path (using path instead of url is good)

    // grab the last backward page for comparison
    var bwd = !$rootScope.pathBwd.length ? null :
      $rootScope.pathBwd[$rootScope.pathBwd.length - 1];

    // grab the last forward page for comparison
    var fwd = !$rootScope.pathFwd.length ? null :
      $rootScope.pathFwd[$rootScope.pathFwd.length - 1];

    // if the new page is the last backward page, assume the user went "back"
    if (bwd == newPath) {
      // it's no longer the last backward page, so remove it

      // and push into the forward stack the page we're just leaving to go backward from
    // the new page is the last page we came backward from, assume the user went "forward"
    else if (fwd == newPath) {
      // it's no longer the last forward page, so remove it

      // and push into the backward stack the page we're going forward away from
    // we didn't go back or forward; assume it's a new forward trail being started
    else if ($rootScope.path != newPath) {
      // remember the page we're leaving as our last backward page

      // empty out our forward stack since we're now starting a wholly new forward path
      $rootScope.pathFwd = [];

      // this is what it's all about; scroll to the top of the freshly-loaded page
      $('html,body').animate({ scrollTop: 0 }, 'slow');

    // now that we're done with our comparisons, we can remember our new current page
    $rootScope.path = newPath;

Of course this whole “new page detection” thing I’m doing doesn’t only have to be used to scroll to the top of the page. There could be any number of things you might want to do depending on whether the route is changing backward or forward or down a new path.



Tags: , ,

Leave a Reply