Адаптивная карусель на AngularJS

       Наверное, каждый начинающий web-разработчик должен написать кривую, с кучей костылей, но свою карусель. Данный материал поможет разобраться с некоторыми тонкостями фреймворка и покажет читателю как сделать карусель «angular way».
       Карусель — элемент web-интерфейса, который поочередно показывает пользователю заранее подготовленные слайды с информацией.

пример карусели

       Готовый проект можно посмотреть тут.
       Идея нашей карусели: красиво выстроить карточки на экране в два уровня при помощи css свойства z-index и поочередно менять css положение карточек с анимацией изменений при помощи свойства transition.
       CSS файл будет выглядеть следующим образом:

просмотреть 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 — создает необходимое количество элементов карточек. У данной функции есть два нюанса:
    1. количество пользовательских карточек меньше требуемого для карусели. В данном случае недостающие карточки наполняются повторяющимися имеющимися шаблонами.
    2. количество пользовательских карточек больше требуемого. Лишним картам присваивается положение на заднем фоне карусели и устанавливается абсолютная прозрачность. По мере движения карусели эти карточки меняются местами с уже показанными и так по кругу.

  • 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}} {{card.alt}}/
javascript


       Можно использовать как индивидуально написанные шаблоны, так и клонированные с помощью ng-repeat, а также комбинировать их вместе.

Спасибо за внимание, всем удачи.

© Habrahabr.ru