Анимация в Angular-приложениях

  • Tutorial


Ни одно серьезное приложение не обходится без анимации в том или ином виде. Анимация делает приложения более современными, красивыми и зачастую — более понятными, улучшая пространственную ориентацию внутри приложения. Без обратной связи иногда трудно понять, что произошло, когда мы нажали на элемент. Раньше при необходимости добавить анимацию в приложение, я пользовался CSS-анимацией и в целом был почти доволен.


После перехода нашего продукта на Angular 2+ мы столкнулись с тем, что Ангуляр предоставляет свой механизм для описания анимации. Поскольку Ангуляр полностью владеет транзакциями DOM, то он может упростить описание анимации и мы решили попробовать отказаться от анимации на CSS. Да и в целом было интересно посмотреть, что из этого получится. За почти год разработки проекта мы так и не перешли обратно на CSS-анимацию, и я могу сказать, что можно вполне успешно жить с анимацией Ангуляра. В этой статье я расскажу, как использовать анимацию в проектах на Angular 2+ и о возможностях, которые до сих пор почему-то не описаны в официальном гайде.


Настройка анимации


Анимация в Ангуляре построена на базе Web Animations API — это стандарт, который дает гибкость JS, сохраняя производительность CSS-анимации. Подробнее об этом API вы можете почитать здесь. В браузерах, которые поддерживают этот стандарт, анимация использует тот же механизм, что и для CSS-анимации, а значит и производительность должна быть не хуже. В остальных браузерах необходимо использовать полифил. Начиная с шестой версии Ангуляра, если вы не используете AnimationBuilder напрямую (что нужно редко), включать полифил уже не надо — Ангуляр сам переключится на использование CSS-анимации для неподдерживаемых браузеров.


Для наших экспериментов с анимацией мы создадим простое ToDo-приложение и добавим в него необходимую анимацию, используя только возможности Ангуляра. Полностью код приложения вы можете увидеть в репозитории, здесь же я приведу только часть, непосредственно связанную с анимацией.


Прежде чем мы начнем разбираться с анимацией, нам надо добавить возможность использования механизма анимации в проект. Для этого необходимо всего лишь включить в проект BrowserAnimationsModule, и нам станут доступны все возможности анимации. Всего в Ангуляре есть два модуля, имеющие отношение к анимации — BrowserAnimationsModule и NoopAnimationsModule. Второй нужен для того, чтобы отключить анимацию при тестировании компонента.


Базовое использование анимации в Angular


Итак, в ToDo-приложении нам необходима следующая анимация:


  • Когда пользователь помечает какую-то задачу выполненной, этот пункт должен анимировано переключится в новое состояние. Справедливо и обратное действие.
  • Добавление или удаление новых задач в списке должно происходить анимировано.

Вот как это будет выглядеть:





Что анимировать понятно, теперь давайте рассмотрим, как нам это сделать. Вся анимация Ангуляра описывается в метаданных компонента. У нас анимироваться будет компонент TodoItem, в него мы и станем добавлять нашу анимацию. Для создания анимации в Ангуляре используется ряд сущностей, которые нам надо импортировать из модуля анимации — trigger, state, style, animate, transition, keyframes. Давайте рассмотрим, зачем нужна каждая из них, прежде чем добавлять анимацию. Всю информацию, конечно, можно найти в документации Ангуляра, но без базовых понятий мы не сможем перейти к более сложным ситуациям.


trigger — позволяет привязать вашу анимацию к компоненту или к DOM-элементам внутри компонента. Помимо указания имени триггера, мы также можем задать все состояния анимации и описать переходы между различными состояниями. После добавления триггера в метаданные компонента, его необходимо привязать к компоненту или к элементам в шаблоне, которые будут анимироваться. Как это делается, мы рассмотрим позднее. Обычно создание триггера выглядит следующим образом:


@Component({
   selector: 'my-component',
   templateUrl: 'my-component-tpl.html',
   animations: [
      trigger("myAnimationTrigger", [
         state(...),
         state(...),
         transition(...),
         transition(...)
      ])
   ]
})
class MyComponent {
}

state — позволяет задать состояние компонента, которое будет использоваться в анимации, указав имя состояния и набор стилей для данного состояния. По сути, вся анимация Ангуляра — это конечный автомат, где происходит переход из одного состояния в другое:


@Component({
   selector: 'my-component',
   templateUrl: 'my-component-tpl.html',
   animations: [
      trigger("myAnimationTrigger", [
         state('collapsed', style({ 
            height: '0px', 
            color: 'maroon', 
            borderColor: 'maroon' 
         })),
         state('expanded', style({ 
            height: '*', 
            borderColor: 'green', 
            color: 'green' 
         }))
      ])
   ]
})
class MyComponent {
}

