Automatically switch to a fallback image using Angular

Automatically switch to a fallback image using Angular

When we as developers are scaffolding a website that utilizes a content management system, one of the largest and most complex UX issues that we encounter is the requirement of field inputs. Essentially, to require or not to require; that is the question. We have to account for the possible partial completion of page configurations and the many scenarios that could take place; making certain pages and situations a quality assurance nightmare.

Usually this problem is very easy to solve when you are parsing a server-side template. If this input is either undefined or the length of the string is zero, render this image. This sounds easy because it is: just wrap the <img /> tag in an if statement and move on.

The service call variable

When we move away from server-side parsing and switch to front-end service calls (using jQuery, Angular, or any other framework) is where things get a little dicey. Many times when our requested service response is returned, we can again test to see if an endpoint is defined or not. This is easy, but what if an image path is defined but results in a 404: not found. How do you go about testing for this? Of course we don't want just an empty box with a nice broken asset icon to show. That's just not acceptable. We need to be able to replace this broken link with a placeholder image; or fallback.

Recently on a new website redesign for Speedco that I was a part of, the need to be able to test for such a 404 error and then apply a fallback image came up. Below is a brief description on how I came up with it.

Setting up the directive

We will be using a custom directive to be able to handle all of the necessary scripting and logic. The best part of using an Angular directive for this solution is the fact it is modular, easily repeatable and isolated; preventing any unwanted chaining and weird double-negatives. This is the shell that we will need:

var app = angular.module('app', []);

app.directive('ngFallbackImg', ['$timeout', function($timeout) {
  return {
    restrict: 'AC',
    scope: {},
    link: function(scope, element, attrs) {
      
    }
  };
}]);

Notice that I also included the $timeout service dependency. This will help us prevent the guts of the directive from running till after the page renders. This directive has also been isolated to only recognize element attribute and class calls.

Binding the error

First we need to bind the error callback that the page will throw if a 404 is detected to a function with a few pieces of additional logic. Next add the following the function wrapped by a timeout delay of ten milliseconds. So our link function will look like this:

link: function(scope, element, attrs) {
  $timeout(function() {
    element.bind('error', function() {
      if (attrs.src != attrs.errSrc) {
        attrs.$set('src', attrs.errSrc);
      }
    });
  }, 10);
}

So what does this do? I will skip the timeout part as I hope that this is pretty evident what it does. The bind function binds the function callback to the error that is attached to the <img /> element. Within this function we are making sure that the fallback image and the original image source do not match, and then we are resetting the image source to the value of the fallback. That's it!

Watching the ngSrc

Next we need to another watcher to look at the ng-src attribute. You may be asking yourself what is the point of ng-src? Just like that of ng-class, ng-src allows you to evaluate Angular functions and tests to determine it's value. So we need to keep an eye on this value to see if it resolves or not. If an ng-src value errors, it will return an undefined value which is something that we can test. So lets append the following function to our $timeout within our link function:

attrs.$observe('ngSrc', function(value) {
  if (!value && attrs.errSrc) {
    attrs.$set('src', attrs.errSrc);
  }
});

So our completed link function will look like this:

link: function(scope, element, attrs) {
  $timeout(function() {
    element.bind('error', function() {
      if (attrs.src != attrs.errSrc) {
        attrs.$set('src', attrs.errSrc);
      }
    });
    attrs.$observe('ngSrc', function(value) {
      if (!value && attrs.errSrc) {
        attrs.$set('src', attrs.errSrc);
      }
    });
  }, 10);
}

I want to bring special attention to the use of the $observe function that was just added. The easiest way to describe what $observe does is compare it to that of $watch. If you are unfamiliar with $watch, a $watch function that is bound to a scope variable will fire whenever the scope variable changes. The $observe function operates the exact same way but with element attributes; in our case of the image's source. If this isn't super clear, refer to the following resources:

So with this new function that we just added, we are watching the value of ng-src. As stated above, if the value of the returned source is undefined, we will be assigning the image's src attribute that of the fallback image.

Applying the directive to our HTML

Now that our directive is complete, lets take a look at the HTML that is needed to make this work:

<img ng-fallback-img ng-src="profile-picture.jpg" err-src="fallback-image.jpg" />

You can see that we initialized the directive (using ng-fallback-img) accompanied with two different attributes: ng-src and err-src. Within ng-src we will define our desired image and place the source for our fallback image within err-src. That's it! Angular will handle all of the logic from here on out.

As a small footnote, you may benefit by placing ng-cloak on the parent element to prevent the element from flickering during the image transition.

The finished product

Assuming you did everything correctly, this is what your finalized directive should look like:

var app = angular.module('app', []);

app.directive('ngFallbackImg', ['$timeout', function($timeout) {
  return {
    restrict: 'AC',
    scope: {},
    link: function(scope, element, attrs) {
      $timeout(function() {
        element.bind('error', function() {
          if (attrs.src != attrs.errSrc) {
            attrs.$set('src', attrs.errSrc);
          }
        });
        attrs.$observe('ngSrc', function(value) {
          if (!value && attrs.errSrc) {
            attrs.$set('src', attrs.errSrc);
          }
        });
      }, 10);
    }
  };
}]);

This new directive will hopefully add a little more stability to your project which will in turn make it more polished and finalized. Cheers!

Show Comments