Skip to content

2. 트랜지션 & 트리거


이 문서에서는 트랜지션에 대해 좀 더 깊이 들어가서 * 와일드카드 상태와 void 상태에 대해 알아보자. 이 상태들은 엘리먼트가 화면에 추가되거나 사라질 때 사용하는 특수 상태이다. 그리고 이 문서에서는 애니메이션 트리거를 여러 개 적용하는 방법, 애니메이션 콜백을 활용하는 방법, 키프레임을 사용해서 애니메이션을 정해진 순서대로 실행하는 방법도 알아보자.


1. 미리 정의된 상태와 와일드카드 매칭

Angular에서 트랜지션 상태는 state() 함수로 선언할 수 있지만 이 외에도 와일드카드(*) 상태와 보이드(void) 상태가 미리 정의되어 있다.


1) 와일드카드 상태

와일드카드(*)는 모든 애니메이션 상태와 매칭된다. 그래서 이 상태는 엘리먼트의 초기 상태나 종료 상태에 관계없이 특정 조건의 트랜지션을 적용할 때 사용할 수 있다.


예를 들어 open => *과 같은 트랜지션은 엘리먼트가 open 상태였다가 다른 상태로 전환되는 경우에 모두 매칭된다.


001


이전에 살펴봤던 예제처럼 open 상태와 closed 상태가 있는 앱을 살펴보자. 그런데 이번에는 어떤 상태에서 다른 상태로 트랜지션하는 것이 아니라 아무 상태에서 closed로 바뀔 때는 1초, 아무 상태에서 open으로 바뀔 때는 0.5초 시간동안 애니메이션을 실행해 보자.


그러면 트랜지션을 정의하는 로직을 다음과 같이 작성할 수 있다.


src/app/open-close.component.ts
animations: [
  trigger('openClose', [
    // ...
    state('open', style({
      height: '200px',
      opacity: 1,
      backgroundColor: 'yellow'
    })),
    state('closed', style({
      height: '100px',
      opacity: 0.8,
      backgroundColor: 'blue'
    })),
    transition('* => closed', [
      animate('1s')
    ]),
    transition('* => open', [
      animate('0.5s')
    ]),
  ]),
],


그리고 두 상태를 양방향으로 전환하는 트랜지션을 정의하려면 다음과 같이 작성하면 된다.


src/app/open-close.component.ts
transition('open <=> closed', [
  animate('0.5s')
]),


2) 상태 전환할 때 와일드카드 상태 활용하기

이 예제는 버튼의 상태가 openclosed 두 개만 있기 때문에 와일드카드 상태가 크게 유용하지 않다. 와일드카드 상태는 다른 상태로 전환되는 경우가 많을 때 유용하다. 그래서 버튼이 open에서 전환되는 상태가 closed 외에 inProgress 상태도 있다면 와일드카드를 사용해서 코드의 양을 줄일 수 있다.


002


src/app/open-close.component.ts
animations: [
  trigger('openClose', [
    // ...
    state('open', style({
      height: '200px',
      opacity: 1,
      backgroundColor: 'yellow'
    })),
    state('closed', style({
      height: '100px',
      opacity: 0.8,
      backgroundColor: 'blue'
    })),
    transition('open => closed', [
      animate('1s')
    ]),
    transition('closed => open', [
      animate('0.5s')
    ]),
    transition('* => closed', [
      animate('1s')
    ]),
    transition('* => open', [
      animate('0.5s')
    ]),
    transition('open <=> closed', [
      animate('0.5s')
    ]),
    transition ('* => open', [
      animate ('1s',
        style ({ opacity: '*' }),
      ),
    ]),
    transition('* => *', [
      animate('1s')
    ]),


* => * 트랜지션은 모든 상태 전환과 매칭된다.


트랜지션은 정의된 순서대로 매칭되기 때문에 * => * 트랜지션은 다른 트랜지션보다 제일 나중에 정의해야 한다. 그래서 이 예제로 보면 open => closed 트랜지션이 가장 먼저 적용되고 그 다음에 closed => open 트랜지션이 그 다음 우선순위로 적용되며 상태가 정확하게 지정되지 않은 트랜지션은 모두 * => *와 매칭된다.


이 코드에 트랜지션 규칙을 더 추가하려면 * => * 앞에 추가해야 한다.


3) 스타일 적용할 때 와일드카드 사용하기

와일드카드 *는 스타일을 지정할 때도 사용할 수 있다. 스타일에 와일드카드를 사용하면 트리거 안에 지정되지 않은 스타일이 현재 스타일 값으로 대체된다.


src/app/open-close.component.ts
transition ('* => open', [
  animate ('1s',
    style ({ opacity: '*' }),
  ),
]),


4) 보이드(void) 상태

엘리먼트가 화면에 나타나거나 화면에서 사라지는 트랜지션을 정의하려면 void 상태를 활용할 수 있다.


5) 와일드카드 상태와 보이드 상태 함께 사용하기

