Skip to content

Instantly share code, notes, and snippets.

@bunnymatic
Forked from shamus/a.md
Last active August 29, 2015 14:16
Show Gist options
  • Save bunnymatic/2dc85fcc7dda8e98f9cf to your computer and use it in GitHub Desktop.
Save bunnymatic/2dc85fcc7dda8e98f9cf to your computer and use it in GitHub Desktop.

Communication between collaborating Directives

Directives man! While the they're literally the entry point into angular development (you can't get going without ng-app), many people starting out with Angular are hesistant to write their own because of the complexity associated with them. But once that initial hurdle is crossed their value as reusable components becomes indispensible.

Occasionally a complicated component will come along, one that is naturally modeled by mutliple directives. Unlike stand alone directives these directives only make sense when grouped together. They collaborate. They are aware of each other and they need to communicate with each other. This post will discuss best practices for managing communication among collaborating directives and illustrate these practices with an example.

Lets explore a few ways to solve this problem. Imagine a scenario like this:

<component>
  <subcomponent></subcomponent>
  <another-subcomponent></another-subcomponent>
</component>

Shared scopes

It's tempting to communicate via shared scopes. The component directive could expose an API by exporting functions and data on its scope. These are available to both subcomponent and another-subcomponent (assuming neither uses an isolate scope) and now both directives can communicate directly with the parent component directive. Component, however, cannot communicate directly with subcomponent or another-subcomponent becuase scopes represent a hierarchical relationship between parent and child. This relationship is implemented using prototypical inheritance, so children have access to data placed on the scope by the parent but not vice versa. For component to communicate with its children it has to find another way.

Similarly, subcomponent and another-subcomponent will have trouble communicating because they are siblings and scopes do not provide a method for sibling communication.

NOTE: which approach? trying to communicate using the limited communication you can do using scopes?

This approach does not appear to be ideal. Communication is limited, but there are a few other problems:

Maybe: In addition to the limited communication channels available to nested directives and shared scopes, there are other problems:
  • Scopes are generally used to provide data to templates and coordinate events generated by user interaction with the view. Using them for inter-directive communication concerns overloads their responsibilities and is working against the framework.
  • Isolated scopes are not available since data and communication is being routed through scopes.
  • As component grows so too will the surface area of scope. It will become hard to maintain and reason about the implementation. Data placed on the scope by component may accidentally be masked by data placed on the scope by subcomonent or another-subcomponent.

For a more detailed discussion on scopes in angular, check out this article http://blog.carbonfive.com/2014/02/11/angularjs-scopes-an-introduction/

Eventing via $scope.$broadcast, $scope.$emit & $scope.$on

The inability to communicate directly between parent and child or between sibling directives often leads to the idea of using Angular's eventing system to facilitate communication. This is an improvement over the scope based solution in that it keeps the scope clean -- they're being used for their intended purpose, isolate scopes won't create problems, etc. But the eventing system has its own concerns: specifically eventing in angular is directed either up the scope hierarchy via $emit or down it via $broadcast. This means that a directives location in the document is important when it comes to sending the event. Many apps circumvent this by injecting $rootScope and only using $broadcast from there. Not only is the extra dependency a bit of a bummer but increasing dependency list sizes is usually an indication that something is amiss.

The biggest problem with this approach, conceptually at least, is that the collaborating directives are carrying on a private conversation in public. By using $broadcast, the event is delivered across the entire system and any directive can react to it. This can lead to uninteded consequences -- a generically named event triggers an unintended event handler. While that problem can be solved by naming conventions, this is a work around attempting to better target the event. $broadcast is best suited to situations where the listeners of an event are unknown by a component. This differs from the situation of collaborating directives; the consumers are well known to the component.

Controllers

