Адаптивная карусель на AngularJS
Наверное, каждый начинающий web-разработчик должен написать кривую, с кучей костылей, но свою карусель. Данный материал поможет разобраться с некоторыми тонкостями фреймворка и покажет читателю как сделать карусель «angular way».
Карусель — элемент web-интерфейса, который поочередно показывает пользователю заранее подготовленные слайды с информацией.
Готовый проект можно посмотреть тут.
Идея нашей карусели: красиво выстроить карточки на экране в два уровня при помощи css свойства z-index и поочередно менять css положение карточек с анимацией изменений при помощи свойства transition.
CSS файл будет выглядеть следующим образом:
el-carousel {
width: 100%;
margin: 0;
position: relative;
z-index: 20;
}
.el-carousel .el-card {
position: absolute;
background: rgba(141, 141, 141, 0.5);
border:1px #e0e0e0 solid;
border-radius:1px;
box-shadow: 0 0 0 4px rgba(107, 108, 40, 0.25), 0 0 0 5px rgba(183, 183, 183, 0.6);
-webkit-transition: all 1s ease-in-out;
-moz-transition: all 1s ease-in-out;
-o-transition: all 1s ease-in-out;
transition: all 1s ease-in-out;
z-index: 5;
opacity: 0.2;
cursor: pointer;
}
.el-carousel .sm-el-card-1 {
height: 65%;
width: 23%;
left: 6%;
bottom: 1%;
z-index: 20;
}
.el-carousel .sm-el-card-2 {
height: 70%;
width: 25%;
left: 2%;
bottom: 7%;
opacity: 0;
}
.el-carousel .sm-el-card-3 {
height: 70%;
width: 25%;
left: 2%;
bottom: 7%;
z-index: 20;
opacity: 0.8;
}
.el-carousel .sm-el-card-4 {
height: 84%;
width: 30%;
left: 35%;
bottom: 14%;
z-index: 20;
opacity: 1;
background: rgba(141, 141, 141, 0.7);
}
.el-carousel .sm-el-card-5 {
height: 70%;
width: 25%;
left: 73%;
bottom: 7%;
z-index: 20;
opacity: 0.8;
}
.el-carousel .sm-el-card-6 {
height: 70%;
width: 25%;
left: 73%;
bottom: 7%;
opacity: 0;
}
.el-carousel .sm-el-card-7 {
height: 65%;
width: 23%;
left: 70.5%;
bottom: 1%;
}
.el-carousel .sm-el-card-8 {
height: 77%;
width: 27%;
left: 33%;
bottom: 8%;
}
.el-carousel .sm-el-card-hide {
height: 77%;
width: 27%;
left: 33%;
bottom: 8%;
opacity: 0;
}
.el-carousel .md-el-card-4 {
height: 57%;
width: 14%;
left: 2%;
bottom: 7%;
z-index: 20;
opacity: 0.6;
}
.el-carousel .md-el-card-5 {
height: 71%;
width: 16%;
left: 20.5%;
bottom: 11%;
z-index: 20;
opacity: 0.8;
}
.el-carousel .md-el-card-6 {
height: 84%;
width: 19%;
left: 40.5%;
bottom: 14%;
z-index: 20;
opacity: 1;
background: rgba(141, 141, 141, 0.7);
}
.el-carousel .md-el-card-7 {
height: 71%;
width: 16%;
left: 63.75%;
bottom: 11%;
z-index: 20;
opacity: 0.8;
}
.el-carousel .md-el-card-8 {
height: 57%;
width: 14%;
left: 84%;
bottom: 7%;
z-index: 20;
opacity: 0.6;
}
.el-carousel .md-el-card-9 {
height: 57%;
width: 14%;
left: 84%;
bottom: 7%;
opacity: 0;
}
.el-carousel .md-el-card-10 {
height: 67%;
width: 15%;
left: 62%;
bottom: 7%;
}
.el-carousel .md-el-card-1 {
height: 78%;
width: 18%;
left: 38.7%;
bottom: 9.5%;
}
.el-carousel .md-el-card-2 {
height: 67%;
width: 15%;
left: 18.7%;
bottom: 7%;
}
.el-carousel .md-el-card-3 {
height: 57%;
width: 14%;
left: 2%;
bottom: 7%;
opacity: 0;
}
.el-carousel .md-el-card-hide {
height: 78%;
width: 18%;
left: 38.7%;
bottom: 9.5%;
opacity: 0;
}
.el-carousel .lg-el-card-1 {
height: 78.7%;
width: 15%;
left: 40.5%;
bottom: 10%;
}
.el-carousel .lg-el-card-2 {
height: 65%;
width: 13%;
left: 23.5%;
bottom: 7%;
}
.el-carousel .lg-el-card-3 {
height: 52%;
width: 11%;
left: 9%;
bottom: 3%;
}
.el-carousel .lg-el-card-4 {
height: 33%;
width: 7%;
left: 1%;
bottom: 4%;
z-index: 10;
opacity: 0.4;
}
.el-carousel .lg-el-card-5 {
height: 57%;
width: 12%;
left: 10%;
bottom: 7%;
z-index: 20;
opacity: 0.6;
}
.el-carousel .lg-el-card-6 {
height: 71%;
width: 14%;
left: 25%;
bottom: 11%;
z-index: 20;
opacity: 0.8;
}
.el-carousel .lg-el-card-7 {
height: 84%;
width: 16%;
left: 42%;
bottom: 14%;
z-index: 20;
opacity: 1;
background: rgba(141, 141, 141, 0.7);
}
.el-carousel .lg-el-card-8 {
height: 71%;
width: 14%;
left: 61%;
bottom: 11%;
z-index: 20;
opacity: 0.8;
}
.el-carousel .lg-el-card-9 {
height: 57%;
width: 12%;
left: 78%;
bottom: 7%;
z-index: 20;
opacity: 0.6;
}
.el-carousel .lg-el-card-10 {
height: 33%;
width: 7%;
left: 91.32%;
bottom: 4%;
z-index: 10;
opacity: 0.4;
}
.el-carousel .lg-el-card-11 {
height: 52%;
width: 11%;
left: 77%;
bottom: 3%;
}
.el-carousel .lg-el-card-12 {
height: 65%;
width: 13%;
left: 59.5%;
bottom: 7%;
}
.el-carousel .lg-el-card-hide {
height: 78.7%;
width: 15%;
left: 40.5%;
bottom: 10%;
opacity: 0;
}
Так как карусель адаптивная — все параметры размеров и положения определяются в процентах.
Итак, попробуем реализовать нашу карусель angular-way. Чтобы каждая карточка карусели имела уникальный шаблон и действие, воспользуемся директивой и фабрикой.
Рассмотрим фабрику
В замыкании будем хранить два массива:
- list — массив html-шаблонов карточек,
- action — массив действий при клике на карточку.
Фабрика предоставляет два метода addCard и addAction, которые добавляют в массивы html-шаблон и функцию, которая должна выполниться при клике соответственно. Функция addCard принимает на вход второй, необязательный параметр, который при логическом значении true разрешает промис. Это своего рода костыль, о котором я расскажу немного позже.
Отдельно отмечу безопасное подключение зависимостей. Не углубляясь в детали, имена подключаемых зависимостей менять нельзя, так как это полностью ломает код при его минификации. Поэтому в ангуляре предусмотрено безопасное подключение зависимостей — вместо функции фабрики/контроллера/директивы передается массив, первыми элементами которого указываются строковые имена зависимостей, а последним уже сама функция с подключенными зависимостями. В данном случае минификация кода нам уже не страшна, однако нужно не забывать, что порядок перечисления строковых зависимостей в массиве должен совпадать с порядком подключения зависимостей в функцию.
'use strict';
(function () {
angular.module('carousel')
.factory('card', ['$q', function ($q) {
var list = [],
action = [],
done = $q.defer();
// добавляем новую карточку
function addCard(card, last) {
if(typeof card === 'object' && card.length > 0) {
list.push(card);
if(last) { done.resolve(); }
}
}
// добавляем действие при клике на карточку
function addAction(foo) {
action.push(foo);
}
// возвращаем методы наполнения и массивы карточек, и действий
return {
addCard: addCard,
addAction: addAction,
list: list,
action: action,
done: done.promise
};
}]);
})();
Директива html-шаблона карточек
Тут все просто. Для директивы установлен изолируемый скоуп с тремя пробрасываемыми параметрами через атрибуты элемента, на который установлена директива.
Отмечу, что для удобства в директивах можно изменять имена пробрасываемых параметров. Ключом в объекте скоупа указывается удобное для использования имя, а в его поле, после указания типа пробрасывания (= ,@, &) — имя атрибута элемента. Если атрибут элемента двойной, например last-card, то в имени элемента он указывается в виде верблюжьей нотации lastCard.
Параметр action пробрасывается через & — это означает, что на вход принимается выражение, в нашем случае — функция клика по карточке.
Параметры item и last пробрасываются через =, т.е. на вход принимается определенное значение, которое может быть задано как явно в значении атрибута, так и определено переменной скоупа, в котором находится директива, и передано в атрибут элемента переменной.
Данные параметры необходимы в том случае, когда мы не хотим создавать отдельный шаблон для каждой карточки, а делаем один шаблон и клонируем его с помощью директивы ng-repeat. В параметр item передается текущий объект данных, полученный при помощи ng-repeat. В параметр last передается специальное значение $last, которому директива ng-repeat присвоит true, если объект из рассматриваемого массива для клонирования — последний.
Для получения html-шаблона (фактически внутреннего содержимого директивы) необходимо установить параметру transclude значение true. При этом в связывающей функции директивы (link) появится пятый параметр, который представляет из себя функцию трансклюзии. Отмечу, что назначение данной функции более широкое, но в данном случае она используется только для получения внутреннего содержимого директивы.
Настало время обосновать применение костыля с явным определением последнего элемента клонирования $last. Дело в том, что пользовательская директива с transclude в паре с ng-repeat работает специфически. Это связано с последовательностью выполнения операций: первым делом angular клонирует шаблон элемента, затем выполняются остальные директивы с меньшим приоритетом, в том числе пользовательские, и только после этого клонированные шаблоны наполняются значениями из скоупа. Поэтому если явно не указать, что ng-repeat сделал свое дело — карусель не будет отображать карточки с содержимым клонированных элементов.
'use strict';
(function () {
angular.module('carousel')
.directive('elCard', ['card', function(card) {
return {
scope: {
action: '&cardAction',
item: '=elCard',
last: '=lastCard'
},
restrict: 'A',
transclude: true,
link: function(scope, elem, attr, ctrl, transclude) {
// наполняем массив действий при клике на соответствующую карточку
card.addAction(scope.action);
// наполняем массив элементов карточек
transclude(scope, function(item) {
card.addCard(item, scope.last);
elem.remove();
});
}
};
}]);
})();
Директива карусели
Директива строит нашу карусель по имеющимся шаблонам. Карусель имеет три варианта исполнения: на 3, 5 или 7 карт в первом ряду. По умолчанию выбрано 7 карточек, но предусмотрен пробрасываемый параметр elements для определения количества карт вручную. В зависимости от количества карточек определяется коэффициент пересчета высоты элемента карусели heihtCoeff.
Директива построена на трех функциях:
- changeHeight — пересчитывает высоту карусели в зависимости от ширины экрана. Данная функция выполняется не только при первом старте директивы, но и при срабатывании события изменения размера окна браузера.
- makeCards — создает необходимое количество элементов карточек. У данной функции есть два нюанса:
- количество пользовательских карточек меньше требуемого для карусели. В данном случае недостающие карточки наполняются повторяющимися имеющимися шаблонами.
- количество пользовательских карточек больше требуемого. Лишним картам присваивается положение на заднем фоне карусели и устанавливается абсолютная прозрачность. По мере движения карусели эти карточки меняются местами с уже показанными и так по кругу.
- moveCards — каждой карточке присвоена директива ng-class, которая присваивает элементу имя класса из строкового значения переменной. В нашем случае все классы, определяющие местоположение карточек, занесены в массив строковых элементов и функция всего лишь реализует продвижение «очереди» при помощи методов массива shift и push.
Для приведения в действие и остановки предусмотрены вспомогательные функции runCarousel и stopCarousel, которые периодически выполняют moveCards при помощи $interval. При переходе на другую вкладку $interval продолжает свою работу, а css свойство transition — нет, что приводит к сбоям в работе карусели. Поэтому старт и остановка карусели привязаны к событиям смены активного окна. По окончании работы директивы не забываем отвязать всех слушателей.
'use strict';
(function () {
angular.module('carousel')
.directive('elCarousel', ['$window', '$compile', '$interval', 'card', function($window, $compile, $interval, card) {
return {
scope: {
elements: '=elCarousel'
},
restrict: 'A',
link: function($scope, elem) {
var cards = [],
action = [],
heightCoeff = ($scope.elements === 3) ? 2 : ($scope.elements === 5) ? 3.3 : 3.96,
cardAmount = ($scope.elements === 3) ? 8 : ($scope.elements === 5) ? 10 : 12;
$scope.card = [];
// присваиваем элементу необходимый для работы класс
elem.addClass('el-carousel');
// выполняем подготовительные действия для обычных карточек и созданных при помощи ng-repeat
function preStartActions() {
if(card.list.length < 1) { return; }
cards = card.list;
action = card.action;
makeCards();
}
preStartActions();
card.done.then(preStartActions);
// изменяем высоту элемента в зависимости от ширины
function changeHeight() {
var carouselWidth = elem.width(),
carouselHeight = carouselWidth / heightCoeff;
elem.css('height', carouselHeight);
}
angular.element($window).bind('resize', changeHeight);
changeHeight();
// создаем DOM элементы карточек из массива
function makeCards() {
elem.empty();
var k = 0,
cardNumber = (cards.length > cardAmount) ? cards.length : cardAmount,
numClass = (cardAmount === 8) ? 'sm-' : (cardAmount === 10) ? 'md-' : 'lg-';
for(var i = 0; i < cardNumber; i++) {
var div = angular.element('');
if(i < cards.length) {
div.append(cards[i].clone());
$scope['cardAction' + i] = action[i];
} else {
div.append(cards[k].clone());
$scope['cardAction' + i] = action[k];
k = (k > cards.length - 2) ? 0 : k + 1;
}
$scope.card[i] = (i < cardAmount) ? numClass + 'el-card-' + (i + 1) : numClass + 'el-card-hide';
$compile(div)($scope);
elem.append(div);
}
}
// перемещаем карточки в порядке очереди
function moveCards() {
var lastElem = $scope.card.shift();
$scope.card.push(lastElem);
}
// старт/стоп карусели в зависимости от активности окна
var moveInterval;
runCarousel();
angular.element($window).bind('blur', stopCarousel);
function stopCarousel() {
$interval.cancel(moveInterval);
}
angular.element($window).bind('focus', runCarousel);
function runCarousel() {
moveInterval = $interval(moveCards, 2000);
}
elem.bind('$destroy', function () {
$interval.cancel(moveInterval);
angular.element($window).unbind('blur', stopCarousel);
angular.element($window).unbind('focus', runCarousel);
angular.element($window).unbind('resize', changeHeight);
});
}
};
}]);
})();
В результатате наш html код карусели может выглядеть следующим образом
{{card.name}}
Можно использовать как индивидуально написанные шаблоны, так и клонированные с помощью ng-repeat, а также комбинировать их вместе.
Спасибо за внимание, всем удачи.