와일드카드 상태와 보이드 상태를 함께 사용하면 이렇게 활용할 수 있다:


  • * => void는 엘리먼트가 화면에서 사라질 때 적용된다. 이전 상태는 어느 것이든 관계없다.
  • void => *는 엘리먼트가 화면에 나타날 때 적용된다. 나타난 이후에 어떤 상태가 되느냐는 관계없다.
  • *void 상태를 포함해서 모든 상태와 매칭된다.


2. 나타나거나 사라지는 애니메이션

이번 섹션에서는 엘리먼트가 나타나거나 사라지는 애니메이션에 대해 알아보자.


Note

  • 이번 예제에서 엘리먼트가 화면에 나타나거나 화면에서 사라지는 것은 DOM에 엘리먼트가 추가되거나 DOM에서 엘리먼트가 제거되는 것과 같은 역할을 한다.


새로운 동작을 추가해 보자:


  • 히어로 목록에 히어로를 추가하면 이 히어로는 화면 왼쪽에서 날아와서 표시된다.
  • 목록에서 히어로를 제거하면 이 히어로는 화면 오른쪽으로 날아가면서 사라진다.


src/app/hero-list-enter-leave.component.ts
animations: [
  trigger("flyInOut", [
    state("in", style({ transform: "translateX(0)" })),
    transition("void => *", [
      style({ transform: "translateX(-100%)" }),
      animate(100),
    ]),
    transition("* => void", [
      animate(100, style({ transform: "translateX(100%)" })),
    ]),
  ]),
];


이전에 살펴봤던 것처럼 void => ** => void를 사용하면 이런 동작을 간단하게 구현할 수 있다.


3. :enter, :leave

void => *:enter로, * => void:leave로 대신 사용할 수 있다. :enter:leave는 별칭으로 미리 정의되어 있는 셀렉터이다.


transition ( ':enter', [ ... ] );  // void => * 와 같은 의미
transition ( ':leave', [ ... ] );  // * => void 와 같은 의미


화면에 나타나는 엘리먼트는 아직 DOM에 존재하지 않기 때문에 대상으로 지정하기 어렵다. 하지만 :enter:leave를 사용하면 화면에 추가되는 엘리먼트와 화면에서 제거되는 엘리먼트를 간단하게 지정할 수 있다.


1) :enter, :leave*ngIf, *ngFor와 함께 사용하기

:enter 트랜지션은 *ngIf*ngFor로 화면에 추가되는 엘리먼트를 대상으로 적용할 수 있으며, :leave도 마찬가지로 화면에서 사라지는 엘리먼트를 대상으로 적용할 수 있다.


이번에는 엘리먼트가 추가되거나 제거될 때 트리거되는 myInsertRemoveTrigger를 정의해 보자. 템플릿은 이렇게 작성한다.


src/app/insert-remove.component.html
<div @myInsertRemoveTrigger *ngIf="isShown" class="insert-remove-container">
  <p>The box is inserted</p>
</div>


이 컴포넌트 파일에서 :enter 트랜지션은 엘리먼트가 화면에 추가되는 것을 표현하기 위해 투명도가 0부터 1까지 변한다.


src/app/insert-remove.component.ts
trigger('myInsertRemoveTrigger', [
  transition(':enter', [
    style({ opacity: 0 }),
    animate('100ms', style({ opacity: 1 })),
  ]),
  transition(':leave', [
    animate('100ms', style({ opacity: 0 }))
  ])
]),


이 예제에서 state()는 한 번도 사용하지 않았다.


4. :increment, :decrement

transition() 함수에는 :increment:decrement라는 셀렉터도 사용할 수 있다. 이 셀렉터는 숫자값이 변했을 때 트랜지션을 시작하는 트리거이다.


Note

  • 아래 예제에는 query()stagger() 메서드를 사용했다.


src/app/hero-list-page.component.ts
trigger('filterAnimation', [
  transition(':enter, * => 0, * => -1', []),
  transition(':increment', [
    query(':enter', [
      style({ opacity: 0, width: 0 }),
      stagger(50, [
        animate('300ms ease-out', style({ opacity: 1, width: '*' })),
      ]),
    ], { optional: true })
  ]),
  transition(':decrement', [
    query(':leave', [
      stagger(50, [
        animate('300ms ease-out', style({ opacity: 0, width: 0 })),
      ]),
    ])
  ]),
]),