Нужно понимать, что параметры состояния будут применены на элементе, пока он находится в данном состоянии.


style — позволяет описать список CSS-параметров и их значений, которые будут использоваться в анимации или в состоянии компонента, заданным через state. Анимировать можно только те параметры, которые браузер считает анимируемыми. Пример того, как используется style, мы видели выше в описании функции state. Но прежде чем мы пойдем дальше, я хочу остановиться на одном важном моменте при задании значений CSS-параметров.


Иногда бывает так, что вы не знаете, какое значение параметра будет во время выполнения вашего приложения — например, высоту или ширину элемента. В таком случае анимировать изменения такого параметра через CSS довольно сложно, но в Ангуляре это решается довольно просто — вам нужно использовать '*' в качестве значения параметра. Тогда Ангуляр возьмет значение из DOM.


animate — основная функция анимации. При ее использовании вам необходимо задать параметры тайминга и/или параметры стилей, которые будут изменяться во время анимации. Важный момент состоит в том, что параметры стилей, указанные в animate, будут активны только в момент анимации. Но когда она закончится, они вернутся к тем, которые диктуются состоянием элемента в DOM, если, конечно, в конце анимации компонент не окажется в одном из состояний, описанных через state. Параметры тайминга анимации могут принимать различные значения, которые должны быть знакомы вам из CSS-анимации:


animate(500, style(...))
animate("1s", style(...))
animate("100ms 0.5s", style(...))
animate("5s ease", style(...))
animate("5s 10ms cubic-bezier(.17,.67,.88,.1)", style(...))

transition — позволяет описать последовательность переходов между состояниями анимируемого элемента. Первым параметром мы определяем, когда анимация запустится. Затем мы можем указать параметры анимации, используя animate и style. Для определения параметров запуска анимации мы можем использовать следующие варианты:


  • transition("on => off", animate(...)) — запустит анимацию при переходе триггера из состояния 'on' в состояние 'off'
  • transition("on <=> off", animate(...)) — запустит анимацию при переходе из состояния 'on' в 'off' и обратно
  • transition("* => off", animate(...)) — запустит анимацию при переходе из любого состояния в состояние off. Возможно и указание обратного направления 'on => *'.
  • transition("void => *", animate(...)) — запустит анимацию, когда элемент будет добавлен в DOM. При указании обратной транзакции, '* => void', анимация запустится при удалении элемента из DOM

Также в качестве параметров запуска анимации можно указать функцию, в которую будут переданы параметры анимации, где есть поля fromState и toState. На основании значений этих полей можно принять решение, стоит ли запускать анимацию. Если функция вернет true, анимация запустится.


В движке анимации Ангуляра есть еще некоторые алиасы для задания переходов: enter/leave и increment/decrement. Первая пара, по сути, аналогична варианту * => void и void => *, а вторая позволяет запускать анимацию, когда значение триггера уменьшилось или увеличилось на единицу. Это бывает удобно, например, при реализации компонента слайдера картинок: изменяя индекс текущей картинки, вы будете управлять анимацией компонента.


По умолчанию вся анимация, указанная в транзакции, будет выполняться последовательно. Если вам необходимо выполнить параллельную анимацию, необходимо воспользоваться функцией group и обернуть в нее анимацию:


group([
   animate("1s", { background: "black" }))
   animate("2s", { color: "white" }))
])

Есть также функция sequence, которая запустит анимацию последовательно, но ее обычно не используют, так как она подразумевается по умолчанию.


keyframes — позволяет определить как будет вести себя анимация на различных этапах времени анимации. Для этого у вас есть шкала от 0 до 1, и вы можете указать, на каком этапе этого промежутка и как должны выглядеть стили вашего анимируемого элемента. Например, таким образом можно реализовать bounce-анимацию элемента:


transition('* => bouncing', [
   animate('300ms ease-in', keyframes([
      style({transform: 'translate3d(0,0,0)', offset: 0}),
      style({transform: 'translate3d(0,-10px,0)', offset: 0.5}),
      style({transform: 'translate3d(0,0,0)', offset: 1}) 
   ]))
])

Итак, мы рассмотрели базовые функции анимации в Ангуляре. Но прежде чем мы приступим к анимации нашего ToDo-списка, нам нужно рассмотреть еще два момента: как подключить анимацию в компонент и как отслеживать переходы компонента из одного состояния в другое.


