36
loading...
This website collects cookies to deliver better user experience
ng generate directive count-up
count-up.directive.ts
file that contains an empty directive:@Directive({
selector: '[countUp]'
})
export class CountUpDirective {
constructor() {}
}
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>
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) {}
}
OnChanges
lifecycle hook.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);
}
@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$
observable that will be used to display the current count in the template.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()
);
}),
);
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 the asyncScheduler
, take a look at the resources section.
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.
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
);
});
}
displayCurrentCount
method will be called in the ngOnInit
method.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
);
});
}
}