So how should these directives communicate? The answer is in the documentation for directives (https://docs.angularjs.org/guide/directive) although tucked away at the bottom of the page:

Best Practice: use controller when you want to expose an API to other directives. Otherwise use link.

Obvious, right? Ok, maybe not. In order to understand it we will need to look at the directive definition object and the signature of the link function. Lets say we have a template that looks like this:

Obvious, right? Ok, maybe not. Taking a look at how a directive is defined will shed some light. Here is the example template from above again:

<component>
  <subcomponent></subcomponent>
  <another-subcomponent></another-subcomponent>
</component>

And here is the directive definition for component which specifies a controller:

module.directive('component', function() {
  function ComponentController() {
  }

  ComponentController.prototype.doSomething = function() {
    // ironically does nothing
  }

  return {
    restrict: 'E',
    controller: ComponentController,
    scope: {}
  };
});

And the directive definition for subcomponent:

module.directive('subcomponent', function() {
  function link(scope, element, attrs, componentController) {
    componentController.doSomething();
  }

  return {
    restrict: 'E',
    require: '^component',
    link: link,
    scope: {}
  }
});

Here's how it hangs together:

  • The component directive defines a controller class. The controller is instantiated each time the directive is encountered. It is instantiated before the directive's template is compiled so that the controller can initialize the scope if needed.
  • The subcomponent directive requires the compoent controller via the require property. Controllers are required via the directive name, not the name of the controller class. The '^' indicates that Angular should look for the controller in parent directives.
  • The link function receives the controller as its fourth argument.

An Example

Imagine a tool which shows annotations on an image. It will look something like this:

-- screenshot / embedded directive --

There are three main sections to this thing:

  • the image viewer
  • the control bar
  • the annotation list

By default, annotations are not shown. The image viewer shows the image but no annotations. The annotation list is hidden. When the user shows annotations the image viewer overlays the annotations and the annotation list is shown. Annotations can be selected either by clicking on the visual representation in the image or by selecting the item from the list.

I assume there's more going here, ya? it might be nice right here to see the DOM with the 3 image directives to show how they are nested.
Is it worth pointing out that the controller is accessed in the nested directies using the `controller` variable which is not related to the name `Controller` used to define the actual controller's functionality? I notice that when I do angular stuff, sometimes I'm confused and amazed that by choosing the correct variable names, things get hooked up right, and other times it doesn't and I always feel like i want to know *which* variable names are important for that auto hookup and which are not. ... which is why I'd want the mention of `controller` is not related to `Controller` which i could have called `AnnotatedImageController` or `WhateverController`
Is
var EVENTS = ['showAnnotations', 'hideAnnotations', 'selectAnnotation'];
function AnnotatedImage(AnnotationStore) {
function Controller(scope) {
AnnotationStore.load(scope.configuration.annotations);
this.handlers = EVENTS.reduce(function(memo, event) {
memo[event] = [];
return memo;
}, {});
}
EVENTS.forEach(function(event) {
var capitalized = event.charAt(0).toUpperCase() + event.slice(1);
Controller.prototype['on' + capitalized] = function(handler) {
this.handlers[event].push(handler);
};
});
Controller.prototype.annotations = function() {
return AnnotationStore.all();
}
Controller.prototype.showAnnotations = function() {
this.handlers.showAnnotations.forEach(function(handler) {
handler(AnnotationStore.all());
});
}
Controller.prototype.hideAnnotations = function() {
this.handlers.hideAnnotations.forEach(function(handler) {
handler(AnnotationStore.all());
});
}
Controller.prototype.selectAnnotation = function(coordinates) {
AnnotationStore.clearSelections();
var annotation = AnnotationStore.selectByCoordinates(coordinates);
this.handlers.selectAnnotation.forEach(function(handler) {
handler(AnnotationStore.all(), annotation);
});
}
return {
restrict: 'E',
template: [
'<annotated-image-viewer src="configuration.image"></annotated-image-viewer>',
'<annotated-image-controls></annotated-image-controls>',
'<annotated-image-list></annotated-image-list>'
].join(''),
controller: ['$scope', Controller],
scope: {
configuration: '='
}
}
}
AnnotatedImage.Controls = function Controls() {
function link(scope, el, attrs, controller) {
scope.annotations = controller.annotations();
scope.showAnnotations = function() {
controller.showAnnotations();
}
scope.hideAnnotations = function() {
controller.hideAnnotations();
}
controller.onShowAnnotations(function() {
scope.viewing = true;
});
controller.onHideAnnotations(function() {
scope.viewing = false;
});
}
return {
restrict: 'E',
require: '^annotatedImage',
template: [
'<div>',
'<span> {{ annotations.length }} Annotations </span>',
'<span data-role="show annotations" ng-click="showAnnotations()" ng-hide="viewing">Show</span>',
'<span data-role="hide annotations" ng-click="hideAnnotations()" ng-show="viewing">Hide</span>',
'</div>'
].join(''),
link: link,
scope: {}
}
}
AnnotatedImage.List = function List() {
function link(scope, el, attrs, controller) {
controller.onShowAnnotations(function(annotations) {
scope.viewing = true;
scope.annotations = annotations;
});
controller.onSelectAnnotation(function(annotations) {
scope.viewing = true;
scope.annotations = annotations;
});
controller.onHideAnnotations(function() {
scope.viewing = false;
});
}
return {
restrict: 'E',
require: '^annotatedImage',
link: link,
template: [
'<ul ng-show="viewing">',
'<li ng-repeat="annotation in annotations" ng-class="{ selected: annotation.selected }">',
'<span>{{ annotation.author }}</span> <span>{{ annotation.text }}</span>',
'</li>',
'</ul>'
].join(''),
scope: {}
}
}
var annotations = [];
AnnotatedImage.Store = {
load: function(_annotations_) {
annotations = _annotations_;
},
add: function(annotation) {
annotations.push(annotation);
},
all: function() {
return annotations;
},
byCoordinates: function(coordinates) {
return annotations.filter(function(annotation) {
var c = annotation.coordinates;
var insideHorizontally = coordinates.offsetX >= c.topLeft.offsetX && coordinates.offsetX <= c.bottomRight.offsetX;
var insideVertically = coordinates.offsetY >= c.topLeft.offsetY && coordinates.offsetY <= c.bottomRight.offsetY;
return insideHorizontally && insideVertically;
})[0];
},
selectByCoordinates: function(coordinates) {
var annotation = this.byCoordinates(coordinates);
if (annotation) {
annotation.selected = true;
}
return annotation;
},
clearSelections: function() {
annotations.forEach(function(annotation) {
annotation.selected = false;
});
}
}
AnnotatedImage.ViewManager = function ViewManager(canvas, src) {
this.canvas = canvas;
this.context = canvas.getContext('2d');
this.image = new Image();
this.image.onload = function() {
this.canvas.width = this.image.width;
this.canvas.height = this.image.height;
this.update();
}.bind(this);
this.image.src = src;
}
AnnotatedImage.ViewManager.prototype.update = function() {
this.context.drawImage(this.image, 0, 0);
if (this.shouldHideAnnotations || !this.annotations) {
return;
}
this.annotations.forEach(function(annotation) {
this.context.strokeStyle = '#888';
if (annotation.selected) {
this.context.strokeStyle = '#F00';
}
this.drawRect(annotation.coordinates);
}.bind(this));
}
AnnotatedImage.ViewManager.prototype.showAnnotations = function(annotations) {
this.annotations = annotations;
this.shouldHideAnnotations = false;
this.update();
}
AnnotatedImage.ViewManager.prototype.hideAnnotations = function() {
this.annotations = undefined;
this.shouldHideAnnotations = true;
this.update();
}
AnnotatedImage.ViewManager.prototype.drawRect = function(coordinates) {
var width = coordinates.bottomRight.offsetX - coordinates.topLeft.offsetX;
var height = coordinates.bottomRight.offsetY - coordinates.topLeft.offsetY;
this.context.strokeRect(coordinates.topLeft.offsetX, coordinates.topLeft.offsetY, width, height);
}
AnnotatedImage.Viewer = function Viewer() {
function link(scope, el, attrs, controller) {
var canvas = el.find('canvas');
var viewManager = new AnnotatedImage.ViewManager(canvas[0], scope.src);
canvas.bind('click', function(event) {
controller.selectAnnotation(event);
});
controller.onShowAnnotations(function(annotations) {
viewManager.showAnnotations(annotations);
});
controller.onHideAnnotations(function() {
viewManager.hideAnnotations();
});
controller.onSelectAnnotation(function(annotations) {
viewManager.showAnnotations(annotations);
});
}
return {
restrict: 'E',
require: '^annotatedImage',
template: '<canvas />',
link: link,
scope: {
src: '='
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment