Angular Stagger Animation

agnusdei·2023년 7월 5일
0
post-custom-banner
ng generate directive MyDirective
import { AnimationPlayer } from '@angular/animations';
import {
  coerceBooleanProperty,
  coerceNumberProperty,
} from '@angular/cdk/coercion';
import {
  AfterContentInit,
  AfterViewInit,
  Directive,
  ElementRef,
  Input,
  Renderer2,
} from '@angular/core';

import {
  ScrollAnimation,
  ScrollAnimationService,
} from './scroll-animation.service';

@Directive({
  selector: '[scrollAnimation]',
  standalone: true,
})
export class ScrollAnimationDirective
  implements AfterContentInit, AfterViewInit
{
  @Input() scrollAnimation: ScrollAnimation = 'fadeIn';

  @Input()
  get delay() {
    return this._delay;
  }
  set delay(value: any) {
    this._delay = coerceNumberProperty(value);
  }
  private _delay: number = 0;

  @Input()
  get stagger() {
    return this._stagger;
  }
  set stagger(value: any) {
    this._stagger = coerceBooleanProperty(value);
  }
  private _stagger: boolean = false; // 애니메이션에 stagger 효과를 넣을지 여부

  private intersectionObserver: IntersectionObserver; // 요소의 가시성 감지
  private intersectingCache: boolean = false; // 요소가 현재 가시 상태인지 나타내는 값

  private animationPlayer: AnimationPlayer | undefined; // 애니메이션 제어

  constructor(
    private elementRef: ElementRef,
    private renderer2: Renderer2,
    private scrollAnimationService: ScrollAnimationService
  ) {
    const intersectionObserverInit: IntersectionObserverInit = {
      rootMargin: '0px 0px -40% 0px',
    };
    this.intersectionObserver = new IntersectionObserver(
      (entries) => this.onIntersection(entries[0]),
      intersectionObserverInit
    );
  }

  ngAfterContentInit() {
    // 애니메이션 효과 빌드
    const properties = this.scrollAnimationService.build(
      this.scrollAnimation
    )?.properties;
    const animation = this.scrollAnimationService.build(
      this.scrollAnimation
    )?.animation;

    // 스타일 설정
    if (properties && animation) {
      this.setStyles(properties);
      this.animationPlayer = animation.create(this.elementRef.nativeElement);
    }
  }

  ngAfterViewInit() {
    // 옵저버블을 활용하여 요소를 관찰
    this.intersectionObserver.observe(this.elementRef.nativeElement);
  }

  // stagger 유무 적용
  setStyles(properties: any) {
    if (!this.stagger) {
      return this.renderer2.setStyle(
        this.elementRef.nativeElement,
        'opacity',
        0
      );
    }

    for (const style in properties) {
      this.renderer2.setStyle(
        this.elementRef.nativeElement,
        style,
        properties[style]
      );
    }
  }

  // onIntersection : 메서드는 Intersection Observer의 콜백 함수로서, 요소의 가시성이 변경될 때 호출
  onIntersection(entry: IntersectionObserverEntry) {
    // intersectingCache 값이 true인 경우, 즉 이미 가시 상태인 경우 함수를 종료합니다.
    if (this.intersectingCache) {
      return;
    }

    // intersectingCache 값이 entry.isIntersecting 값과 동일한 경우에도 함수를 종료합니다. 이는 가시 상태가 변경되지 않았음을 의미합니다.
    if (this.intersectingCache == entry.isIntersecting) {
      return;
    }
    // intersectingCache 값을 entry.isIntersecting 값으로 설정합니다. 이는 가시 상태가 변경되었음을 나타냅니다.
    this.intersectingCache = entry.isIntersecting;
    // intersectingCache 값이 false인 경우, 즉 요소가 보이지 않는 경우 함수를 종료합니다.
    if (!this.intersectingCache) {
      return;
    }

    // stagger 값이 true인 경우, renderer2를 사용하여 요소의 투명도(opacity)를 1로 설정합니다. 이는 스태거 효과를 적용하는 부분입니다.
    if (this.stagger) {
      this.renderer2.setStyle(this.elementRef.nativeElement, 'opacity', 1);
    }

    // setTimeout 함수를 사용하여 animationPlayer가 존재하는 경우 지정된 delay 시간 후에 애니메이션을 실행합니다.
    setTimeout(() => this.animationPlayer?.play(), this.delay);

    // intersectionObserver의 관찰을 중지합니다. 이는 애니메이션이 한 번만 실행되도록 하기 위해 관찰을 중단하는 것입니다.
    this.intersectionObserver.disconnect();

    // 요약하면, onIntersection 메서드는 요소의 가시성이 변경되면 해당 요소에 대한 스태거 효과를 적용하고,
    // 지정된 지연 시간 후에 애니메이션을 실행합니다. 또한, 애니메이션이 실행된 후에는 관찰을 중지하여 중복 실행을 방지합니다.
  }
}
  1. 디렉티브
ng generate service <name> [options]
  1. 서비스 생성
import { AnimationBuilder, AnimationFactory } from '@angular/animations';
import { Injectable } from '@angular/core';
import {
  fromIntroBottom,
  fromIntroBottomStagger,
  fromIntroLeftStagger,
  fromIntroRight,
  fromIntroRightStagger,
  fromIntroTop,
  fromIntroTopStagger,
  fromIntroleft,
  toIntroBottom,
  toIntroLeft,
  toIntroRight,
  toIntroTop,
} from './intro.animations';

