Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Madeline Johnsen
Ben Nadel at cf.Objective() 2014 (Bloomington, MN) with: Madeline Johnsen@madjohnsen )

Defining Post-Route-Change Scroll Behavior Using $location in AngularJS

By Ben Nadel on

Over the weekend, I described my evolving thoughts on the use of $location in a route-driven AngularJS application. And, while I think that the post was accurate from a technical standpoint, I believe it failed to capture the actual workflow of $route / $location consumption in a complex user interface (UI). As such, I wanted to do a quick follow-up that might better articulate how I think the route and the overall $location can work together to render a View.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

In the previous post, I talked about how I use the route to determine which view is rendered (ie, the location of the user within the landscape of the application); and, how I use the $location to [help] define how the selected view should behave once it's rendered. To demonstrate this, I thought it would be useful to actually have a changing view. So, for this particular demo, I have two views that are mutually exclusive. Using links, the user can select one at a time - the route will determine which view is rendered and the location query string will determine the scroll-offset of the view once it is rendered.

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Defining Post-Route-Change Scroll Behavior Using $location in AngularJS
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body ng-controller="AppController">
  •  
  • <h1>
  • Defining Post-Route-Change Scroll Behavior Using $location in AngularJS
  • </h1>
  •  
  • <!--
  • BEGIN: Nav.
  •  
  • In these navigational links, the path is mapped on to the view that should
  • be rendered (ie, "a" vs. "b") and the query string is then used to determine
  • state transformation after the view is rendered.
  • -->
  • <div class="nav">
  •  
  • <div
  • class="section"
  • ng-class="{ active: ( section == 'a' ) }">
  •  
  • <strong>Section A:</strong>
  •  
  • <a href="#/a">Normal</a>
  • <a href="#/a?scroll=same">Same</a>
  • <a href="#/a?scroll=500">500px</a>
  • <a href="#/a?scroll=1000">1,000px</a>
  •  
  • </div>
  •  
  • <div
  • class="section"
  • ng-class="{ active: ( section == 'b' ) }">
  •  
  • <strong>Section B:</strong>
  •  
  • <a href="#/b">Normal</a>
  • <a href="#/b?scroll=same">Same</a>
  • <a href="#/b?scroll=500">500px</a>
  • <a href="#/b?scroll=1000">1,000px</a>
  •  
  • </div>
  •  
  • </div>
  • <!-- END: Nav. -->
  •  
  •  
  • <!-- BEGIN: Views. -->
  • <div
  • class="views"
  • ng-switch="section">
  •  
  • <div
  • ng-switch-when="a"
  • bn-view
  • class="view view-a">
  •  
  • <h2>
  • Section A
  • </h2>
  •  
  • Section a - 1.<br />Section a - 2.<br />Section a - 3.<br />Section a - 4.<br />
  • Section a - 5.<br />Section a - 6.<br />Section a - 7.<br />Section a - 8.<br />
  • Section a - 9.<br />Section a - 10.<br />Section a - 11.<br />Section a - 12.<br />
  • Section a - 13.<br />Section a - 14.<br />Section a - 15.<br />Section a - 16.<br />
  • Section a - 17.<br />Section a - 18.<br />Section a - 19.<br />Section a - 20.<br />
  •  
  • </div>
  •  
  • <div
  • ng-switch-when="b"
  • bn-view
  • class="view view-b">
  •  
  • <h2>
  • Section B
  • </h2>
  •  
  • Section b - 1.<br />Section b - 2.<br />Section b - 3.<br />Section b - 4.<br />
  • Section b - 5.<br />Section b - 6.<br />Section b - 7.<br />Section b - 8.<br />
  • Section b - 9.<br />Section b - 10.<br />Section b - 11.<br />Section b - 12.<br />
  • Section b - 13.<br />Section b - 14.<br />Section b - 15.<br />Section b - 16.<br />
  • Section b - 17.<br />Section b - 18.<br />Section b - 19.<br />Section b - 20.<br />
  •  
  • </div>
  •  
  • </div>
  • <!-- END: Views. -->
  •  
  •  
  • <!-- Load scripts. -->
  • <script type="text/javascript" src="../../vendor/angularjs/angular-1.3.8.min.js"></script>
  • <script type="text/javascript" src="../../vendor/angularjs/angular-route-1.3.8.min.js"></script>
  • <script type="text/javascript">
  •  
  • // Create an application module for our demo.
  • var app = angular.module( "Demo", [ "ngRoute" ] );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // Configure the route provider.
  • app.config(
  • function( $routeProvider ) {
  •  
  • // Set up the route so that we can easily map the URL component on to
  • // the "section" route parameter.
  • $routeProvider.when( "/:section", {} );
  •  
  • // Default to section selection in case one is not being provided.
  • $routeProvider.otherwise( "/a" );
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I control the root of the application.
  • // --
  • // NOTE: While we are not referencing the $route service, we have to inject it or
  • // the "$routeChangeSuccess" event will never be fired (as AngularJS will only
  • // create the $route service on-demand).
  • app.controller(
  • "AppController",
  • function( $scope, $route, $routeParams ) {
  •  
  • // I determine which section is rendered.
  • $scope.section = null;
  •  
  • // I update the rendered section to reflect the route configuration.
  • $scope.$on(
  • "$routeChangeSuccess",
  • function handleRouteChangeEvent( event ) {
  •  
  • // The $routeChangeSuccess event will fire even if the query
  • // string is the only thing that changes. As such, just ignore
  • // any events that don't result in an actual route-change.
  • if ( $scope.section === $routeParams.section ) {
  •  
  • return;
  •  
  • }
  •  
  • $scope.section = $routeParams.section;
  •  
  • console.log( "Route Changed:", $scope.section );
  •  
  • }
  • );
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I help transition the state of the currently rendered view.
  • app.directive(
  • "bnView",
  • function( $location ) {
  •  
  • // Return the directive configuration.
  • return({
  • link: link,
  • restrict: "A"
  • });
  •  
  •  
  • // I bind the JavaScript events to the local scope.
  • function link( scope, element, attributes ) {
  •  
  • // The current element is the "content" which is being scrolled
  • // within the context of the parent. As such, we need to affect the
  • // scrolling on the parent element.
  • // --
  • // NOTE: It would probably be cleaner to put this directive directly
  • // on the parent element; but this approach keeps the demo simple.
  • var parentNode = element.parent()[ 0 ];
  •  
  • // Grab the "scroll" property out of the query string. This value
  • // determines how we scroll the viewport. If no value is present,
  • // we'll assume the viewport should scroll to the top.
  • var scroll = ( $location.search().scroll || "none" );
  •  
  • // Since the query string is only taken into account during the
  • // linking phase, just remove it from the URL. This way, the user
  • // won't be tempted to play with it.
  • $location.search( "scroll", null );
  •  
  • // If none, then scroll to the top of the viewport.
  • if ( scroll === "none" ) {
  •  
  • return( parentNode.scrollTop = 0 );
  •  
  • }
  •  
  • // If same, then keep the viewport scrolled to the same position.
  • // Here, we can just return because this is the natural behavior of
  • // the viewport (assuming some other directive didn't force a repaint
  • // while the content was will loading).
  • if ( scroll === "same" ) {
  •  
  • return;
  •  
  • }
  •  
  • // If we've made it this far, assume the provided scroll value is an
  • // integer - scroll the viewport appropriately.
  • parentNode.scrollTop = parseInt( scroll, 10 );
  •  
  • }
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

As you can see, while the $route and $routeParams determine which view will be included, the scroll behavior is determined by the $location search (query string) and is consumed by the linking function on the view directive. Since the scroll behavior is only observed during the linking phase, I'm removing the "scroll" query string value after it is consumed (by setting it to null).

Hopefully this demo does a bit more to clarify my thoughts on a more robust consumption of the $route and $location services in a complex AngularJS application; rather than using one or the other, both the $route and the $location services can be used to drive different aspects of the application.




Reader Comments

I was wondering - shouldn't you have implemented this cleanly with ui-views and states and routing in Angular? Why write your own views?

On top of that your example does not work. Try hitting A500, then B1000 and it wont work (in FF 41.0.2)

Reply to this Comment

@Oskar,

I was unclear as to the bugs in your demo, but if you click around a bit, go from a to b, use normal, then hit a500 and then try a1000, nothing happens etc.

Reply to this Comment

@Oskar,

I don't know too much about the ui-router. The core AngularJS router has always been sufficient for my needs. I am not sure how much cleaner it would have made this demo. That said, the part that's actually involved in the routing is the $routeChangeSuccess handler, so I am not sure how much of a win it would have been.

As far a bug, the response to the $location scroll request is in the link() function of the view. So, if you won't change the view and cause a re-linking of the directive, you won't get a change in the vertical offset. But, if you go from A to B and back, the scroll should always work (at least in my Firefox and Chrome).

Reply to this Comment

@Oskar,

If you wanted to be able to jump from one offset to another, in the same view, you could always add an event listener to the $locationChangeSuccess in the link() function. Then, it could change the offset any time the relevant URL search param changed.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
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.