5. 불리언 값으로 트랜지션 시작하기

트리거에 바인딩된 값이 불리언 타입이라면, 이 값은 transition() 표현식 안에서 true/false1/0 값과 매칭된다.


src/app/open-close.component.html
<div [@openClose]="isOpen ? true : false" class="open-close-container"></div>


위 예제 코드에서 HTML 템플릿의 <div> 엘리먼트에는 openClose 트리거가 isOpen 표현식으로 연결되어 있는데, isOpen 프로퍼티는 불리언 타입이기 때문에 true 값이거나 false 값이 된다. isOpen 프로퍼티 값에 따라 open/close 상태를 문자열로 지정하는 것과 같은 로직이라고 볼 수 있다.


그리고 컴포넌트 코드에서 Component의 메타데이터 animations: 프로퍼티에는 true로 평가되는 상태에 엘리먼트 높이를 와일드카드 스타일이나 기본값으로 지정한다. 이번 예제에서는 애니메이션이 시작하기 전에 갖고 있던 값을 그대로 사용했다. 그리고 엘리먼트가 "close" 상태가 되면 높이를 0으로 만들어서 보이지 않게 구현했다.


src/app/open-close.component.ts
animations: [
  trigger('openClose', [
    state('true', style({ height: '*' })),
    state('false', style({ height: '0px' })),
    transition('false <=> true', animate(500))
  ])
],


6. 다중 애니메이션 트리거

애니메이션 트리거는 한 번에 여러 개를 정의할 수도 있다. 여러 엘리먼트에 트리거를 연결하거나 부모-자식 관계의 엘리먼트에 트리거를 연결하면 애니메이션 여러 개를 동시에 실행할 수 있다.


1) 부모-자식 애니메이션

Angular에서 애니메이션이 시작되면 부모 애니메이션이 항상 우선권을 가지며 자식 애니메이션은 중단된다. 그래서 자식 애니메이션을 시작하려면 부모 애니메이션이 각각의 자식 애니메이션을 찾아서 animateChild()으로 실행해줘야 한다.


애니메이션 비활성화하기

HTML 엘리먼트에 @.disabled를 바인딩하면 애니메이션을 비활성화할 수 있으며, 이 때 자식 엘리먼트도 모두 영향을 받는다. 그래서 @.disabledtrue 값을 바인딩하면 해당 엘리먼트와 해당 엘리먼트의 자식 엘리먼트에서는 모든 애니메이션이 비활성화된다.


이 내용에 대해 살펴보자.


<div [@.disabled]="isDisabled">
  <div
    [@childAnimation]="isOpen ? 'open' : 'closed'"
    class="open-close-container"
  >
    <p>The box is now {{ isOpen ? 'Open' : 'Closed' }}!</p>
  </div>
</div>
@Component({
  animations: [
    trigger("childAnimation", [
      // ...
    ]),
  ],
})
export class OpenCloseChildComponent {
  isDisabled = false;
  isOpen = false;
}


@.disabledtrue 값으로 바인딩되면 @childAnimation 트리거는 시작되지 않는다.


그리고 호스트 엘리먼트에 @.disabled가 바인딩되면 이 엘리먼트의 자식 엘리먼트의 모든 애니메이션도 비활성화된다. 이 때 비활성화되는 엘리먼트 단위로만 할 수 있으며, 한 엘리먼트의 일부 애니메이션만 실행할 수는 없다.


그런데 부모 엘리먼트의 애니메이션은 비활성화하고 자식 엘리먼트의 애니메이션만 실행할 수 있는 방법이 있다:


  • 부모 애니메이션에서 query() 함수를 실행해서 자식 엘리먼트의 애니메이션을 모은 후에 직접 시작할 수 있다.
  • 서브 애니메이션도 쿼리한 후에 animateChild() 함수로 시작할 수 있다.


모든 애니메이션 비활성화하기

Angular 앱에 있는 애니메이션을 모두 비활성화하려면 최상위 컴포넌트에 @.disabled를 바인딩하면 된다.


src/app/app.component.ts
@Component({
  selector: "app-root",
  templateUrl: "app.component.html",
  styleUrls: ["app.component.css"],
  animations: [slideInAnimation],
})
export class AppComponent {
  @HostBinding("@.disabled")
  public animationsDisabled = false;
}