type Result = {
  properties: Object;
  animation: AnimationFactory;
};

export type ScrollAnimation =
  | 'fadeIn'
  | 'fadeOut'
  | 'introTop'
  | 'introRight'
  | 'introBottom'
  | 'introLeft'
  | 'introBottomStagger'
  | 'introLeftStagger'
  | 'introRightStagger'
  | 'introTopStagger'
  | 'focusIn';

@Injectable({
  providedIn: 'root',
})
export class ScrollAnimationService {
  constructor(private animationBuilder: AnimationBuilder) {}

  build(scrollAnimation: ScrollAnimation): Result | null {
    if (scrollAnimation === 'introBottomStagger') { // stagger : 하나씩 요소 나타내는 애니메이션
      return {
        properties: toIntroBottom,
        animation: this.animationBuilder.build(fromIntroBottomStagger),
      };
    } else if (scrollAnimation === 'introLeft') {
      return {
        properties: toIntroLeft,
        animation: this.animationBuilder.build(fromIntroleft),
      };
    } else if (scrollAnimation === 'introLeftStagger') { // stagger
      return {
        properties: toIntroLeft,
        animation: this.animationBuilder.build(fromIntroLeftStagger),
      };
    } 
    } else {
      return null;
    }
  }
}

import { animate, keyframes, query, stagger, style } from '@angular/animations';

export const toIntroTop = {
  transform: 'translateY(-50px)',
  opacity: 0,
};

export const fromIntroTop = [
  animate(
    '1s cubic-bezier(0.250, 0.460, 0.450, 0.940)',
    keyframes([
      style({
        transform: 'translateY(-50px)',
        opacity: '0',
        offset: 0,
      }),
      style({
        transform: 'translateY(0)',
        opacity: '1',
        offset: 1.0,
      }),
    ])
  ),
];

export const toIntroRight = {
  transform: 'translateX(50px)',
  opacity: 0,
};

export const fromIntroRight = [
  animate(
    '1s cubic-bezier(0.250, 0.460, 0.450, 0.940)',
    keyframes([
      style({
        transform: 'translateX(50px)',
        opacity: 0,
        offset: 0,
      }),
      style({
        transform: 'translateX(0)',
        opacity: 1,
        offset: 1.0,
      }),
    ])
  ),
];

export const toIntroBottom = {
  transform: 'translateY(0px)',
  opacity: 0,
};

export const fromIntroBottom = [
  animate(
    '1s cubic-bezier(0.250, 0.460, 0.450, 0.940)',
    keyframes([
      style({
        transform: 'translateY(50px)',
        opacity: 0,
        offset: 0,
      }),
      style({
        transform: 'translateY(0)',
        opacity: 1,
        offset: 1.0,
      }),
    ])
  ),
];

export const toIntroLeft = {
  transform: 'translateX(-50px)',
  opacity: 0,
};

export const fromIntroleft = [
  animate(
    '1s cubic-bezier(0.250, 0.460, 0.450, 0.940)',
    keyframes([
      style({
        transform: 'translateX(-50px)',
        opacity: 0,
        offset: 0,
      }),
      style({
        transform: 'translateX(0)',
        opacity: 1,
        offset: 1.0,
      }),
    ])
  ),
];

export const fromIntroBottomStagger = [
  query('.animation-item', [
    style({
      transform: 'translateY(50px)',
      opacity: 0,
    }),
    stagger(200, [
      animate(
        '1s cubic-bezier(0.250, 0.460, 0.450, 0.940)',
        style({
          transform: 'translateY(0)',
          opacity: 1,
        })
      ),
    ]),
  ]),
];

export const fromIntroLeftStagger = [
  query('.animation-item', [
    style({
      transform: 'translateX(-50px)',
      opacity: '0',
    }),
    stagger(200, [
      animate(
        '1s cubic-bezier(0.250, 0.460, 0.450, 0.940)',
        style({
          transform: 'translateX(0)',
          opacity: '1',
        })
      ),
    ]),
  ]),
];

export const fromIntroRightStagger = [
  query('.animation-item', [
    style({
      transform: 'translateX(50px)',
      opacity: '0',
    }),
    stagger(200, [
      animate(
        '1s cubic-bezier(0.250, 0.460, 0.450, 0.940)',
        style({
          transform: 'translateX(0)',
          opacity: '1',
        })
      ),
    ]),
  ]),
];

export const fromIntroTopStagger = [
  query('.animation-item', [
    style({
      transform: 'translateY(-50px)',
      opacity: '0',
    }),
    stagger(200, [
      animate(
        '1s cubic-bezier(0.250, 0.460, 0.450, 0.940)',
        style({
          transform: 'translateY(0)',
          opacity: '1',
        })
      ),
    ]),
  ]),
];

export const fadeIn = [
  style({
    opacity: 1,
  }),
  animate(
    '2000ms cubic-bezier(0.4, 0.0, 0.2, 1)',
    style({
      opacity: 0,
    })
  ),
];
import { ScrollAnimationDirective } from '/animations/scroll-animation/scroll-animation.directive';


imports: [ScrollAnimationDirective],
<div
  scrollAnimation="introBottomStagger"
  stagger
>
	<div class="animation-item">자식태그1</div>
	<div class="animation-item">자식태그2</div>
    <div class="animation-item">자식태그3</div>
</div>
post-custom-banner

0개의 댓글