user1012032
user1012032

Reputation:

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.

Upvotes: 48

Views: 52936

Answers (15)

Lee Brindley
Lee Brindley

Reputation: 6512

For those of you that went with emp's answer, but were using angular ui-router >= version 1.0.0 (current 1.0.3), please see his directive rewritten, using ui-routers new transitions.

HTML

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

Angular Directive

angular.module("app")
    .directive("keepScrollPos", function($transitions, $state, $window, $timeout, $location, $anchorScroll) {

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

        return {
            link: function(scope, element, attrs) {


                $transitions.onStart({ }, function( trans ) {

                    // store scroll position for the current view
                    if (trans.from().name) {
                        scrollPosCache[trans.from().templateUrl] = [ $window.pageXOffset, $window.pageYOffset ];
                    }

                    trans.promise.finally(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[trans.to().templateUrl] || [ 0, 0 ];
                            $timeout(function() {
                                $window.scrollTo(prevScrollPos[0], prevScrollPos[1]);
                            }, 200);
                        }
                    });
                });
            }
        }
    });

Upvotes: 0

nice ass
nice ass

Reputation: 16719

I made a version that works with any overflowed element, not just the document body:

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

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

  return {
    restrict : 'A',
    link: function($scope, elements, attrs){

      $scope.$on('$routeChangeStart', function() {

        // store scroll position for the current view
        if($route.current)
          cache[$route.current.loadedTemplateUrl + ':' + attrs.keepScrollPos] = [elements[0].scrollLeft, elements[0].scrollTop];              

      });

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

        // else get previous scroll position and apply it if it exists
        var pos = cache[$route.current.loadedTemplateUrl + ':' + attrs.keepScrollPos];
        if(!pos)
          return;

        $timeout(function(){                  
          elements[0].scrollLeft = pos[0];
          elements[0].scrollTop = pos[1];            
        }, 0);

      });

    }
  }

})

Use it like:

<div keep-scroll-pos="some-identifier"> ... </div>

Upvotes: 1

Tadej Krevh
Tadej Krevh

Reputation: 442

Great solution by @br2000.

However unfortunately my page that I was scrolling back to, was still loading data from backend to a long list when the directive tried to restore the position.

So obviously it failed to restore the scroll position. I solved it by using $interval instead of $timeout and gave it 20 repetitions with 300ms timeout. I stored the promise returned from $interval and then checked inside the $interval function if current position is now the same as stored position and if yes, I call a scope method that cancels the $interval - $interval.cancel(promise).

Additionally, initially my pageYOffset and pageXOffset were always 0, because overflow-x: hidden was applied to the root div in the DOM. I solved it by wrapping the root div inside another div on which I then placed this directive.

Upvotes: 0

Akash Pal
Akash Pal

Reputation: 126

I am using a custom solution in my project.

Step 1: Obtain the position of the click on the list and save it in local storage.

var position = document.body.scrollTop;
localStorage.setItem("scrollPosition",position);

Step 2:In the detail view set a global variable backFromDetailView to true.

backFromDetailView = true;

Step 3:On returning from the detail view page to the list. All the content is reloaded from server again up to the scrolled position.

For this purpose bind a function in the html using the following line:

And the controller contains the function:

$scope.goto = function (){
    if(backFromDetailView){
         window.scrollTo(0, localStorage.getItem("scrollPosition"));
     }
}

Some disadvantages of this technique:

  1. All the content including additional content is reloaded again.

  2. In iOS, a black screen appear before scrolling to the appropriate position.

Upvotes: 0

Hashbrown
Hashbrown

Reputation: 13023

Unlike the other answers I wanted to remember more than just scrolls, namely input field values.

Not only that, but a lot of them assumed

  • you only wanted to remember one scrolling element (maybe you have panes or some other app-like display),
  • you have body as your scrolling element (e.g. what if you're using angular snap?),
  • or your scrolling element isn't replaced by angular (i.e. it's outside the ng-view).
<body> <!-- doesn't scroll -->
    <div snap-drawers>..</div>

    <div snap-content="" history="scrollTop"> <!-- the scrolling div-->
        <header>...</header>

        <div ng-view>
            <input name="email" history="val"> <!-- tag with value we want remembered -->

            <div history="scrollLeft" history-watch="scroll" id="evenHorizontalScroll"><!--
                custom value we want remembered.
                NB it must have an id to be identified after angular
                removes it from the DOM between views,
                and since it is not a recognised default we can tell my
                directive the jquery event function what to watch
            --></div>
        </div>
    </div>