Note

  • 앱 전체에 애니메이션을 비활성화하는 기능은 엔드-투-엔드(E2E) 테스트를 실행할 때 활용하면 좋다.


7. 애니메이션 콜백

trigger() 함수는 시작할 때와 종료될 때 콜백 함수를 실행한다. 이번에는 openClose 트리거에 콜백 함수를 연결하는 방법에 대해 알아보자.


src/app/open-close.component.ts
@Component({
  selector: "app-open-close",
  animations: [
    trigger("openClose", [
      // ...
    ]),
  ],
  templateUrl: "open-close.component.html",
  styleUrls: ["open-close.component.css"],
})
export class OpenCloseComponent {
  onAnimationEvent(event: AnimationEvent) {}
}


애니메이션 트리거의 이름이 trigger라면 애니메이션 이벤트는 HTML 템플릿에서 $event 객체에 담겨 @trigger.start@trigger.done 함수로 전달된다. 그래서 이번 예제에서는 트리거의 이름이 openClose이기 때문에 이 트리거의 콜백은 다음과 같이 연결할 수 있다.


src/app/open-close.component.html
<div
  [@openClose]="isOpen ? 'open' : 'closed'"
  (@openClose.start)="onAnimationEvent($event)"
  (@openClose.done)="onAnimationEvent($event)"
  class="open-close-container"
></div>


애니메이션 콜백은 데이터베이스 쿼리와 같이 시간이 오래 걸리는 API를 실행할 때도 유용하다. 예를 들면 백엔드에서 어떤 작업을 하는 동안 버튼이 깜빡이는 등 시각적 효과를 표현할 수 있다.


아니면 어떤 애니메이션이 끝나고 나서 다른 애니메이션을 시작할 때도 애니메이션 콜백 함수를 사용할 수 있다. 버튼이 inProgress 상태에서 깜빡이다가 API 호출이 완료되면 closed 상태가 되면서 다른 애니메이션을 시작할 수 있다.


애니메이션을 활용하면 작업이 실제로 끝나지 않았더라도 작업이 좀 더 빠르게 진행되는 것처럼 느껴진다. 그래서 애니메이션을 약간만 추가해도 사용자의 만족도를 크게 개선할 수 있다. 서버의 실행속도나 네트워크 연결 상태와 같이 개선하기 어려운 문제를 신경쓰는 것보다 훨씬 효율적이다.


콜백 함수는 디버깅 용도로 사용할 수도 있다. 애니메이션 콜백 함수에 맞춰서 애플리케이션의 진행정보를 console.warn()과 같은 함수로 출력해볼 수도 있다.


아래 예제 코드는 애니메이션 이벤트에 맞춰서 애니메이션 정보를 로그로 출력하는 컴포넌트 코드이다.


src/app/open-close.component.ts
export class OpenCloseComponent {
  onAnimationEvent(event: AnimationEvent) {
    // 이 예제에서 트리거 이름은 openClose입니다.
    console.warn(`Animation Trigger: ${event.triggerName}`);

    // phaseName은 "start"나 "done" 문자열입니다.
    console.warn(`Phase: ${event.phaseName}`);

    // 이 예제에서 totalTime은 1초이거나 0.5초입니다.
    console.warn(`Total time: ${event.totalTime}`);

    // 이 예제에서 fromState는 "open"이거나 "closed"입니다.
    console.warn(`From: ${event.fromState}`);

    // 이 예제에서 toState는 "open"이거나 "closed"입니다.
    console.warn(`To: ${event.toState}`);

    // 이 예제에서 이벤트가 발생한 HTML 엘리먼트는 버튼입니다.
    console.warn(`Element: ${event.element}`);
  }
}


8. 키프레임(keyframes)

이전 섹션에서는 두 상태를 전환하는 간단한 트랜지션에 대해 알아봤다. 이번에는 키프레임을 사용해서 여러 단계로 실행되는 애니메이션을 만들어 보자.


Angular의 keyframes() 함수는 CSS에서 사용하는 키프레임과 비슷하다. 키프레임은 한 재생시간 안에서 여러 번 스타일을 변경할 때 사용한다. 예를 들면 버튼의 투명도를 조절하는 대신 2초에 걸쳐 색상을 변경하는 식으로 활용할 수 있다.


003


이 애니메이션은 다음과 같이 구현한다.


