角度とRXJSでカウントアップアニメーションを構築すること


カバー写真Andy Holmes を返します.
この記事では、カウントアップアニメーションを無効にする方法を説明します.サードパーティ製のライブラリを使わずにゼロからディレクティブディレクティブを構築します.最終的な結果は次のようになります.

始めましょう!

角度CLIによる指令の生成
角度でディレクティブを作成するには、次のコマンドを実行します
ng generate directive count-up
角度CLIはcount-up.directive.ts 空のディレクティブを含むファイル:
@Directive({
  selector: '[countUp]'
})
export class CountUpDirective {
  constructor() {}
}

入力の定義
The CountUpDirective つの入力を持ちます:カウントとアニメーション持続時間、カウント入力の名前が指令セレクタの名前と同じであるところ.使用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) {}
}
ご覧のように、入力はセッターとして定義されます.入力値はRXJS被験者に放出されます.そして、それは我々がOnChanges ライフサイクルフック.

ローカル州の定義
The CountUpDirective 動作対象に格納される2つのローカルステートスライスがあります.
@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$ and duration$ 科目私たちはcombineLatest 演算子は、現在のカウントの計算をリセットするたびにcount$ or 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 より良いレンダリングパフォーマンス.時animationFrameScheduler を使用するinterval , 最初の引数は0 . そうでなければ、それはasyncScheduler . 言い換えれば、以下の実装currentCount$ 用途asyncScheduler フードの下でanimationFrameSchedulerinterval 機能
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
      );
    });
}
The 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
        );
      });
  }
}

Demo

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


  • ピアレビュー

  • ティムにこの記事に役立つ提案を与えてくれてありがとう!