Retain scroll position on route change in AngularJS?

Sample app: http://angular.github.com/angular-phonecat/step-11/app/#/phones

If you choose the last phone "Motorola charm" it will show you the details of the phone. When you navigate back with on your browser it reloads the data and scrolling is at the top.

What is the best way to automatically scroll to where is was left when navigatin back? And also, why does angular reloads the data?

I have the same "angular-phonecat" sample on my computer and I have added an infinite scroll which loads more data as you scroll. So I really dont want the user to reload 50+ items again or scrolling down for 30 seconds.

I have a fiddle here that shows how to restore scroll position in the list view after a detail view; not encapsulated in a directive yet, working on that...

http://jsfiddle.net/BkXyQ/6/

$scope.scrollPos = {}; // scroll position of each view

$(window).on('scroll', function() {
    if ($scope.okSaveScroll) { // false between $routeChangeStart and $routeChangeSuccess
        $scope.scrollPos[$location.path()] = $(window).scrollTop();
        //console.log($scope.scrollPos);
    }
});

$scope.scrollClear = function(path) {
    $scope.scrollPos[path] = 0;
}

$scope.$on('$routeChangeStart', function() {
    $scope.okSaveScroll = false;
});

$scope.$on('$routeChangeSuccess', function() {
    $timeout(function() { // wait for DOM, then restore scroll position
        $(window).scrollTop($scope.scrollPos[$location.path()] ? $scope.scrollPos[$location.path()] : 0);
        $scope.okSaveScroll = true;
    }, 0);
});

The fiddle also shows fetching the list once, outside of 'ListCtrl'.

I have used the solution of @Joseph Oster in order to create a directive. I have also taken the liberty to update the answer to use:

  • $locationChangeStart
  • $locationChangeSuccess

as the other events are obsolete.

Fiddle is here: http://jsfiddle.net/empie/p5pn3rvL/

Directive source:

angular.module('myapp', ['ngRoute'])
    .directive('autoScroll', function ($document, $timeout, $location) {
    return {
        restrict: 'A',
        link: function (scope, element, attrs) {
            scope.okSaveScroll = true;

            scope.scrollPos = {};

            $document.bind('scroll', function () {
                if (scope.okSaveScroll) {
                    scope.scrollPos[$location.path()] = $(window).scrollTop();
                }
            });

            scope.scrollClear = function (path) {
                scope.scrollPos[path] = 0;
            };

            scope.$on('$locationChangeSuccess', function (route) {
                $timeout(function () {
                    $(window).scrollTop(scope.scrollPos[$location.path()] ? scope.scrollPos[$location.path()] : 0);
                    scope.okSaveScroll = true;
                }, 0);
            });

            scope.$on('$locationChangeStart', function (event) {
                scope.okSaveScroll = false;
            });
        }
    };
})

i created a directive that works on the window scroll ( it could updated to work on any element though )

html usage

<div ng-keep-scroll="service.scrollY">
<!-- list of scrolling things here -->
</div>

where "service.scrollY" MUST be a variable within a service. Services retain their state and values, controllers are recreated every time they load and clear their values so you cant use them to store persistent data. the controller has a scope variable pointing to the service.

directive js

app.directive('ngKeepScroll', function ($timeout) {
    return function (scope, element, attrs) {

        //load scroll position after everything has rendered
        $timeout(function () {
            var scrollY = parseInt(scope.$eval(attrs.ngKeepScroll));
            $(window).scrollTop(scrollY ? scrollY : 0);
        }, 0);

        //save scroll position on change
        scope.$on("$routeChangeStart", function () {
            scope.$eval(attrs.ngKeepScroll + " = " + $(window).scrollTop());
        });
    }
});

Below is another version of keep-scroll-pos directive. This version

  • Remembers scroll position of each templateUrl of your $routeProvider definition.

  • Respects hash tags, e.g., #/home#section-2, will scroll to #section-2 not previous scroll position.

  • Is easy to use, as it is self-contained, and stores scroll positions internally.

Example of html use:

<div ng-view keep-scroll-pos></div>

