Перейти к содержимому

35. Angular Animations

Angular Animations — это мощный механизм анимаций, встроенный во фреймворк. Он работает поверх Web Animations API и даёт декларативный синтаксис прямо в компонентах. Забудь про ручной CSS — Angular умеет анимировать вход, выход, состояния и даже дочерние элементы 🚀


// app.config.ts (Standalone)
import { provideAnimations } from '@angular/platform-browser/animations';
// или для No-op (для тестов):
import { provideNoopAnimations } from '@angular/platform-browser/animations';
export const appConfig: ApplicationConfig = {
providers: [
provideAnimations()
]
};

trigger() — точка входа. Каждый триггер имеет имя и список state/transition:

import {
trigger, state, style, animate,
transition, keyframes, group, sequence, animateChild, query
} from '@angular/animations';
@Component({
selector: 'app-card',
animations: [
trigger('cardState', [
state('visible', style({ opacity: 1, transform: 'scale(1)' })),
state('hidden', style({ opacity: 0, transform: 'scale(0.8)' })),
transition('hidden => visible', animate('300ms ease-out')),
transition('visible => hidden', animate('200ms ease-in')),
])
],
template: `
<div [@cardState]="isVisible ? 'visible' : 'hidden'">
Контент карточки
</div>
<button (click)="isVisible = !isVisible">Переключить</button>
`
})
export class CardComponent {
isVisible = true;
}

state() описывает конечное состояние элемента. style() — это CSS-снимок:

state('expanded', style({
height: '*', // * означает "вычислить автоматически"
opacity: 1,
padding: '16px',
})),
state('collapsed', style({
height: '0px',
opacity: 0,
padding: '0px',
overflow: 'hidden',
})),

transition() описывает переход между состояниями:

// Конкретный переход
transition('collapsed => expanded', animate('400ms cubic-bezier(0.4, 0, 0.2, 1)')),
// Оба направления одновременно
transition('collapsed <=> expanded', animate('300ms ease-in-out')),
// Любой переход в состояние
transition('* => expanded', animate('300ms')),
// Вход и выход (специальные псевдо-состояния)
transition(':enter', [
style({ opacity: 0, transform: 'translateY(-10px)' }),
animate('300ms ease-out', style({ opacity: 1, transform: 'translateY(0)' }))
]),
transition(':leave', [
animate('200ms ease-in', style({ opacity: 0, transform: 'translateY(10px)' }))
]),

trigger('bounce', [
transition(':enter', [
animate('600ms', keyframes([
style({ transform: 'translateY(-100%)', offset: 0 }),
style({ transform: 'translateY(10px)', offset: 0.7 }),
style({ transform: 'translateY(-5px)', offset: 0.85 }),
style({ transform: 'translateY(0)', offset: 1.0 }),
]))
])
])

group() запускает несколько анимаций одновременно:

transition(':enter', [
group([
animate('400ms ease-out', style({ opacity: 1 })),
animate('400ms ease-out', style({ transform: 'translateX(0)' })),
])
])

sequence() выполняет анимации одну за другой:

transition(':enter', [
sequence([
animate('200ms', style({ backgroundColor: '#dd0031' })),
animate('200ms', style({ transform: 'scale(1.1)' })),
animate('200ms', style({ transform: 'scale(1)' })),
])
])

По умолчанию Angular анимирует дочерние элементы параллельно с родителем. animateChild() даёт контроль:

trigger('pageAnimation', [
transition(':enter', [
query(':enter', animateChild(), { optional: true }),
// optional: true — не ломать, если дочерних элементов нет
])
])

Когда декларативный подход не подходит, используй AnimationBuilder для создания анимаций в коде:

import { AnimationBuilder, AnimationPlayer, style, animate } from '@angular/animations';
@Component({
selector: 'app-animated',
template: `<div #el>Кликни меня</div>`,
})
export class AnimatedComponent {
@ViewChild('el') el!: ElementRef;
private player: AnimationPlayer | null = null;
constructor(private builder: AnimationBuilder) {}
playAnimation() {
// Останавливаем предыдущую анимацию
this.player?.destroy();
const factory = this.builder.build([
style({ transform: 'rotate(0deg)' }),
animate('1000ms ease-in-out', style({ transform: 'rotate(360deg)' })),
]);
this.player = factory.create(this.el.nativeElement);
this.player.play();
this.player.onDone(() => console.log('Анимация завершена!'));
}
}

Плавные переходы между страницами:

app.component.ts
@Component({
template: `
<div [@routeAnimation]="getRouteAnimationData()">
<router-outlet #outlet="outlet" />
</div>
`,
animations: [
trigger('routeAnimation', [
transition('* <=> *', [
style({ position: 'relative' }),
query(':enter, :leave', [
style({ position: 'absolute', top: 0, left: 0, width: '100%' })
], { optional: true }),
query(':enter', [
style({ left: '100%', opacity: 0 })
], { optional: true }),
group([
query(':leave', [
animate('300ms ease-out', style({ left: '-100%', opacity: 0 }))
], { optional: true }),
query(':enter', [
animate('300ms ease-out', style({ left: '0%', opacity: 1 }))
], { optional: true }),
])
])
])
]
})
export class AppComponent {
getRouteAnimationData() {
// Каждый роут должен иметь data.animation
return this.router.routerState.snapshot.root.firstChild?.data?.['animation'];
}
}

@Component({
animations: [
trigger('highlight', [
state('active', style({ boxShadow: '0 0 20px #dd0031' })),
state('inactive', style({ boxShadow: 'none' })),
transition('inactive <=> active', animate('300ms')),
])
]
})
export class HighlightComponent {
@HostBinding('@highlight')
get animationState() {
return this.isActive ? 'active' : 'inactive';
}
isActive = false;
}

Можно передавать параметры в анимации для гибкости:

trigger('slide', [
transition(':enter', [
style({ transform: 'translateX({{ from }})' }),
animate('{{ duration }}', style({ transform: 'translateX(0)' }))
], { params: { from: '-100%', duration: '300ms' } })
])
// В шаблоне
<div [@slide]="{ value: '', params: { from: '-200px', duration: '500ms' } }">