Для подключения анимации в компонент есть два способа: повесить анимацию на элемент в шаблоне компонента или повесить анимацию на сам компонент. Чтобы повесить анимацию на элемент в шаблоне, достаточно просто добавить к элементу атрибут с триггером анимации и передать ему нужное состояние:


<div [@myAnimationTrigger]="myStatusExp">...</div>

В этом примере переменная myStatusExp определена в компоненте, и при ее изменении будет запускаться анимация, если указаны нужные переходы в transition.


Если же необходимо накинуть анимацию на сам компонент, для этого можно использовать декоратор @HostBinding:


class MyComponent {
   @HostBinding('@myAnimationTrigger')
   public myStatusExp;
}

Последнее, что нам осталось рассмотреть — как отслеживать изменение анимации в компоненте. Для этого можно повесить обработчики на триггер анимации, на старт и стоп анимации:


<todo-item *ngFor="let item of items"
   (@myAnimationTrigger.start)="animationStarted($event)"
   (@myAnimationTrigger.done)="animationDone($event)"
   [@myAnimationTrigger]="myStatusExp">
</todo-item>

В результате на старте или окончании анимации Ангуляр вызовет наши обработчики и передаст туда AnimationEvent со следующим содержимым:


interface AnimationEvent {
   fromState: string
   toState: string
   totalTime: number
   phaseName: string
   element: any
   triggerName: string
   disabled: boolean
}

Как видите, событие содержит много данных про анимацию. Обычно подписка на окончание анимации полезна, чтобы выполнить какое-то действие, например, удалить модальное окно из DOM.


Итак, мы рассмотрели базовые методы, с помощью которых в Ангуляре описывается анимация. Теперь давайте добавим анимацию в метаданные нашего компонента:


@Component({
...
   animations: [
      trigger('stateAnimation', [
         state('incomplete', style({ 
            'color': 'black', 
            'text-decoration': 'none' 
         })),
         state('complete', style({ 
            'color': '#d9d9d9', 
            'text-decoration': 'line-through' 
         })),
         transition('incomplete => complete', [
            style({ 'text-decoration': 'line-through' }),
            animate('0.2s')
         ]),
         transition('complete => incomplete', [
            style({ 'text-decoration': 'none' }),
            animate('0.2s')
         ])
      ]),
      trigger('todoAnimation', [
         transition(':enter', [
            style({ height: 0 }),
            animate('0.3s ease-in', style({ height: '*' }))
         ]),
         transition(':leave', [
            animate('0.3s ease-out', style({ transform: 'scale(0)' }))
         ]),
      ])
   ]
...
})
export class TodoItemComponent {
...
   @HostBinding('@todoAnimation') true;

   @HostBinding('@stateAnimation') get state() {
      return this.todo.completed ? 'complete' : 'incomplete';
   }
...
}

Полный код можно увидеть в репозитории в бранче base-animation.


Давайте рассмотрим, что делает код анимации. Мы создаем два триггера анимации: один для анимации изменения состояния задачи (stateAnimation), а второй — для анимации добавления или удаления задач из списка (todoAnimation).


Начнем со второго триггера. В нем мы задаем два перехода состояния компонента. При появлении элемента в DOM мы выставляем начальную высоту элемента в 0 и анимируем изменение высоты до значения, которое определяется содержимым элемента. При удалении элемента из DOM мы применяем трансформацию scale из рантайм-состояния, равного 1, до конечного, равного 0. Все довольно просто.


Теперь давайте рассмотрим первый триггер. В нем мы сначала определяем два состояния компонента — incomplete и complete — и указываем стили для этих состояний. Как я уже говорил выше, эти стили будут активны в компоненте, пока он находится в состоянии. Затем мы задаем два перехода между этими состояниями, где сначала применяем стили к компоненту (чтобы пользователь сразу увидел часть измененных стилей), а затем анимируем оставшиеся параметры.


Итак, мы добавили нужную анимацию в наше приложение, но немного поигравшись, поняли, что есть проблема: мы хотели, чтобы наша анимация работала, только когда пользователь совершает какие-то действия над списком задач, но она также срабатывает и при изменении фильтра списка задач и при первом наполнении списка задачами. Так происходит потому, что наша директива ngFor перестраивает наш список при изменении фильтра.





Немного поразмыслив, мы понимаем, что нам нужно как-то отключать анимацию на время, пока мы изменяем список задач, и Ангуляр дает нам такую возможность. Для этого надо добавить в шаблон компонента TodoList атрибут [@.disabled]:


<ul class="todo-list" [@.disabled]="disableAnimation">
   <app-todo-item *ngFor="let todo of todos; trackBy: trackById" [todo]="todo"></app-todo-item>
</ul>

Изменяя значения переменной компонента disableAnimation, мы можем включать и отключать анимацию компонентов TodoItem. Код управления значением переменной disableAnimation вы можете посмотреть в исходниках в репозитории.


Важный момент: если анимация отключена, обработчики старта и остановки анимации все равно будут вызываться. Чтобы понять, были ли они вызваны при отключенной анимации, нужно использовать параметр disabled в событии анимации. Если он равен true, значит анимация была отключена.


Теперь наша анимация работает как надо, но это далеко не предел возможностей анимирования в Ангуляре. Давайте рассмотрим, что еще можно сделать и что пока не описано в гайде по анимации.


Вынос анимации из компонентов и использование параметров


В рамках продукта анимация часто повторяется в нескольких компонентах, отличаясь только параметрами этой анимации — таймингами анимации или начальными и конечными значениями. Чтобы не дублировать анимацию много раз, есть два решения.


Первое что приходит в голову — вынести в отдельный файл функцию, которая на основании переданных параметров будет возвращать результат создания триггера анимации с помощью функции trigger. Вот как это выглядит:


export const todoAnimation = (timing, enterStart, enterStop, leaveStop) => {
   return trigger('todoAnimation', [
      transition(':enter', [
        style({ height: enterStart }),
        animate(timing, style({ height: enterStop }))
      ]),
      transition(':leave', [
        animate(timing, style({ transform: 'scale(' + leaveStop + ')' }))
      ])
   ]);
}

Это решение вполне рабочее, но, начиная с четвертой версии Ангуляра, были добавлены две возможности, позволяющие переиспользовать анимацию — задание параметров для функций анимации и использование анимации через useAnimation. Давайте рассмотрим их по порядку.


Итак, начиная с 4-й версии следующие функции поддерживают задание параметров анимации:


state([...], { /* options */ })
transition([...], { /* options */ })
sequence([...], { /* options */ })
group([...], { /* options */ })
query([...], { /* options */ })
animation([...], { /* options */ })
useAnimation([...], { /* options */ })
animateChild([...], { /* options */ })

Мы еще не рассматривали ряд функций из данного списка, но не переживайте: о них мы поговорим позднее. Параметры функций из данного списка различны. Так, например, функции transition, sequence, group и animation в качестве опций могут принимать два параметра: delay и params.


Параметр delay позволяет отложить запуск анимации. В качестве значений нельзя использовать значения в процентах или отрицательные.


Параметр params позволяет передать в функцию параметры, которые будут подставлены в значения при использовании конкретной функции анимации. Как это происходит, мы увидим ниже в примерах.


В качестве параметров сейчас можно использовать только параметры тайминга или параметры значений стилей. Функция state может принимать только параметр params, что логично, так как delay для нее бесполезен.


Параметры остальных функций мы рассмотрим позднее. Еще один важный момент: если вы используете подстановку параметров в функции, то обязательно должны указать дефолтные значения, иначе Ангуляр будет выдавать ошибку.


Давайте перепишем функцию анимации с использованием параметров:


export const todoAnimation = (timing, enterStart, enterStop, leaveStop) => {
   return trigger('todoAnimation', [
      transition(':enter', [
        style({ height: "{{ enterStart }}" }),
        animate("{{ timing }}", style({ height: "{{ enterStop }}" }))
      ], { params: { enterStart: 0, enterStop: 1, timings: '0.3s' } }),
      transition(':leave', [
        animate(timing, style({ transform: 'scale({{ leaveStop }})' }))
      ], { params: { leaveStop: 0, timings: '0.3s' }})
   ]);
}

Еще один важный момент: переданные параметры анимации будут подставлены в момент запуска анимации и не могут быть изменены во время анимации.
Также важно, что параметры анимации можно передать и в момент запуска анимации. Выглядеть это будет примерно вот так:


<div 
   [@fadeAnimation]="{value: 'fadeIn', params: { start: 0, end: 1, timing: 1000 } }"
>...</div>

Вторая возможность, которая была добавлена в версии 4 — набор из двух функций, animation и useAnimation. Первая позволяет вам описать анимацию, а вторая — использовать ее в метаданных компонента. Вот как это будет выглядеть при наличии fadeIn/fadeOut-анимации в компоненте:


import { animation, style, animate } from "@angular/animations";

export const fadeAnimation = animation([
   style({ opacity: "{{ from }}" }),
   animate("{{ time }}", style({ opacity: "{{ to }}" }))
], { time: "1s", to: 1, from: 0 })

Теперь используем анимацию в компоненте:


import {useAnimation, transition} from "@angular/animations";
import {fadeAnimation} from "./animations";

...
transition('* => fadeIn', [
   useAnimation(fadeAnimation, {
      from: 0,
      to: 1,
      time: '1s easy-in'
   })
]),
transition('* => fadeOut', [
   useAnimation(fadeAnimation, {
      from: 1,
      to: 0,
      time: '1s easy-out'
   })
])

Как вы могли заметить, при описании анимации через animate мы указали значения для всех параметров. Если при использовании анимации через useAnimation какой-то параметр не будет передан, то будет использоваться параметр, указанный нами в animation как дефолтный. Прежде чем мы используем новые функции анимации, нам нужно рассмотреть еще две новые функции.


Функции query и stager


Функция query очень похожа на element.querySelectorAll. Она позволяет выбрать элементы из DOM и запустить на них анимацию пачкой или изменить стилизацию этих элементов. Это позволит нам перенести анимацию из компонента TodoItem на уровень выше в TodoList и сделать анимацию более гибкой, например, запустить анимацию каждого четного элемента. Вот как выглядит использование функции query:


query('*', style({ opacity: 0 }))
query('div, .inner, #id', [
   animate(1000, style({ opacity: 1 }))
])

В качестве первого параметра указывается селектор, по которому будут выбраны элементы. На текущий момент поддерживаются следующие варианты селекторов:


  • Обычный CSS-селектор — в качестве селектора могут использоваться классические CSS-селекторы, например, вот такой query('div, #id, .class, [attr], a + b')
  • Использование алиасов :enter и :leave — позволяет выбрать элементы, добавленные или удаленные в DOM Ангуляром
  • @triggerName или @* — позволяет выбрать либо элементы с заданным триггером, либо все элементы, у которых задан какой-либо триггер
  • :animating — позволяет выбрать все элементы, которые сейчас анимируются
  • :self — позволяют выбрать сам элемент, относительно которого вызывается query

Все указанные выше селекторы можно комбинировать, например, вот таким образом:


query(':self, .record:enter, .record:leave, @subTrigger', [...])

Как видите, это дает много возможностей для создания более сложной анимации элементов.


Функция query также может принимать параметры при ее использовании и кроме параметров delay и params, о которых мы говорили выше, она также поддерживает еще два — optional и limit.


По умолчанию, если под ваш селектор не нашлось ни одного элемента, будет сгенерировано исключение. Если для вас отсутствие элементов не является ошибочной ситуацией, вы можете указать в параметре optional значение true. В этом случае отсутствие элементов будет считаться нормальной ситуацией.


Второй параметр, limit, позволяет выбрать через query не все элементы, а только заданное количество. Если указать в параметре limit отрицательное значение, элементы будут выбраны с конца списка.


Вторая функция, stagger, полезна при совместном использовании с query-анимацией вот в каком случае. Предположим, что у нас есть код, который через ngFor показывает нотификации пользователю:


<div [@notificationAnimation]="notifications.length">
   <div *ngFor="let notification of notifications">
      {{ notification }}
   </div>
</div>

И есть анимация fade на добавляемых элементах через использование query:


trigger('notificationAnimation', [
   transition('* => *', [
      query(':enter', [
         style({ opacity: 0 }),
         animate('1s', style({ opacity: 1 }))
      ])
   ])
])

Если мы запустим приложение, то увидим, что появление списка нотификаций происходит единовременно, а это совсем не то, чего бы хотелось. Нам бы хотелось, чтобы нотификации появлялись по очереди, с небольшой задержкой, и так же исчезали. Для реализации этого эффекта мы можем воспользоваться функцией stagger:


trigger('notificationAnimation', [
   transition('* => *', [
      query(':enter', stagger('100ms', [
         animate('1s', style({ opacity: 1 }))
      ])
   ])
])

Функция stagger добавит задержку перед запуском анимации наших элементов, что сделает их появление более плавным и аккуратным. Чтобы сделать плавным также и исчезновение нотификаций, надо обратить поведение функции stagger. Для этого мы можем либо указать отрицательное значение в параметрах вызова функции, либо просто добавить параметр reverse:


stagger('100ms reverse', [...])

Итак, давайте перепишем анимацию задач с использованием функции query. Для этого перенесем ее из компонента TodoItem в компонент TodoList:


@Component({
   selector: 'app-todo-list',
   templateUrl: './todo-list.component.html',
   styleUrls: ['./todo-list.component.css'],
   animations: [
      trigger('todoList', [
         transition('* => *', [
            query(':enter', [
               style({ height: 0 }),
               animate('0.3s ease-in', style({ height: '*' }))
            ], { optional: true }),
            query(':leave', [
               animate('0.3s ease-out', style({ transform: 'scale(0)' }))
            ], { optional: true })
         ])
      ])
   ]
})
export class TodoListComponent implements OnInit, AfterViewInit {
...
}

И подключим ее в шаблоне нашего списка дел:


<ul class="todo-list" [@.disabled]="disableAnimation" [@todoList]="todos.length">
   <app-todo-item
      *ngFor="let todo of todos; trackBy: trackById"
      [todo]="todo"
      (itemRemoved)="remove($event)"
      (itemModified)="update($event)"
   ></app-todo-item>
</ul>

Поигравшись с анимацией, мы увидим, что появление элементов работает как надо, а вот удаление задач из списка отрабатывается без анимации. Это происходит из-за того, что перестройка списка дел происходит раньше, чем мы успеваем провести анимацию. Прежде чем исправить возникшую проблему, необходимо рассмотреть еще одно нововведение анимации.


Анимация дочерних элементов


Если в приложении необходимо анимировать несколько групп элементов одновременно, причем одна из групп является родителем в DOM для второй, то анимация родительских элементов будет более приоритетна над анимацией дочерних, и анимация дочерних элементов будет заблокирована. Предположим, что в шаблоне компонента есть следующий код:


<div [@parentAnimation]="exp">
   <header>Hello</header>
   <div [@childAnimation]="exp">
      one
   </div>
   <div [@childAnimation]="exp">
      two
   </div>
   <div [@childAnimation]="exp">
      three
   </div>
</div>

Как видите, родительская и дочерние анимации запускаются при изменении одной и той же переменной компонента, т.е. одновременно. Если мы просто опишем два триггера анимации в компоненте, дочерняя анимация никогда не будет отрабатывать.


Чтобы исправить эту проблему, в анимацию Ангуляра была добавлена функция animateChild, которая позволяет в рамках родительской анимации управлять дочерней анимацией. И тогда анимация в компоненте будет выглядеть следующим образом:


@Component({
   selector: 'parent-child-component',
   animations: [
      trigger('parentAnimation', [
         transition('false => true', [
            query('header', [
               style({ opacity: 0 }),
               animate(500, style({ opacity: 1 }))
            ]),
            query('@childAnimation', [
               animateChild()
            ])
         ])
      ]),
      trigger('childAnimation', [
         transition('false => true', [
            style({ opacity: 0 }),
            animate(500, style({ opacity: 1 }))
         ])
      ])
   ]
})

Функция animateChild поддерживает задание трех параметров: два из них аналогичны тем, что мы уже видели у других функций, а третий параметр — это duration. С помощью него мы может из родительской анимации управлять длительностью дочерней анимации, в том числе даже отменить ее, указав 0 в качестве длительности.


Однако в рамках нашего приложения списка задач мы воспользуемся animateChild() для того, чтобы исправить проблему с удалением задач из списка. Для этого нам придется немного исправить шаблон списка задач, добавив один уровень в отображение списка:


<ul class="todo-list">
   <li *ngFor="let todo of todos; trackBy: trackById" @todoList>
      <app-todo-item
         @todoItem
         [todo]="todo"
         (itemRemoved)="remove($event)"
         (itemModified)="update($event)"
      ></app-todo-item>
   </li>
</ul>

И переделать нашу анимацию следующим образом:


@Component({
   selector: 'app-todo-list',
   templateUrl: './todo-list.component.html',
   styleUrls: ['./todo-list.component.css'],
   animations: [
      trigger('todoList', [
         transition(':enter, :leave', [
            query('@*', animateChild())
         ])
      ]),
      trigger('todoItem', [
         transition(':enter', [
            useAnimation(enterAnimation)
         ]),
         transition(':leave', [
            useAnimation(leaveAnimation)
         ])
      ])
   ]
})

Теперь при попытке удалить задачу из списка мы увидим, что анимация отрабатывает, и только после этого происходит удаление элемента из DOM. Полный код вы можете увидеть в репозитории в бранче child-animation.


В работе дочерней анимации есть один важный момент. Когда animateChild запускает дочернюю анимацию, она сначала берет значения первого кадра анимации и применяет его к элементам и только после этого запускает анимацию. Это позволяет задать начальное состояние для дочерней анимации, что избавляет родительскую анимацию от необходимости устанавливать начальные значения дочерней анимации.


Использование плеера анимации


К сожалению, не всегда есть возможность определить анимацию элементов только через метаданные компонента и функции анимации.


Например, вы создаете компонент отображения нотификаций, и вам необходимо сбрасывать анимацию исчезания, когда юзер навел мышку на нотификацию, и запускать снова, когда он увел мышку в сторону. Такое поведение проще сделать, управляя анимацией вручную.


Конечно, такие ситуации возникают довольно редко, но если вы столкнулись с такой проблемой, то можете использовать плеер анимации Ангуляра напрямую. Это низкоуровневый способ управления анимацией, который потребует от вас четкого понимания, что вы делаете и зачем. Запуск анимации через триггеры делает за вас много работы, например, он отслеживает состояние компонента и при его изменении сам отменяет предыдущую анимацию.


Для того, чтобы воспользоваться билдером анимации, его необходимо добавить в компонент через DI. Затем создать новую фабрику нужной вам анимации и с помощью этой фабрики создать плеер анимации для нужного элемента. Вот как это будет выглядеть:


import { AnimationBuilder } from "@angular/animations";

@Component({...})
class MyCmp {
   constructor(public builder: AnimationBuilder) {}

   animate() {
      const factory = this.builder.build([
         // тут будет наша анимация
      ]);

      const player = factory.create(this.someElement);

      player.play();
   }
}

Плеер анимации содержит довольно много возможностей для работы с вашей анимацией, а именно:


Функции для управления анимацией – play, pause, restart, finish, reset. Полагаю, из названия функций понятно, что они делают. Также плеер анимации позволяет вам подписываться на события анимации, используя методы onDone(fn: () => void): void и onStart(fn: () => void): void.


В нашем простом приложении нет необходимости в низкоуровневой работе с анимацией, но для примера мы добавим в список прогресс-бар, который будет отображать загрузку списка задач с сервера.


У нас, конечно, нет сервера в приложении, поэтому долгую загрузку мы будем имитировать. Я не буду приводить тут все изменения, сделанные в приложении, их вы можете увидеть в бранче animation-player в репозитории.




Рассмотрим только важные части кода. В нашем компоненте прогресс-бара есть метод установки текущего процента:


set percentage(p: number) {
   const lastPercentage = this._percentage;
   this._percentage = p;

   if (this.player) {
      this.player.destroy();
   }

   const factory = this._builder.build([
      style({ width: lastPercentage + '%' }),
      animate('777ms cubic-bezier(.35, 0, .25, 1)', style({ width: p + '%' }))
   ]);
   this.player = factory.create(this.loadingBar.nativeElement, {});
   this.player.play();
}

Давайте рассмотрим, что тут происходит. Когда приходит новое состояние прогресса, мы удаляем старый плеер анимации, если он был создан. Это прервет выполняющуюся анимацию. Затем мы создаем новую анимацию, которая передвинет ползунок процента с текущего значения до следующего, и запускаем эту анимацию на элементе. Опять же, ничего сложного. Код конечно не идеален, но для показа как работает плеер анимации вполне достаточен. В нашем проекте я всего пару раз пользовался плеером анимации напрямую, да и то в последствии переписал код на классическое использование анимации через метаданные компонента.


Анимация через роутинг


Итак, у нас есть довольно много возможностей для анимации элементов DOM внутри компонентов. Но этого недостаточно. Если компонент изменяется с использованием роутинга, было бы здорово научить роутер активировать нужную нам анимацию при смене содержимого аутлета.


К сожалению, из-за принципов работы директивы router-outlet мы не можем просто повесить на него триггер анимации, поскольку компоненты, добавляемые роутингом, вставляются не внутрь router-outlet, а после директивы. Для решения этой проблемы нам нужно обернуть router-outlet новым элементом и повесить на него триггер анимации. Но как получить состояние роутинга в используемом триггере? В этом нет ничего сложного, достаточно воспользоваться переменными шаблона:


<div [@routeAnimation]="prepRouteState(routerOutlet)">
   <router-outlet #routerOutlet="outlet"></div>
<div>

Как видите, мы передаем состояние роутинга функции prepRouteState, где нужно будет на основании состояния роутинга вернуть нужное состояние триггера:


@Component({
   animations: [
      trigger('routeAnimation', [
         transition('homePage => supportPage', [
            // ...
         ]),
         transition('supportPage => homePage', [
            // ...
         ])
      ])
   ]
})
class AppComponent {
   prepRouteState(outlet: any) {
      return outlet.activatedRouteData['animation'] || 'firstPage'; 
   }
}

Причем для определения состояния триггера анимации мы можем использовать любые параметры роутинга, например, параметры data, заданные при описании роутинга:


const ROUTES = [
   { path: '',
      component: HomePageComponent,
      data: {
         animation: 'homePage'
      }
      },
      { path: 'support',
         component: SupportPageComponent,
         data: {
            animation: 'supportPage'
         }
   }
]

Итак, давайте добавим анимацию роутинга в наше приложение:


@Component({
  ...
  animations: [
    trigger('routeAnimation', [
      transition('completed <=> all, active <=> all, active <=> completed', [
        query(':self', style({ height: '*', width: '*' })),
        query(':enter, :leave', style({ position: 'relative' })),
        query(':leave', style({ transform: 'scale(1)' })),
        query(':enter', style({ transform: 'scale(0)' })),
        group([
          query(':leave', group([
            animate('0.4s cubic-bezier(.35,0,.25,1)', style({ 
               transform: 'scale(0)' 
            })),
            animateChild()
          ])),
          query(':enter', group([
            animate('0.4s cubic-bezier(.35, 0, .25, 1)', style({ 
               transform: 'scale(1)' 
            })),
            animateChild()
          ]))
        ]),
        query(':self', style({ height: '*', width: '*' })),
      ])
    ])
  ]
})
export class AppComponent {

  prepRouteState(outlet: any) {
    if (outlet.isActivated) {
      return outlet.activatedRoute.data.getValue()['status'] || 'all';
    }
  }

}

Давайте рассмотрим, что же делает наш код анимации. Когда происходит изменение триггера анимации со значения all на значение active, срабатывает транзакция анимации. Первое, что мы делаем, это задаем начальные значения элементов. При этом мы используем селектор контейнера :self, а также селекторы добавленных и удаленных элементов из DOM. Важный момент, это код query(':self', style({ height: '*', width: '*' })), который задаст нашему контейнеру высоту и ширину, взяв её из параметров элемента в DOM на текущий момент. Для нас это будут параметры странички с которой мы переходим.


Когда роутер сменяет содержимое аутлета, старая страничка удаляется из DOM, а новая — добавляется. Этот процесс можно обработать с помощью enter/leave. Поскольку нам нужно, чтобы анимация смены страниц происходила одновременно, мы обернули анимацию в функцию group. Затем мы выставляем новые параметры высоты и ширины для нашего контейнера, которые Ангуляр возьмет уже с новой странички роутинга.


И последнее, что мы сделали в описании анимации — добавили animateChild в анимацию каждой страницы, чтобы дать возможность содержимому страниц выполнить анимацию, указанную в компонентах самих страниц. Полный код можно найти в репозитории в основной ветке проекта.


Заключение


В этой статье мы рассмотрели, как работать с анимацией Ангуляра. Возможно, вы столкнетесь с задачами, которые трудно будет реализовать таким путем, но тут можно сказать, что команда Ангуляра постоянно развивает модуль анимации и, возможно, решение ваших проблем появится в новой версии. В нашей же команде мы пришли к выводу, что анимация Ангуляра позволяет нам реализовать все наши хотелки, а некоторые вещи делает гораздо проще. Будет интересно услышать о вашем опыте работы с анимацией в Ангуляр-приложениях.

InfoWatch
Company

Comments 5

    0
    Вот каких статей на русском языке по Angular'у не хватало — так это об анимациях. Спасибо за статью!
      +1

      В триггере routeAnimation что-то у вас не так.


      Оба компонента и тот что :leave и тот что :enter имеют position: relative; Значит пока мы скейлим их они будут стоять рядом один под другим. А style({ height: '*', width: '*' }) и overflow: hidden на обертке делают так, что анимацию исчезновения мы никогда не увидим. (Что подтверждается в демке на https://stackblitz.com/github/KyKyPy3/todo/ )
      Тогда весь код, что касается leave в примере вообще не нужен, эффект тот же самый.

        0
        Да Вы правы, анимация будет не идеальна. Но у меня не стояло задачи сделать красивую анимашку, я старался в основном показать как работать с анимацией в ангуляре, максимально охватив ее возможности в одном примере. Статья и так получилась огромной.
        0
        Отличная статья. Спасибо! Один вопрос: не смог найти информацию о том, что в 6 версии полифил не нужен, ни одна статья с описанием новых возможностей 6 версии об этом не пишет.
          0
          Об этом сказано в версии документации которая сейчас в разработке. Вот тут в самом начале Вы можете это найти — next.angular.io/guide/animations

        Only users with full accounts can post comments. Log in, please.