Bad films are bad

which is why there's goodfil.ms

AngularJS and the Goodfilms Mobile Site - Part 1

Following on from the release of the Goodfilms mobile site, we received a lot of requests for a more detailed post on how we used AngularJS, and what we thought of it. In this Part 1 we’ll talk about how we use Directives to isolate some mobile-specific logic and keep them maintainable.

Before we get started, let’s take a look at what the mobile site looks like:

Choosing AngularJS

We evaluated Backbone, BatmanJS and EmberJS before settling on AngularJS. Once we found Angular we stopped looking at alternatives - even after the first few experiments we found it to be way out in front in the client-side framework arms race.

The most immediate difference was that your application is fundamentally described in the HTML. Rather than defining an application as a hierarchy of JS ‘classes’, then an implicit or explicit render step to populate the DOM, you describe a great deal of the functionality of the site in HTML. This was a little jarring, and almost reminiscent of adding ugly snippets like onclick='callGlobalFunction(); return false;' to your <a> tags. But in practice, it has none of the nastiness that approach leads to, due to the cleverness of coupling Controllers to sections of the DOM, and the genius of Directives.

If you are interested in learning more about the concepts in AngularJS, the documentation and examples on the official site are excellent. You could also check out the presentation I gave to the Melbourne Rails meetup, which is itself an Angular app (source here).

Controllers - your neatly scoped friend

The golden rule of Controllers is to keep DOM manipulation out of them. Instead, you update a $scope object and Angular propagates those changes to the DOM. In practice, Controllers map really well to the functional components of your application - in Goodfilms mobile, there are six,1 three of which are visible here:

AngularJS hierarchy view

The other three are FilmDetail, Search and Queue. You can see them in action in the video at the top. We’ll be mostly focussing on the top level of the application in this part - the FrameController:

The app structure
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<body ng-controller="FrameController">

  <header ng-class="{menuopen: menuopen}">
    <div id="header"> ... </div>
    <nav id="menu"> ... </nav>
  </header>

  <div id="main" ng-class="{menuopen: menuopen}">
    <div id="stage" ng-view>
  </div>

  <script type="text/ng-template" id="/views/feed.html"> ... </script>
  <script type="text/ng-template" id="/views/film.html"> ... </script>
  <script type="text/ng-template" id="/views/list.html"> ... </script>

</body>

The FrameController is scoped to this section of the DOM, but not to any domain objects yet, which means its only job really is to update its $scope. Let’s look at a simple task - showing/hiding the left menu drawer.

In the HTML above, we add the ng-class="{menuopen: menuopen}" declaration to a couple of elements, and Angular ensures that if variable ‘menuopen’ is truthy, the class ‘menuopen’ will be set on the header and main. So, with the following controller code and css:

frame_controller.js
1
2
3
4
5
function FrameController($scope) = {
  $scope.toggleMenu = function() {
    $scope.menuopen = !$scope.menuopen;
  };
};
frame.css
1
2
3
4
5
6
body >header, #main {
  -webkit-transition: 0.3s -webkit-transform;
}
.menuopen {
  -webkit-transform: translate3d(280px,0,0);
}

We have a nice way to slide the menu (part of the header tag) in from the left, and slide the main content away. But we need a way to call toggleMenu() from the DOM. This is where AngularJS really shines.

Tapping vs Clicking

One of the most commonly-encountered differences between desktop and mobile is the handling of click events. We’re primarily targeting iOS Safari, which will send three events on a simple tap. These are ‘touchstart’ and ‘touchend’, which fire immediately as you, well, start and stop touching. Then, Safari waits 300ms to see if you’re double tapping, and if not, sends a ‘click’ event. If you only use click events, the user experience is a 300ms pause before the app responds to every tap. It’s a big contributor to the sense of ‘non-nativeness’ that mobile web apps can have, and we can do better.

How does this look in an Angular app? Well, the normal, desktop way of toggling our menu would be to use ng-click:

1
<div class="icon menu" ng-click='toggleMenu()'></div>

Clicking on this icon will fire the toggleMenu() action on the controller and AngularJS will set the classes on our elements. And the CSS transition will neatly slide the elements into place. But this is for a click event - Angular provides no built-in way of handling touch events. Oh gnoes! What are we to do? Oh wait, it’s unbelievably easy.

Directives - DOM magic begone!

Well, the DOM magic is still there, it’s just beautifully isolated. Let’s start with the naïve example, where you just bind to touchstart. This will at least get us around the 300ms delay:

Your first Directive
1
2
3
4
5
6
7
app.directive('gfTap', function() {
  return function(scope, element, attrs) {
    element.bind('touchstart', function() {
      scope.$apply(attrs['gfTap']);
    });
  };
});

In the above, app is the Angular app object, though it could just as easily be a service you mix in to your Controller if you want to isolate it. The signature return function(scope, element, attrs) {...}; is the simplest-possible directive syntax, very useful when all you want to do is bind an event.

Before going on, I’ll present the same code in Coffeescript, since that’s how the Goodfilms app is written, and because I think the two work together so well. I disliked BatmanJS because it forced you to go full-coffeescript, and I appreciate that Angular has made an effort to make a beautiful Javascript library, which simply gets more concise by switching to coffeescript. Here’s the same, naïve example:

Your first Directive, in Coffee
1
2
3
app.directive 'gfTap', ->
  (scope, element, attrs) ->
    element.bind 'touchstart', -> scope.$apply(attrs['gfTap'])

This, I think, is easier to understand, and it says that the ‘gfTap’ directive means execute scope.$apply(attrs['gfTap']) on ‘touchstart’. That snippet is how Angular internally calls a Controller function - it’s the same for ng-click. We can update our HTML to use our directive:

Using our directive
1
<div class="icon menu" gf-tap='toggleMenu()'></div>

Now our toggleMenu() method is being called but without waiting for a click event. In fact, anywhere you were going to use an ng-click you can now use a gf-tap, and you’ve beaten the 300ms delay on mobile without complicating your HTML or your Controller. Boom!

Directives isolate complexity

There’s a problem with our naïve gf-tap directive, however, which is that when scrolling or swiping, the ‘touchstart’ event is still triggered, but it isn’t a tap. Safari will actually send a ‘touchmove’ event in these cases, so we should prevent the Controller action from being fired if we detect one:

A smarter gf-tap
1
2
3
4
5
6
app.directive 'gfTap', ->
  (scope, element, attrs) ->
    tapping = false
    element.bind 'touchstart', -> tapping = true
    element.bind 'touchmove', -> tapping = false
    element.bind 'touchend', -> scope.$apply(attrs['gfTap']) if tapping

What we’ve done here is a fixed a bug in our javascript by making our gf-tap directive more sophisticated, but we didn’t need to change anything else. That’s a sign of clean, maintainable code, and I love that Angular makes it so easy.

Once-off Directives

Directives aren’t just for reusable event logic such as gf-tap above, they’re also ideal for interfacing with third-party libraries. We use Zepto.js and its plugin Flickable.js for the swiping of movie posters in the feed, and use a directive to wire it up. This is how it looks:

gf-flickable.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
flickable = angular.module("flickable", [])
flickable.directive 'gfFlickable', ->
  (scope, element, attrs) ->
    el = $(element[0]) # get a Zepto element

    # initial state - the first is selected
    scope.selectedIndex = 0
    selectedEl = -> $(el.children()[scope.selectedIndex])

    tapping = false
    el.flickable
      segmentPx: 245

      # Once again, we detect a tap event and show/hide the buttons
      onStart: -> tapping = true
      onMove: -> tapping = false
      onEnd: -> selectedEl().toggleClass('showbuttons') if tapping

      # If we're moving to a new element, update everything
      onScroll: (eventData, newSelectedIndex) ->
        # Hide the buttons on the old one
        selectedEl().removeClass('selected showbuttons')

        # Show the buttons on the new one
        scope.selectedIndex = newSelectedIndex
        selectedEl().addClass('selected showbuttons')

        # Tell the rest of our app
        scope.didFlick()
        scope.$apply()
feed.html
1
2
3
4
5
<ul id="feed" gf-flickable>
  <li class="event" ng-repeat="event in events">
    ...
  </li>
</ul>

The key here is that all the DOM manipulation is one place, and the controller action didFlick() can do important stuff (in Goodfilms it lazily loads the next image to be displayed and fetches more events if you’re near the end of the list) but the two don’t overlap.

This example is good, I feel, because the code within gf-flickable.js is pretty similar to code you might be writing already, but Angular lets you encapsulate it in a directive, making your app more maintainable with hardly any effort. It means that the id of ‘feed’ on your ul isn’t both used to attach events and styles, too, which is a bonus.

Wrap-up

We got a big benefit from Angular - it helped us focus on dealing with the various challenges of mobile touch interactions separately from modelling our domain logic and separately from our presentation. Those goals can certainly be met with any good JS framework, or even with disciplined framework-free JS, but we found Angular repeatedly led us to good solutions quickly, and with minimal code.

Next time, we’ll start by talking about what happens when the AngularJS abstraction breaks down, and we have to break our golden rule. Until then, please let us know your feedback, and if you haven’t done so already, sign up to Goodfilms!

Goodfilms

Goodfilms is a way to share the movies you watch with your friends. We rate movies on two criteria - ‘quality’ and ‘rewatchability’, so you can admit to your guilty pleasures and properly capture the feeling you get when a film leaves you exhausted. Sign up now and keep track of the films you love, and find great, challenging or silly new ones to watch.

Comments