Search code examples
canvasangularangular2-changedetection

Angular 2: canvas and change detection


I want to create a circular progress bar in Angular 2, using the canvas element. I have done multiple projects in Angular 1, but I am totally new to Angular 2 and TypeScript, so I'm kind of plodding along...

I've created a ProgressCircle component, and made it injectable so I can use it inside of other components. This progress component receives a couple of attributes from the parent component that determine what the progress circle looks like and how it is filled.

Note: I pulled this code out of an old ES5 project. This might show.

import { Component, Input, Injectable, ElementRef, AfterViewInit } from '@angular/core';

@Injectable()
@Component({
    selector: 'progress-circle',
    template: `
        <div class="progress-indicator" [style.width]="size + 'px'" [style.height]="size + 'px'">
            <canvas [attr.width]="size" [attr.height]="size"></canvas>
            <span class="progress-text-container">
                <span class="progress-text">{{ progress }}</span>
            </span>
        </div>
    `
})
export class ProgressCircle implements AfterViewInit {
    @Input() size:number;
    @Input() progress:number;
    @Input() lineWidth:number;
    @Input() rotate:number;
    @Input() color:string;
    @Input() trackColor:string;
    @Input() bgColor:string;
    @Input() borderWidth:number;
    @Input() animate:boolean;

    constructor(private el:ElementRef) {}

    ngAfterViewInit() {
        this.draw();
    }

    draw() {
        let canvas = this.el.nativeElement.getElementsByTagName('canvas')[0],
            ctx    = canvas.getContext('2d'),

            options = {
                percent: this.progress || 0,
                size: this.size || 90,
                lineWidth: this.lineWidth || (this.size / 10) || 10,
                rotate: this.rotate || 0,
                color: this.color || '#000',
                trackColor: this.trackColor || '#e6e6e6',
                bgColor: this.bgColor || 'transparent',
                borderWidth: this.borderWidth || 0,
                doAnimate: this.animate
            },

            radius = (options.size - options.lineWidth) / 2,
            progress = 0,
            fillColor:string = undefined,

            animFrame:number, bg:ImageData;

        let drawCircle = (strokeColor:string, fillColor:string, percent:number, hasBorder:boolean = false) => {
            // logic to draw circle goes here
        }

        let animateProgess = () => {
            if (++progress <= options.percent) { // we need to animate
                // draw circle
                animFrame = window.requestAnimationFrame(animateProgess);
            }
        }

        ctx.translate(options.size / 2, options.size / 2);
        ctx.rotate((-.5 + (options.rotate / 180)) * Math.PI);

        if (options.doAnimate) {
            bg = ctx.getImageData(0, 0, canvas.width, canvas.height);
            animFrame = window.requestAnimationFrame(animateProgess);
        } else {
            drawCircle(options.color, fillColor, options.percent);
            this.progress = Math.floor(options.percent); 
        }
    }
}

Now, this works fine. But the progress input variable from the parent component might change at any time. How do I trigger a redraw of my progress bar at the appropriate time?

I cannot initialize the progress bar until after the view has been loaded (hence the call to ngAfterViewInit), or else it looks all funky because the canvas element hasn't been fully initialized yet. I've looked into the ngOnChanges lifecycle hook but it's useless because it first fires way before the view has been loaded.

One thought I had was to make the progress input variable observable, then subscribe to it in the ngAfterViewInit hook, but I'm not entirely sure how that would work. I also suppose I could hook into ngOnChanges and check if it's the first change (actually the SimpleChange class has such a method according to the docs), but it feels kind of hackish to me.

Would appreciate any pointers! Angular 2 is growing on me, but it definitely takes a LOT of getting used to coming from Angular 1/ES5.


Solution

  • Use ngOnChanges. That's the appropriate lifecycle hook to use to be notified when an input property value changes.

    As for waiting until after the view is initialized... I would have ngAfterViewInit set a flag that ngOnChanges examines each time it runs:

    export class ProgressCircle implements AfterViewInit {
       viewInitialized = false;
       ...
       ngOnChanges() {
          if(!viewInitialized)  return;
          ...
       }
       ngAfterViewInit() {
          viewInitialized = true;
          ...
       }
    

    There might be some way for ngOnChanges to determine if the view is initialized, but I'm not aware of any (post a comment if you know of a way, and it is more efficient than a flag).

    Also, @Injectable only needs to be used on services that depend on other services.