Angular와 RxJS로 카운트업 애니메이션 만들기

27792 단어 rxjsanimationsangular
Unsplash에서 Andy Holmes의 표지 사진.

이 기사에서는 반응형 방식으로 Angular에서 카운트업 애니메이션을 빌드하는 방법을 설명합니다. 우리는 서드파티 라이브러리 없이 처음부터 카운트업 지시문을 만들 것입니다. 최종 결과는 다음과 같습니다.



시작하자!

Angular CLI로 지시문 생성



Angular에서 지시문을 만들려면 다음 명령을 실행합니다.

ng generate directive count-up


Angular CLI는 빈 지시문이 포함된 count-up.directive.ts 파일을 생성합니다.

@Directive({
  selector: '[countUp]'
})
export class CountUpDirective {
  constructor() {}
}


입력 정의


CountUpDirective에는 count 및 animation duration의 두 가지 입력이 있습니다. 여기서 count 입력의 이름은 지시문 선택기의 이름과 같습니다. 템플릿에서 CountUpDirective를 사용하면 다음과 같습니다.

<p [countUp]="200" [duration]="5000"></p>


이러한 입력은 다음과 같이 CountUpDirective에 정의되어 있습니다.

@Directive({
  selector: '[countUp]'
})
export class CountUpDirective {
  @Input('countUp') // input name is the same as selector name
  set count(count: number) {}

  @Input()
  set duration(duration: number) {}
}


보시다시피 입력은 설정자로 정의됩니다. 입력 값은 OnChanges 수명 주기 후크를 사용하지 않고 반응적으로 변경 사항을 수신할 수 있도록 RxJS 주제로 내보내집니다.

로컬 상태 정의


CountUpDirective에는 행동 주제에 저장될 두 개의 로컬 상태 슬라이스가 있습니다.

@Directive({
  selector: '[countUp]'
})
export class CountUpDirective {
  // default count value is 0
  private readonly count$ = new BehaviorSubject(0);
  // default duration value is 2000 ms
  private readonly duration$ = new BehaviorSubject(2000);
}


그런 다음 입력이 변경되면 새 입력 값이 이러한 주제에 내보내집니다.

@Directive({
  selector: '[countUp]'
})
export class CountUpDirective {
  private readonly count$ = new BehaviorSubject(0);
  private readonly duration$ = new BehaviorSubject(2000);

  @Input('countUp')
  set count(count: number) {
    // emit a new value to the `count$` subject
    this.count$.next(count);
  }

  @Input()
  set duration(duration: number) {
    // emit a new value to the `duration$` subject
    this.duration$.next(duration);
  }
}


다음 단계는 템플릿에 현재 카운트를 표시하는 데 사용할 currentCount$ 옵저버블을 빌드하는 것입니다.

현재 카운트 계산



현재 개수를 계산하려면 count$duration$ 주제의 값이 필요합니다. combineLatest 연산자를 사용하여 count$ 또는 duration$가 변경될 때마다 현재 카운트 계산을 재설정합니다. 다음 단계는 0에서 시작하여 시간이 지남에 따라 현재 카운트를 증가시킨 다음 느려지고 애니메이션 지속 시간이 만료될 때 count 값으로 끝나는 간격으로 외부 관찰 가능 항목을 전환하는 것입니다.

private readonly currentCount$ = combineLatest([
  this.count$,
  this.duration$,
]).pipe(
  switchMap(([count, duration]) => {
    // get the time when animation is triggered
    const startTime = animationFrameScheduler.now();

    // use `animationFrameScheduler` for better rendering performance
    return interval(0, animationFrameScheduler).pipe(
      // calculate elapsed time
      map(() => animationFrameScheduler.now() - startTime),
      // calculate progress
      map((elapsedTime) => elapsedTime / duration),
      // complete when progress is greater than 1
      takeWhile((progress) => progress <= 1),
      // apply quadratic ease-out function
      // for faster start and slower end of counting
      map((progress) => progress * (2 - progress)),
      // calculate current count
      map((progress) => Math.round(progress * count)),
      // make sure that last emitted value is count
      endWith(count),
      distinctUntilChanged()
    );
  }),
);


더 나은 렌더링 성능을 위해 기본값animationFrameScheduler 대신 asyncScheduler를 사용합니다. animationFrameSchedulerinterval 와 함께 사용될 때 첫 번째 인수는 0 여야 합니다. 그렇지 않으면 asyncScheduler 로 대체됩니다. 즉, currentCount$asyncScheduler 함수에 두 번째 인수로 전달되지만 animationFrameScheduler의 다음 구현은 후드 아래에서 interval를 사용합니다.

private readonly currentCount$ = combineLatest([
  this.count$,
  this.duration$,
]).pipe(
  switchMap(([count, animationDuration]) => {
    const frameDuration = 1000 / 60; // 60 frames per second
    const totalFrames = Math.round(animationDuration / frameDuration);

    // interval falls back to `asyncScheduler`
    // because the `frameDuration` is different from 0
    return interval(frameDuration, animationFrameScheduler).pipe(
      // calculate progress
      map((currentFrame) => currentFrame / totalFrames), 
      // complete when progress is greater than 1
      takeWhile((progress) => progress <= 1),
      // apply quadratic ease-out function
      map((progress) => progress * (2 - progress)),
      // calculate current count
      map((progress) => Math.round(progress * count)),
      // make sure that last emitted value is count
      endWith(count),
      distinctUntilChanged()
    );
  })
);


💡 If you're not familiar with the animationFrameScheduler and its advantages for updating the DOM over the asyncScheduler, take a look at the resources section.



현재 카운트 표시



지시문의 호스트 요소 내에서 현재 카운트를 렌더링하려면 Renderer2 의 인스턴스와 호스트 요소에 대한 참조가 필요합니다. 둘 다 생성자를 통해 주입할 수 있습니다. 또한 Destroy가 파괴될 때 관찰 가능한 currentCount$를 구독 취소하는 데 도움이 되는 CountUpDirective 공급자를 주입합니다.

@Directive({
  selector: '[countUp]',
  // `Destroy` is provided at the directive level
  providers: [Destroy],
})
export class CountUpDirective {
  constructor(
    private readonly elementRef: ElementRef,
    private readonly renderer: Renderer2,
    private readonly destroy$: Destroy
  ) {}
}


💡 Take a look at to learn more about Destroy provider.



그런 다음 currentCount$ 변경 사항을 수신하고 호스트 요소 내에서 방출된 값을 표시하는 메서드를 만들어야 합니다.

private displayCurrentCount(): void {
  this.currentCount$
    .pipe(takeUntil(this.destroy$))
    .subscribe((currentCount) => {
      this.renderer.setProperty(
        this.elementRef.nativeElement,
        'innerHTML',
        currentCount
      );
    });
}

displayCurrentCount 메서드는 ngOnInit 메서드에서 호출됩니다.

마무리


CountUpDirective의 최종 버전은 다음과 같습니다.

/**
 * Quadratic Ease-Out Function: f(x) = x * (2 - x)
 */
const easeOutQuad = (x: number): number => x * (2 - x);

@Directive({
  selector: '[countUp]',
  providers: [Destroy],
})
export class CountUpDirective implements OnInit {
  private readonly count$ = new BehaviorSubject(0);
  private readonly duration$ = new BehaviorSubject(2000);

  private readonly currentCount$ = combineLatest([
    this.count$,
    this.duration$,
  ]).pipe(
    switchMap(([count, duration]) => {
      // get the time when animation is triggered
      const startTime = animationFrameScheduler.now();

      return interval(0, animationFrameScheduler).pipe(
        // calculate elapsed time
        map(() => animationFrameScheduler.now() - startTime),
        // calculate progress
        map((elapsedTime) => elapsedTime / duration),
        // complete when progress is greater than 1
        takeWhile((progress) => progress <= 1),
        // apply quadratic ease-out function
        // for faster start and slower end of counting
        map(easeOutQuad),
        // calculate current count
        map((progress) => Math.round(progress * count)),
        // make sure that last emitted value is count
        endWith(count),
        distinctUntilChanged()
      );
    }),
  );

  @Input('countUp')
  set count(count: number) {
    this.count$.next(count);
  }

  @Input()
  set duration(duration: number) {
    this.duration$.next(duration);
  }

  constructor(
    private readonly elementRef: ElementRef,
    private readonly renderer: Renderer2,
    private readonly destroy$: Destroy
  )

  ngOnInit(): void {
    this.displayCurrentCount();
  }

  private displayCurrentCount(): void {
    this.currentCount$
      .pipe(takeUntil(this.destroy$))
      .subscribe((currentCount) => {
        this.renderer.setProperty(
          this.elementRef.nativeElement,
          'innerHTML',
          currentCount
        );
      });
  }
}


데모





자원


  • Official docs of the requestAnimationFrame function
  • Official docs of the animationFrameScheduler


  • 피어 리뷰어




  • 이 기사에 대해 유용한 제안을 해주신 Tim에게 감사드립니다!

    좋은 웹페이지 즐겨찾기