27
Building Count-Up Animation with Angular and RxJS
Cover photo by Andy Holmes on Unsplash.
This article explains how to build a count-up animation in Angular in a reactive way. We are going to build a count-up directive from scratch without third-party libraries. The final result will look like this:
Let's get started!
To create a directive in Angular, run the following command:
ng generate directive count-up
The Angular CLI will generate a count-up.directive.ts
file that contains an empty directive:
@Directive({
selector: '[countUp]'
})
export class CountUpDirective {
constructor() {}
}
The CountUpDirective
has two inputs: count and animation duration, where the name of the count input is the same as the name of the directive selector. Using the CountUpDirective
in the template will look like this:
<p [countUp]="200" [duration]="5000"></p>
These inputs are defined in the CountUpDirective
as follows:
@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) {}
}
As you can see, inputs are defined as setters. Input values will be emitted to RxJS subjects, which will allow us to reactively listen to their changes, without using the OnChanges
lifecycle hook.
The CountUpDirective
has two local state slices that will be stored in behavior subjects:
@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);
}
New input values will then be emitted to these subjects as inputs change:
@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);
}
}
The next step is to build the currentCount$
observable that will be used to display the current count in the template.
To calculate the current count we need values of the count$
and duration$
subjects. We will use the combineLatest
operator to reset the calculation of the current count each time the count$
or duration$
changes. The next step is to switch the outer observable with an interval that starts with 0, increases current count over time, then slows down, and ends with the count
value when the animation duration expires:
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()
);
}),
);
We use animationFrameScheduler
instead of the default asyncScheduler
for better rendering performance. When the animationFrameScheduler
is used with interval
, the first argument must be 0
. Otherwise, it falls back to the asyncScheduler
. In other words, the following implementation of currentCount$
uses asyncScheduler
under the hood, although the animationFrameScheduler
is passed as a second argument to the interval
function:
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 theasyncScheduler
, take a look at the resources section.
To render the current count within the directive's host element, we need an instance of Renderer2
and a reference to the host element. Both can be injected through the constructor. We will also inject the Destroy
provider that will help us to unsubscribe from the currentCount$
observable when the CountUpDirective
is destroyed:
@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 this article to learn more about
Destroy
provider.
Then we need to create a method that listens to the currentCount$
changes and displays emitted values within the host element:
private displayCurrentCount(): void {
this.currentCount$
.pipe(takeUntil(this.destroy$))
.subscribe((currentCount) => {
this.renderer.setProperty(
this.elementRef.nativeElement,
'innerHTML',
currentCount
);
});
}
The displayCurrentCount
method will be called in the ngOnInit
method.
The final version of the CountUpDirective
looks like this:
/**
* 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
);
});
}
}
Thank you Tim for giving me helpful suggestions on this article!
27