AngularJS Directive: Two ways to skin a cat

Let’s take a look at a recent directive I needed to create, and dissect the two completely different approaches I took to accomplish what I needed.

First let me preface by saying that the problem I needed to solve was somewhat unique, in that I’m writing an application for a device that has no mouse. Interfacing with the application is done completely using arrows and “tabbing” to focus on elements.

The Problem

I have a horizontal row of elements that the user can tab through. There are only 4 elements visible on the screen but the row itself actually contains many (25+). This is a fairly common use case on the web, but we take scrollbars and mice for granted in this situation. The problem is that when the user would tab to the 4th element, which was partially cut off from the screen, the “feel” of the UX at that point was less than desirable. The entire row would shimmy and shift and you would have to keep tabbing and it all felt very disjointed and confusing. Finally, jQuery is not included in the project so we’re limited to Angular’s jqLite and Vanilla JavaScript.

The Approach

The solution I wanted to do seemed simple enough, since 3 items are always completely visible, and the 4th is always cut off (half visible – to indicate that theres more in the list), I want the entire row to shift by a single element every time the 4th item in the row is focused.

shift_row1

Before we take a look at the two different approaches I took, a few important items to note: The elements in the row are displayed via an ng-repeat. There are multiple rows, each with its own unique collection of elements. The user can tab right and left in the row so the approach needs to support both directions. Also, every element in the row is the same exact width.

Solution 1: Manipulate the DOM (a.k.a the “jQuery” approach)

The first approach I came up with was to simply shift the row by effecting the left-margin of the first element in the row. Whenever the user focused on the 4th element in the list, I would bump the first elements left-margin by the negative amount of the width of an element. This involved tracking the number of items that have been “shifted” (i.e. how many times did the user press right onto the 4th item) so I could shift the first element by that many elements. If the user pressed right 6 times, the first element in the list should have a negative margin of element_width * 4 so that elements 5, 6, 7, and 8 are visible. That is, they pressed right 6 times, but landed on the 4th element only 4 times thus the row shifted by 4 elements.

shift_row2

By tracking the count of items that have been skipped, the opposite direction works well too. Once the user presses left and lands on the 2nd element in the list, do the opposite. Decrement the count of skipped elements by 1, and readjust the negative left-margin.

angular.module('playstation')

.directive('shiftRow', [function(){
  'use strict';

    return function(scope, element, attrs) {
      var allItems      = element.parent()[0].querySelectorAll(attrs.shiftRowSelector),
          firstItem     = allItems[0],
          rowStart      = allItems[0].getBoundingClientRect().left,
          rowEnd        = element[0].parentNode.getBoundingClientRect().right - rowStart,
          itemWidth     = (allItems[1] ? allItems[1].getBoundingClientRect().left : 0) - rowStart,
          itemCount     = 0;

      // determine row count (items not clipped)
      angular.forEach(allItems, function(item) {
        itemCount += (item.getBoundingClientRect().right < rowEnd);
      });

      element.find('a').on('focus', function(event) {
        var container = event.target.parentNode;

        container.itemIndex = parseInt(event.target.getAttribute('tabindex'), 10);
        container.bounds = container.getBoundingClientRect();

        if (container.bounds.right > rowEnd) {
          // if the item is clipped in that its right edge is "off screen", shift the row by 1:
          firstItem.style.marginLeft = ((itemWidth * (container.itemIndex - itemCount)) * -1) + 'px';
        } else if (container.itemIndex > 1 && container.bounds.left <= rowStart) {
          // if the item isn't first and its left edge is "off screen", shift the row:
          firstItem.style.marginLeft = ((itemWidth * (container.itemIndex - 2)) * -1) + 'px';
        }
      });
    };
}]);

I have a similar list elsewhere in the application that behaves the same way, except it has 6 elements per row and each element’s width is slightly smaller. Because of this I want the directive to be as flexible and reusable as possible. I do some vanilla JavaScript getBoundingClientRect calls to compute the width of every element (the first really, to use as a template) and store that data within the directive itself. By also comparing the bounding right edge of an item I can tell if its the “last” item in the row (i.e. 4th in the original example, or 6th in the other use case).

<ng-include
  shift-row shift-row-selector=".content-item"
  ng-repeat="item in collection | limitTo: 25"
  src="template_path('/views/partials/item.html')">
</ng-include>

Potential Pitfalls

There are a few pros and cons with this approach. The biggest con is that if my collection of data was exceptionally large, i.e. way more than 25, this approach would be very inefficient. We are rendering the entire row of 25 items, when only 4 are visible at any give time. Reusing the directive is fairly trivial, in that I only need to include shift-row and shift-row-selector and everything else is computed and dynamic.

Let’s take another look at how we can achieve the same result.

Solution 2: Manipulate the Data (a.k.a the “Angular” way)

So let’s think about the approach differently. We still have the same problems, but lets pretend that the data in each row is in the hundreds (and not 25). So now we have to ditch the whole DOM approach and start thinking about how we can manipulate the data thats being rendered instead.

What we want to do is just slice 4 items from the collection in the row and only render those to the screen. The same basic principals apply though, when the user tabs right and focuses on the 4th item in the list, we want to shift the row by 1 so that instead of elements 1, 2, 3, and 4 we instead see 2, 3, 4, and 5.

shift_row3

The first thing thats easy is to only render 4 items via the ng-repeat by using slice(). In Angular 1.4 you can use limitTo and include both a length and begin. However I’m still using 1.3 so I’m going to rely on slice instead (I can still limitTo 4 though).

<ng-include
   shift-row shift-data="viewable"
   ng-repeat="item in collection.slice(viewable.start) | limitTo: viewable.count"
   src="template_path('/views/partials/seriesitem.html')">
</ng-include>

A few things are different with this use. First you can see the slice and limitTo on the ng-repeat. The second is that we include some scope data with our shift-row; viewable. This is because we need to pass that scope data into the directive via its own isolateScope. The directive will manipulate the scope data instead of the DOM so that the ng-repeat will rerender the 4 elements whenever the values in viewable change. Here is what the viewable data object looks like (defined within the views controller):

  $scope.viewable = { start: 0, focus: 1, count: 4 };

Now for the directive itself:

angular.module('playstation')

.directive('shiftRow', [function(){
  'use strict';

  return {
    scope: {
      data: '=shiftData'
    },
    link: function(scope, element) {
      element.find('a').on('focus', function(event) {
        var container = event.target.parentNode;
        container.itemIndex = parseInt(event.target.getAttribute('tabindex'), 10);

        if (container.itemIndex === scope.data.count) {
          // if last item, shift row by 1 (since last is cutt off):
          scope.data.start += 1;
          scope.data.focus = scope.data.count - 1;
        } else if (container.itemIndex === 1 && scope.data.start > 0) {
          // if first item and row already shift, go back one (since first is totally hidden):
          scope.data.start -= 1;
          scope.data.focus = 2;
        } else {
          scope.data.focus = container.itemIndex;
        }

        scope.$apply();
      });
    }
  };
}]);

What we’re doing with this approach is simply managing state of a controller $scope variable viewable. By passing in the $scope variable via an isolateScope to the directive, we can not only read the data but change it and have that propagate back to the original view rendering the ng-repeat.

The gist of this code is that if the current element in focus has a tabIndex that is === to the total number of elements per row (i.e. 4th in a row of 4 items), bump the start of the row by one. By changing the value of scope.data.start, the ng-repeat in the view will automatically re-render (and compute a new slice based on the new values). Likewise, going the opposite direction, if the first item in the list is focused and the row has been shifted, step back on the start of the slice by one element and re-render. We can safely rely on tabIndex since there are only 4 elements being rendered so tabIndex will never not be 1-4.

(Note: theres an extra line of code referring to scope.data.focus which is a marker to indicate which element in the row should be “refocused” after its rendered. This is primarily so that when you tab to the 4th element, and the row re-renders, you don’t “lose your place” so to speak. I’m using another separate directive that will auto focus an element whenever it is rendered assuming the value passed to the directive matches its own tabIndex.)

Potential Pitfalls

There are also a few pros and cons with this approach as well. First this approach is much more efficient in that we’re only ever rendering 4 elements to the DOM. If our row’s collection had many many elements, this would be great for our users. The UX feels identical to that of the first approach above. The implementation is not as easy as the first approach because we need to setup $scope.viewable in our controller as well as pass those to the directive.

Conclusion

In the end I decided to stick with the 2nd approach as I was happier with the notion that the DOM was staying light and the device I’m developing on will perform better as a result. Having reused the 2nd approach elsewhere in the app I definitely felt the downside of having to worry about creating extra $scope variables. However, this presented me with a unique solution in that I was able to “save state” with every row. If the user navigates to the 8th item in row one, hits down to get to the second row, they not only start back on the first element of that row but if they hit up they resume on the 8th item of the previous row. This works for every row and feels a lot less confusing for the user as their state isn’t being reset as they navigate around the app.

UPDATE: 3rd way to skin a cat!

After pushing my code through review again, I got some valuable feedback that pointed out something I’m sure we’re all guilty of – over-engineering! I was completely unaware of the ngFocus directive, which completely eliminates my need for a custom directive at all. Here is the end result that I wound up actually implementing and getting into production.

In my app.controller I added a reusable method to $scope that I can then use on ng-focus of my elements within each sliced row:

  $scope.shift_row = function(event, data) {
    var itemIndex = parseInt(event.target.getAttribute('tabindex'), 10);

    if (itemIndex === data.count) {
      data.start += 1;
      data.focus = data.count - 1;
    } else if (itemIndex === 1 && data.start > 0) {
      data.start -= 1;
      data.focus = 2;
    } else {
      data.focus = itemIndex;
    }
  };

Then, I implement the slice parameters slightly different in my repeater:

  <ng-include
    ng-init="shift_state = shift_state"
    ng-repeat="item in collection.slice(shift_state.start) | limitTo: shift_state.count"
    src="template_path('/views/partials/item.html')">
  </ng-include>

The only catch here is that we’re using ng-init which means we’re passing in a value from the current scope to be available to the items inside the ng-repeat’s scope. We define shift_state to look something like:

$scope.shift_state = {
  start: 0,
  focus: 1,
  count: 4
};

And finally in the HTML itself, I just use a simple ng-focus built in directive reusing the shared app controllers $scope.shift_state method:

  <a href="" tabindex="{{$index}}" ng-focus="shift_row($event, shift_state)">

Viola! Clean and simple and neat!

One thought on “AngularJS Directive: Two ways to skin a cat

  1. Great article Jason! I would instinctively have gone for dom manipulation here but your 2nd approach really shows the power of angular and how the view engine listens to the controller. The power of MVC!!! Thanks for the tip

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s