</body>

I've written a[n unfortunately much longer] shared-scope directive that takes care of these problems.

.directive('history', function($compile, $rootScope, $location) {
    return {
        restrict : 'A',
        replace : false,
        scope : false,

        controller : function($scope, $timeout) {
            //holds all the visited views
            var states = new Object();
            //the current view
            var state = null;
            //how many names have been generated where the element itself was used
            var generated = 0;

            //logs events if allowed
            function debug(from) {
                //comment this to watch it working
                //return;

                console.log('StateHistory: ' + from);
                if (from == 'went')
                    console.log(state);
            }

            //applies the remembered state
            function apply() {
                var element;
                //for each item remembered in the state
                for (var query in state) {
                    //use the element directly, otherwise search for it
                    (state[query].element || $(query))
                        //use the appropriate function
                        [state[query].property](
                            //and set the value
                            state[query].value
                        )
                    ;
                    debug('applying:' + query + ':' + state[query].value);
                }

                //start recording what the user does from this point onward
                $scope.ignore = false;
            }

            //generates a reference we can use as a map key
            $scope.generateRef = function() {
                return '' + (++generated);
            };

            //views changed
            $scope.went = function() {
                debug('went');

                //set the current state
                state = states[$location.path()];

                //if we dont remember the state of the page for this view
                if (!state)
                    //get recording!
                    state = states[$location.path()] = new Object();

                //apply the state after other directives
                //(like anchorScroll + autoscroll) have done their thing
                $timeout(apply);
            };

            //one of the elements we're watching has changed
            $scope.changed = function(name, element, property, useObject) {
                //if we're not meant to be watching right now
                //i.e. if the user isnt the one changing it
                if ($scope.ignore) {
                    debug('ignored');
                    return;
                }

                //if we havent recorded anything for this here yet
                if (!state[name]) {
                    //start recording
                    state[name] = {property:property};

                    //and remember to leave behind a reference if the name isn't
                    //good enough (was generated)
                    if (useObject)
                        state[name].element = element;
                }

                //use the requested function to pull the value
                state[name].value = element[property]();

                debug('changed:' + name + ':' + state[name].value);
            };

            //initial view
            $scope.went();

            //subsequent views
            $rootScope.$on('$routeChangeSuccess', $scope.went);
            $rootScope.$on('$routeChangeError', $scope.went);

            $rootScope.$on('$routeChangeStart', function() {
                debug('ignoring');
                $scope.ignore = true;
            });
        },

        link: function (scope, element, attrs) {
            //jquery event function name
            var watch = attrs.historyWatch;
            //if not set, use these defaults
            if (!watch) {
                switch (attrs.history) {
                case 'val':
                    watch = 'change';
                    break;
                case 'scrollTop':
                    watch = 'scroll';
                    break;
                default:
                    watch = attrs.history;
                }
            }

            //the css selector to re-find the element on view change
            var query = null;
            //the reference to the state remembered
            var name;

            //try using the id
            if (attrs.id)
                name = query = '#' + attrs.id;
            //try using the form name
            else if (attrs.name)
                name = query = '[name=' + attrs.name + ']';
            //otherwise we'll need to just reference the element directly
            //NB should only be used for elements not swapped out by angular on view change,
            //ie nothing within the view. Eg the view itself, to remember scrolling?
            else
                name = scope.generateRef();

            //jquery value function name
            var property = attrs.history;

            //watch this element from here on out
            element.on(watch, function() {
                scope.changed(name, element, property, !query);
            });
        }
    };
})

Upvotes: 0

Thilak Raj
Thilak Raj

Reputation: 920

this may solve your problem, it works for me $httpProvider.defaults.cache = true;

Upvotes: 0

Radu
Radu

Reputation: 133

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);
        }
      });
    }
  })

Upvotes: 3

JumpLink
JumpLink

Reputation: 507

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?

Upvotes: 0

emp
emp

Reputation: 5065

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;
            });
        }
    };
})

Upvotes: 9

br2000
br2000

Reputation: 969

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>

Upvotes: 17

Grzegorz Judas
Grzegorz Judas

Reputation: 469

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.

Upvotes: 0

James Gwee
James Gwee

Reputation: 341

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
    });

Upvotes: 1

Anton
Anton

Reputation: 7719

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());
        });
    }
});

Upvotes: 5

Joseph Oster
Joseph Oster

Reputation: 5545

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'.

Upvotes: 31

asgoth
asgoth

Reputation: 35829

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.

Upvotes: 6

Related Questions