The code for keepScrollPos directive is below:

"use strict";

angular.module("myApp.directives", [])

.directive("keepScrollPos", function($route, $window, $timeout, $location, $anchorScroll) {

    // cache scroll position of each route's templateUrl
    var scrollPosCache = {};

    // compile function
    return function(scope, element, attrs) {

        scope.$on('$routeChangeStart', function() {
            // store scroll position for the current view
            if ($route.current) {
                scrollPosCache[$route.current.loadedTemplateUrl] = [ $window.pageXOffset, $window.pageYOffset ];
            }
        });

        scope.$on('$routeChangeSuccess', function() {
            // if hash is specified explicitly, it trumps previously stored scroll position
            if ($location.hash()) {
                $anchorScroll();

            // else get previous scroll position; if none, scroll to the top of the page
            } else {
                var prevScrollPos = scrollPosCache[$route.current.loadedTemplateUrl] || [ 0, 0 ];
                $timeout(function() {
                    $window.scrollTo(prevScrollPos[0], prevScrollPos[1]);
                }, 0);
            }
        });
    }
});

To disregard previously stored scroll position, and to force to scroll to the top, use pseudo hash tag: #top, e.g., href="#/home#top".

Alternatively, if you prefer to just always scroll to the top, use built-in ng-view autoscroll option:

<div ng-view autoscroll></div>

I haven't used it before, but angular has a $anchorScroll service. As to reloading the data, you could cache it using $cacheFactory, or store the data on a higher scope.

If your page requires fetching of data to display, you may have to use $routeChangeSuccess and delay the scrolling function call.

    scope.$on("$routeChangeSuccess", function() {
        $timeout(function () {
            var scrollY = parseInt(scope.$eval(attrs.ngKeepScroll));
            $(window).scrollTop(scrollY ? scrollY : 0);
        }, 1000); // delay by 1 sec
    });

I've found another simple way to solve this issue:

var scrollValue = $(window).scrollTop();

$rootScope.$on("$routeChangeStart", function() {
    scrollValue = $(window).scrollTop();
});

$rootScope.$on('$routeChangeSuccess', function(newRoute, oldRoute) {
    setTimeout(function() { $(window).scrollTop(scrollValue); }, 0);
});

Just put it in .run().

This way, setting timeout value to 0 it still works, but runs after the page is rendered (without timeout function it runs before the content (i.e. template or data loading) is rendered, making the function useless.

If you fetch data from some API, you can wrap the timeout in a function in $rootScope and run it after successful request.

You need to reset the scroll position on each route change. Use this in your main AppController:

  $scope.$on("$routeChangeSuccess", function () {
    $anchorScroll();
  });

Or if you are using ui-route:

  $scope.$on("$stateChangeSuccess", function () {
    $anchorScroll();
  });

For more infomation see In AngularJS, how do I add a $watch on the URL hash?

Based on the great answer from br2000, I updated the directive code to work with ui-router. For states with same name but different params I serialize the $state.params object to make up a unique key in the scrollPosCache object.

.directive("keepScrollPos", function($state, $window, $timeout, $location, $anchorScroll) {

    // cache scroll position of each route's templateUrl
    var scrollPosCache = {};

    // compile function
    return function(scope, element, attrs) {

      scope.$on('$stateChangeStart', function() {
        // store scroll position for the current view
        if ($state.current.name) {
          scrollPosCache[$state.current.name + JSON.stringify($state.params)] = [ $window.pageXOffset, $window.pageYOffset ];
        }
      });

      scope.$on('$stateChangeSuccess', function() {
        // if hash is specified explicitly, it trumps previously stored scroll position
        if ($location.hash()) {
          $anchorScroll();

          // else get previous scroll position; if none, scroll to the top of the page
        } else {
          var prevScrollPos = scrollPosCache[$state.current.name + JSON.stringify($state.params)] || [ 0, 0 ];
          $timeout(function() {
            $window.scrollTo(prevScrollPos[0], prevScrollPos[1]);
          }, 0);
        }
      });
    }
  })