Directivas
Las directivas son básicamente funciones que se ejecutan cuando el compilador de Angular los encuentra en el DOM.
Estas funciones pueden hacer de todo, pero normalmente, las utilizamos para tareas como definir el comportamiento del elemento, o ejecutar transformaciones del DOM.
Las directivas suelen presentarse como atributos, nombres de elementos o nombres de clases.
Además de permitirnos la flexibilidad para crear directivas, AngularJS viene de serie con un conjunto de directivas que además podremos extender.
Carousel
Hoy vamos a ver como utilizar las directivas de AngularJS para construir un elemento complejo, como puede ser un visor de imágenes en formato carousel, tomando la idea de este fantástico tutorial.
En primer lugar, nos descargaremos las herramientas necesarias para reconocer gestos y poner unos iconos para las flechas que indiquen el cambio de imagen, además del script de Angular. Descargamos:
- Font-awesome (iconos): http://fortawesome.github.io/Font-Awesome/
- Hammer.js (gestos): http://hammerjs.github.io/
- Angular.js: https://angularjs.org/
Para nuestro carousel, vamos a utilizar un elemento central DIV en el que se mostrará la imagen principal. Este elemento tendrá unas dimensiones fijas, y queremos que la imagen ocupe todo el DIV sin distorsionarse, ampliándose y recortando la parte de imagen que sobre en caso necesario. Esto lo podemos conseguir aplicando un CSS como el siguiente:
.fill-bg {
background-image: url('mi-url');
background-size : 'cover';
background-repeat : 'no-repeat';
background-position : 'center center';
}
Directiva básica
Pongamos que no queremos crear una clase CSS separada para cada imagen, y queremos automatizar el proceso. Podemos crear una directiva de angular que justamente establezca estos atributos de CSS a nuestra imagen, y aplique la URL deseada.
A esta directiva la llamaremos myBackgroundImage
DETALLE: Se suele utilizar el prefijo my para diferenciar aquellas directivas que hemos creado nosotros.
Nuestro script JS quedaría así:
angular.
module('myApp', []).
directive('myBackgroundImage', function () {
return function (scope, element, attrs) {
element.css({
'background-image': 'url(' + attrs.myBackgroundImage + ')',
'background-size': 'cover',
'background-repeat': 'no-repeat',
'background-position': 'center center'
});
};
});
Y lo utilizaríamos del siguiente modo, en index.html:
<div my-background-image='url/de/mi/imagen'></div>
No obstante, en nuestro caso queremos tener un array de imágenes y cargar una de ellas como imagen seleccionada (esto lo haremos en un controlador específico para nuestra directiva). Además, queremos que al reemplazar la imagen seleccionada, automáticamente se actualice la imagen que mostramos en nuestro DIV.
Patrón de diseño de directivas I: Watch & Update
Un patron muy común al diseñar directivas es el de observar un valor (mediante $watch()), y actualizar el DOM en caso de que dicho valor cambie, para reflejar nuestro estado interno, que es justo lo que queremos.
Para ver como funciona este patron de diseño, modificaremos nuestra directiva myBackgroundImage para actualizar la url mediante $watch, y crearemos un pequeño controlador con un array de imágenes y un timeout para ir actualizando cada cierto tiempo la imagen principal.
Veamos como quedaría nuestro código:
script.js:
angular.
module('myApp', []).
directive('myBackgroundImage', function () {
return function (scope, element, attrs) {
scope.$watch(attrs.myBackgroundImage, function(v) {
element.css({
'background-image': 'url(' + v + ')',
'background-size': 'cover',
'background-repeat': 'no-repeat',
'background-position': 'center center'
});
});
};
});
function CarouselController($scope, $timeout) {
$scope.index = 0;
$scope.images = [
"https://c1.staticflickr.com/5/4019/4407240793_eb4bbff4d0_z.jpg?zz=1",
"http://www.fotolibre.org/albums/userpics/10013/normal_Sagrada%20Familia.jpg",
"http://upload.wikimedia.org/wikipedia/commons/3/39/Kuala_Lumpur_Batu_Caves_0001.jpg",
"https://c1.staticflickr.com/9/8364/8295250491_c83064a545.jpg"];
$scope.image = $scope.images[$scope.index];
$scope.nextImage = function() {
$scope.index = ($scope.index + 1) % $scope.images.length;
$scope.image = $scope.images[$scope.index];
};
var nextImageTimeout = function() {
$scope.nextImage();
$timeout(nextImageTimeout, 5 * 1000);
};
$timeout(nextImageTimeout, 5 * 1000);
}
index.html:
<html>
<head>
<!-- CSS -->
<link rel="stylesheet" href="style.css">
<!-- JS -->
<script src="angular.js"></script>
<script src="script.js"></script>
</head>
<body>
<div ng-app="myApp">
<div>
<h2>Carousel demo</h2>
<div my-background-image='image' ng-controller="CarouselController" class="wide"></div>
<div>
</body>
</html>
style.css:
div.wide {
width: 400px;
height: 300px;
border: 1px solid orange;
}
Podemos ver como el mecanismo de actualización de la imagen es de lo más simple, y nuestra directiva nos abstrae de ensuciarnos las manos para cambiar la imagen.
Sencillamente asociamos nuestra directiva myBackgroundImage con la variable image del controlador. Si el controlador actualiza dicha variable, nuestra directiva se encarga de actualizar la imagen en el DOM.
Patrón de diseño de directivas II: Handlers de eventos externos que llaman a $apply
Otro propósito muy común de las directivas AngularJS es el de conectar controladores de eventos para actualizar el ámbito (scope) de Angular.
Dos detalles interesantes de los scope, es que poseen las funciones:
- $apply: Notifica a AngularJS que ha ocurrido un evento que podría requerir actualizar la vista
- $eval: Ejecuta su parámetro de forma segura dentro de su ámbito.
Vamos a completar ahora nuestro carousel con los botones “previa” y “siguiente” para poder actualizar la imagen, así como del reconocimiento de gestos para poder realizar el mismo cambio a través del típico gesto “swipe”.
Para ello, crearemos dos nuevas directivas swipeLeft y swipeRight, a través de las cuales pasaremos a la directiva la función a ejecutar cuando reconozca el gesto swipe (funciones que definiremos en nuestro controlador). Nuestra directiva reconocerá los gestos gracias a la integración con la librería Hammer.js, como veremos en breve.
Por otro lado, utilizaremos la librería font-awesome para colocar un par de iconos que actuarán como botones, para pasar a la imagen anterior o posterior.
Vayamos primero a ver como quedaría nuestro HTML:
index.html:
<html>
<head>
<!-- CSS -->
<link rel="stylesheet" href="font-awesome-4.2.0/css/font-awesome.min.css">
<link rel="stylesheet" href="style.css">
<!-- JS -->
<script src="angular.js"></script>
<script src="hammer.js"></script>
<script src="script.js"></script>
</head>
<body>
<div ng-app="myApp">
<div>
<h2>Carousel demo</h2>
<div ng-controller="CarouselController">
<i ng-click="prevImage()" class="fa fa-angle-left fa-5x pointer"></i>
<div my-background-image='image' class="wide" swipe-left="nextImage()" swipe-right="prevImage()"></div>
<i ng-click="nextImage()" class="fa fa-angle-right fa-5x pointer"></i>
</div>
<div>
</body>
</html>
Es importante destacar que incluimos todo el contenido del carrusel dentro de un nuevo DIV, para referenciar el controlador.
En dicho DIV, tenemos un par de iconos que hacen las veces de botón gracias al uso de la directiva ng-click.
Además, disponemos de nuestro div con la imagen principal, que además de utilizar nuestra directiva my-background-image, utiliza también dos nuevas directivas swipeLeft y swipeRight, a las que pasa sendas funciones del controlador.
Nótese el uso de display: inline-block en los estilos para acomodar los iconos a los lados de la imagen:
style.css:
div.wide {
width: 400px;
height: 300px;
border: 1px solid orange;
display: inline-block;
}
.pointer {
cursor: pointer;
display: inline-block;
margin-top: 120px;
vertical-align: top;
}
Respecto a nuestro controlador, el único cambio será añadir la función $scope.prevImage, de forma similar al nextImage que ya teníamos:
$scope.prevImage = function() {
$scope.index = ($scope.index - 1 >= 0 ? $scope.index - 1 : $scope.images.length - 1);
$scope.image = $scope.images[$scope.index];
};
Analizemos ya como sería una de estas nuevas directivas:
.directive('swipeLeft', function() {
return function(scope, element, attrs) {
angular.element(document).ready(function () {
var hammerSwipeLeft = new Hammer(element[0]);
hammerSwipeLeft.on('swipeleft', function() {
scope.$eval(attrs.swipeLeft);
scope.$apply();
});
});
};
})
Lo que hacemos es declarar un objeto Hammer, para vincular la función que queremos al gesto deseado.
Como Hammer.js requiere que el DOM ya este cargado, realizaremos las llamadas dentro de angular.element(document).ready().
- new Hammer(element[0]): Creamos un objeto Hammer de reconocimiento de gestos vinculado al elemento HTML que hace uso de la directiva.
- hammerSwipeLeft.on(‘swipeLeft’, function()´): Asociamos al evento swipeLeft (uno de los eventos que define Hammer.js) con la función que qheremos.
- scope.$eval(attrs.swipeLeft): Hemos visto como al usar nuestra directiva, pasábamos como argumento la función nextImage(). Si analizamos attrs.swipeLeft veremos como se corresponde con el valor nextImage(), pero ¡es un string! Utilizamos $eval(str) para ejecutar la función que define ese string.
- scope.$apply(): Dado que hemos ejecutado la función nextImage desde un evento externo, le anunciamos a AngularJS que es probable que deba actualizar las vistas del scope.
A continuación, publicamos todo el código de nuestro script, donde además añadimos un método a nuestro controlador (setImages) para poder definir el contenido de las imágenes desde el exterior:
script.js:
angular.
module('myApp', []).
directive('myBackgroundImage', function () {
return function (scope, element, attrs) {
scope.$watch(attrs.myBackgroundImage, function(v) {
element.css({
'background-image': 'url(' + v + ')',
'background-size': 'cover',
'background-repeat': 'no-repeat',
'background-position': 'center center'
});
});
};
}).directive('swipeLeft', function() {
return function(scope, element, attrs) {
angular.element(document).ready(function () {
var hammerSwipeLeft = new Hammer(element[0]);
hammerSwipeLeft.on('swipeleft', function() {
scope.$eval(attrs.swipeLeft);
scope.$apply();
});
});
};
}).directive('swipeRight', function() {
return function(scope, element, attrs) {
angular.element(document).ready(function () {
var hammerSwipeRight = new Hammer(element[0]);
hammerSwipeRight.on('swiperight', function() {
scope.$eval(attrs.swipeRight);
scope.$apply();
});
});
};
});
function CarouselController($scope, $timeout) {
$scope.index = 0;
$scope.images = [];
$scope.image = "";
$scope.setImages = function(images) {
$scope.images = images;
$scope.image = images[0];
$scope.index = 0;
};
$scope.nextImage = function() {
$scope.index = ($scope.index + 1) % $scope.images.length;
$scope.image = $scope.images[$scope.index];
};
$scope.prevImage = function() {
$scope.index = ($scope.index - 1 >= 0 ? $scope.index - 1 : $scope.images.length - 1);
$scope.image = $scope.images[$scope.index];
};
var nextImageTimeout = function() {
$scope.nextImage();
$timeout(nextImageTimeout, 5 * 1000);
};
$timeout(nextImageTimeout, 5 * 1000);
}
Definiríamos las imágenes al declarar el controlador, con la directiva ng-init, y el método setImages que hemos creado antes:
<div ng-controller="CarouselController" ng-init='setImages(["url/img1.jpg", "url/img2.jpg", "..."])'>
<!-- icons and main image -->
</div>
Patrón de diseño de directivas III: Creando componentes anidados y templates
En determinadas ocasiones, es muy util poder agrupar un conjunto de elementos HTML dentro de una directiva. Por ejemplo, si vamos a reutilizar varias veces nuestro carousel, igual tendría sentido crear una directiva que directamente sea un elemento (algo del estilo < carousel />), que me ahorre unas cuantas lineas de HTML.
Para una mayor flexibilidad, lo que vamos a hacer es mantener las directivas que habíamos creado antes, y las reutilizaremos dentro de una nueva directiva:
directive('carousel', function() {
return {
restrict : 'E',
require : 'ngImages',
scope : {
images : '=ngImages'
},
controller : CarouselController,
template : "<i ng-click='prevImage()' class='fa fa-angle-left fa-5x pointer'></i>" +
"<div my-background-image='images[index]' class='wide' swipe-left='nextImage()' swipe-right='prevImage()'></div>" +
"<i ng-click='nextImage()' class='fa fa-angle-right fa-5x pointer'></i>",
link : function(scope, element, attrs) {}
};
});
Expliquemos con detalle lo que esta pasando:
-
restrict: Nos permite restringir el tipo de directiva. Habitualmente ‘E’ (elemento) o ‘A’ (atributo).
-
require: ‘ngImages’: Nuestra directiva-elemento, requiere que se le pase el parámetro ng-images. Recordemos que Angular pasa automaticamente del formato camelCase al formato con guiones.
-
scope:{ images: ‘=ngImages’}: Estamos definiendo un scope privado para nuestro controlador, con la variable images, a la que asignamos el valor obtenido a través del parámetro ng-images.
-
controller: Asignamos un controlador a nuestra directiva.
-
template: Creamos un template para nuestra directiva.
-
link: Lo que antes hacíamos de return function(scope, element, attrs){}, en realidad es un atajo de: return { link: function(scope, element, attrs){} };. Cuando definimos una directiva, podemos actuar sobre ella en varias etapas de su procesado interno (preLink, link, postLink, compile…). La etapa link es la más habitual para asociar comportamiento a nuestra directiva.
Una vez creada esta directiva, podemos insertar un carousel completo, tan solo con la siguiente línea en nuestro HTML:
<carousel ng-images="['url/img1.jpg', 'url/img2.jpg'" />
Obteniendo un resultado como el de la imagen siguiente, completamente funcional:
Esto es todo por hoy. ¡Saludos!