Adaptive Web Design and AngularJS

Change templates based on device capability

Gert Hengeveld
May 15, 2014 · 4 min read

TL;DR: I created a provider to implement adaptive templates in AngularJS. It’s on Github: https://github.com/ghengeveld/angular-adaptive-templating

I recently attended a JavaScript meetup where I heard a talk on adaptive templates with handlebars and curl.js. The topic was very actual for me, since I had just implemented a very similar thing using RequireJS* and enquire.js. Someone in the audience asked how this could be implemented in AngularJS, which triggered me to write this article.

* Looking back, yepnope.js or curl.js might have been a better choice.

Adaptive templates are a way to implement Adaptive Web Design, which means dynamically changing the layout and function of your application based on the device’s capabilities. AWD is very similar to Responsive Web Design, in the sense that the page may render differently based on screen resolution. However, AWD takes it a bit further and also changes the function (features) of the page based technical capabilities of the device such as support for touch events, geolocation and HTML5 audio/video.

You may also know AWD as Progressive Enhancement, a slightly older term which focusses on browser capability rather than device capability. Since device capability is analogous to browser capability, AWD is just the new buzzword for an old practice.

A very useful library to detect feature support is Modernizr. Our goal is to have Angular load a different template file based on the support for a certain feature. There are three places in an Angular app where you can define a template URL:

  • Route definition
  • Directive definition
  • ngInclude ‘src’ attribute

Function

One solution is to simply define a function which dynamically generates the right template URL. For example:

templateUrl: function () {
return Modernizr.touch ?
'views/main.touch.html' :
'views/main.no-touch.html';
}

When used with ngInclude, you’d have to use a $scope property:

$scope.buildTemplateUrl = function () { ... };

This approach has several drawbacks. First of all it’s not very scalable. You can’t define it in one place and use it throughout the application, unless you put it on the global JavaScript scope (which you should avoid at all times). Secondly, the function will be very hard to unit test because it’s not defined as a service or filter (those aren’t available in these contexts). Finally, you lose the option to pass a string to ngInclude, which may or may not be an issue for you.

Decorator

We can avoid these limitations by using a decorator. Templates loaded by $compile and ngInclude are fetched using $http.get(), so we could decorate the get method on $http to dynamically rewrite the URL before actually fetching it. That way we solve the problem globally.

$provide.decorator('$http', function($delegate) {
var getFn = $delegate.get;
$delegate.get = function () {
arguments[0] = rewriteUrl(arguments[0]);
return getFn.apply(this, arguments);
};
return $delegate;
});

This is the basic decorator. It firsts stores the original get method in a variable, then replaces it with a new function. This function rewrites the URL (the first argument), then calls the original method with the original context and all arguments. For the sake of simplicity, I left out the actual rewriteUrl function implementation. I’ll present my version below, but it may not be the best solution in every scenario.

Feature-based template URLs

The approach I’ve chosen to implement dynamic template URLs based on feature support is to use a specific URL pattern. Take the following URL:

'views/{desktop}{mobile}/main.{touch}.html'

This should automatically be rewritten to a URL matching a combination of tests. For example, a mobile device with support for touch events should load ‘views/mobile/main.touch.html’, while a regular desktop browser should load ‘views/desktop/main.html’. The following function would work:

var tests = {
desktop: $window.matchMedia('(min-width: 768px)').matches,
mobile: $window.matchMedia('(max-width: 767px)').matches,
touch: Modernizr.touch
};
function rewriteUrl(url) {
var pattern = /{([^{}]+?)}/gi;
var matches = url.match(pattern);
if (matches) {
matches.forEach(function (match) {
var testname = match.replace(pattern, '$1');
var test = tests[testname];
url = url.replace(match, test ? testname : '');
});
}
return url.replace(/(\/|\.)\1+/g, '$1');
}

This function can be improved in many ways, but it displays the solution in the clearest way possible. First, it looks for words followed by a question mark, which denote a test. It then runs all the found tests using a lookup table and either removes the entire pattern from the URL if the test fails, or only removes the question mark if the test succeeds. Finally, consecutive slashes and dots are removed to form a proper URL again.

Adding flexibility

It would be nice to be able to configure the available tests in your app’s config block, or even add and remove them at runtime. For this, we’ll need to create a service provider, since providers are configurable:

.provider('aTpl', function() {
var tests = angular.extend({}, window.Modernizr);
this.$get = angular.noop;
this.rewriteUrl = function (url) { ... };
this.addTest = function (name, test) {
tests[name] = test;
return this;
};
})

Now we can configure the provider and setup the decorator as follows:

.config(function (aTplProvider, $window, $provide) {
var isMobile =
$window.matchMedia('(max-width: 767px)').matches;

aTplProvider.addTest('desktop', !isMobile);
aTplProvider.addTest('mobile', isMobile);

$provide.decorator('$http', function($delegate) {
var getFn = $delegate.get;
$delegate.get = function () {
arguments[0] = aTplProvider.rewriteUrl(arguments[0]);
return getFn.apply(this, arguments);
};
return $delegate;
});
})

Wrapping up

The final solution presented here still leaves some things to be desired. For example, I would like to be able to configure the replacement pattern for when a test fails (e.g. it becomes ‘no-touch’ instead of an empty string). Also, it only accepts boolean tests, not functions. I’ve extended the solution with more features and flexibility, and put it on Github. It’s also available through Bower:

bower install --save angular-adaptive-templating

Feel free to fork it and send in your pull requests. ☺

Opinionated AngularJS

Practical tips and best practices for developing AngularJS…

Opinionated AngularJS

Practical tips and best practices for developing AngularJS applications

Gert Hengeveld

Written by

Enabling Component-Driven Development with @ChromaUI and @Storybookjs.

Opinionated AngularJS

Practical tips and best practices for developing AngularJS applications