src/app/status-slider.component.ts
transition('* => active', [
  animate('2s', keyframes([
    style({ backgroundColor: 'blue' }),
    style({ backgroundColor: 'red' }),
    style({ backgroundColor: 'orange' })
  ]))


1) 오프셋(offset)

애니메이션의 키프레임 타이밍을 조절하려면 오프셋(offset)을 지정하면 된다. 오프셋은 애니메이션이 시작되는 지점부터 종료되는 지점을 0부터 1사이의 상대값으로 표현한다.


키프레임 오프셋은 생략할 수 있는데 오프셋을 생략하면 남은 시간을 같은 비율로 나눠서 자동으로 할당된다. 그래서 3개의 키프레임을 오프셋 선언 없이 사용하면 이 키프레임들의 오프셋은 0, 0.5, 1이 된다. 그리고 가운데 오프셋을 0.8로 지정하면 이 애니메이션은 이렇게 표현된다.


004


이 애니메이션은 다음과 같이 구현한다:


src/app/status-slider.component.ts
transition('* => active', [
  animate('2s', keyframes([
    style({ backgroundColor: 'blue', offset: 0}),
    style({ backgroundColor: 'red', offset: 0.8}),
    style({ backgroundColor: '#754600', offset: 1.0})
  ])),
]),
transition('* => inactive', [
  animate('2s', keyframes([
    style({ backgroundColor: '#754600', offset: 0}),
    style({ backgroundColor: 'red', offset: 0.2}),
    style({ backgroundColor: 'blue', offset: 1.0})
  ]))
]),


물론 키프레임은 duration, delay, easing과 함께 사용할 수도 있다.


2) 깜빡이는 애니메이션

키프레임에 오프셋을 활용하면 깜빡이는 애니메이션을 구현할 수 있다.


  • 원래 open, closed 상태에는 버튼의 높이, 색상, 투명도를 1초동안 변경하는 애니메이션이 적용되어 있다.
  • 이 애니메이션 중간에 투명도를 조절하면서 버튼이 깜빡이는 효과를 추가해 보자.


005


이 애니메이션은 이렇게 구현한다.


src/app/open-close.component.ts
trigger("openClose", [
  state(
    "open",
    style({
      height: "200px",
      opacity: 1,
      backgroundColor: "yellow",
    })
  ),
  state(
    "close",
    style({
      height: "100px",
      opacity: 0.5,
      backgroundColor: "green",
    })
  ),
  // ...
  transition("* => *", [
    animate(
      "1s",
      keyframes([
        style({ opacity: 0.1, offset: 0.1 }),
        style({ opacity: 0.6, offset: 0.2 }),
        style({ opacity: 1, offset: 0.5 }),
        style({ opacity: 0.2, offset: 0.7 }),
      ])
    ),
  ]),
]);


3) 애니메이션을 적용할 수 있는 프로퍼티와 단위

Angular 애니메이션은 웹 표준 애니메이션을 기반으로 동작한다. 그래서 브라우저에서 애니메이션을 지원하는 프로퍼티는 모두 Angular 애니메이션 대상이 될 수 있다. 엘리먼트의 위치나 크기, transform 속성, 색상, 외곽선 등이 대상이 될 수 있다.


그리고 위치나 크기와 관련된 프로퍼티는 단위를 붙여서 다음과 같이 문자열로 지정할 수도 있다:


  • 50픽셀: '50px'
  • 상대 폰트 크기: '3em'
  • 퍼센트: '100%'


단위를 생략했을 때 Angular가 기본으로 붙이는 단위는 픽셀(px)이다. 그래서 50이라고 지정한 것과 '50px'은 같은 의미이다.


4) 와일드카드로 프로퍼티 값 자동 계산하기

스타일 프로퍼티의 값은 실행시점에서만 알 수 있는 경우가 있다. 내용의 양이나 화면 크기에 따라서 엘리먼트의 너비와 높이가 조절되기도 한다. 이런 프로퍼티에 CSS 애니메이션을 적용하는 것은 까다로운 일이다.


이런 경우에 style() 함수와 와일드카드 * 프로퍼티 값을 활용하면 애니메이션에 사용되는 프로퍼티 값이 실행시점에 자동으로 계산되어 애니메이션에 적용된다.


아래 예제는 HTML 엘리먼트가 화면에서 사라질 때 실행되는 shrinkOut 트리거를 정의한 코드이다. 이 트리거가 적용된 엘리먼트는 화면에서 사라지기 전의 높이가 어떤 값이냐에 관계없이 0으로 변경된다.


src/app/hero-list-auto.component.ts
animations: [
  trigger("shrinkOut", [
    state("in", style({ height: "*" })),
    transition("* => void", [
      style({ height: "*" }),
      animate(250, style({ height: 0 })),
    ]),
  ]),
];

References