Como construir um componente Timer - Angular 2+

Já comentei em outros posts que normalmente conseguimos atingir o mesmo resultado através de caminhos diferentes. Neste texto vou mostrar como construir um componente temporizador utilizando SVG de uma forma bem simples, com o <path>. Novamente, há outras formas de chegarmos neste resultado, esta é uma delas.

Estruturando a lógica com JavaScript + SVG

Uma das formas de trabalharmos com gráficos vetoriais em HTML é através da utilização de SVG. Considere o código abaixo:

<svg width="80"
     height="80"
     style="border: 1px dashed #ff0000">
  <path d="M 0 0 L 80 80"
        stroke="#000"
        fill="transparent"></path>
</svg>

Criamos um elemento <svg> com 80px de largura e altura e com linhas tracejadas em vermelho na borda. Dentro deste <svg> temos um <path> que permite passarmos um conjunto de instruções dentro do atributo d para desenharmos algo. Abaixo está a descrição das instruções:

  1. M 0 0 -> posicione o cursor/"lápis" nas coordenadas x=0 y=0 (canto superior esquerdo)
  2. L 80 80 -> faça uma linha de onde o cursor/"lápis" está, até as coordenadas x=80 y=80 (canto inferior direito)

O resultado do código acima é representado na imagem a seguir:

Renderização do código acima

Como vamos precisar manipular o <svg> de forma dinâmica, vamos utilizar JavaScript para construir exatamente o mesmo cenário do código acima:

const SVG_SIZE = 80;

const svg = document.createElementNS(
  "http://www.w3.org/2000/svg",
  "svg"
);

svg.setAttribute("height", SVG_SIZE);
svg.setAttribute("style", "border: 1px dashed #ff0000");
svg.setAttribute("width", SVG_SIZE);

document.body.appendChild(svg);

// Cria o <path>
const path = document.createElementNS(
  "http://www.w3.org/2000/svg",
  "path"
);

path.setAttribute("d", "M 0 0 L 80 80");
path.style.fill = "transparent";
path.style.stroke = "#000";

svg.appendChild(path);

Criando um círculo em SVG

Agora que sabemos como construir uma linha no <svg> utilizando o <path>, vamos calcular quais seriam as coordenadas de 0° a 360° para construirmos um círculo.


Observação: existe um elemento chamado <circle> no SVG, porém ele não provê alguns comportamentos que serão utilizados no componente Timer descritos neste texto.

Link para mais detalhes sobre o <circle>.


O centro do círculo (timer) será no meio do <svg>, então podemos simplesmente dividir o tamanho do <svg> por 2 para encontrarmos o ponto médio na horizontal (x) e vertical (y):

const SVG_SIZE = 80;
const xyCentroTimer = SVG_SIZE / 2;

Para calcular as coordenadas do circulo podemos utilizar como base:

  • ângulo que queremos obter as coordenadas
  • raio do circulo
  • ponto central

Veja na imagem abaixo que podemos encontrar o x calculando a seguinte expressão (seno(angulo) * raio) + cx, onde cx é o ponto central no eixo x. A mesma lógica se aplica para encontrar y, porém com o cosseno.

Ilustração de como calcular as coordenadas para criar o circulo do timer

Traduzindo isto para código temos:

const SVG_SIZE = 80;
const raio = SVG_SIZE / 2;
const xyCentroTimer = SVG_SIZE / 2;

// Exemplo do cálculo para 0°
const radianoZeroGraus = 0 /* graus */ * (Math.PI / 180);
const x1 = xyCentroTimer
           + (Math.sin(radianoZeroGraus) * raio);
const y1 = xyCentroTimer
           - (Math.cos(radianoZeroGraus) * raio);

// Exemplo do cálculo para 90°
const radiano90Graus = 90 /* graus */ * (Math.PI / 180);
const x2 = xyCentroTimer
           + (Math.sin(radiano90Graus) * raio);
const y2 = xyCentroTimer
           - (Math.cos(radiano90Graus) * raio);

console.log(x1, y1); // Resultado: 40, 0
console.log(x2, y2); // Resultado: 80, 40

Agora que estruturamos o cálculo das coordenadas do circulo, vamos pegar o código acima, adicionar um loop for para calcular as coordenadas a cada 90° e ver o resultado no console:

const SVG_SIZE = 80;
const raio = SVG_SIZE / 2;
const xyCentroTimer = SVG_SIZE / 2;

const coordenadas = [];
for (let grau = 0; grau < 360; grau = grau + 90) {
  const radianos = grau * (Math.PI / 180);

  // Observação: adicionei o Math.round só
  // para ajudar na avaliação inicial. Depois
  // vamos removê-lo
    
  const x = xyCentroTimer
            + Math.round(Math.sin(radianos) * raio);

  const y = xyCentroTimer
            - Math.round(Math.cos(radianos) * raio);

  coordenadas.push([x, y]);
}

// Resultado na imagem abaixo
console.table(coordenadas);
Resultado no console

O console.table() exibe os dados de forma tabular no console conforme a imagem acima. Para mais detalhes:  https://developer.mozilla.org/pt-BR/docs/Web/API/Console/table


Olhando o resultado acima podemos ver que a lógica está correta. Então vamos juntar o código do cálculo das coordenadas com o código que constrói um SVG:

const SVG_SIZE = 80;
const raio = SVG_SIZE / 2;
const xyCentroTimer = SVG_SIZE / 2;

// Cria o <svg>
const svg = document.createElementNS(
  "http://www.w3.org/2000/svg",
  "svg"
);

svg.setAttribute("height", SVG_SIZE);
svg.setAttribute("style", "border: 1px dashed #ff0000");
svg.setAttribute("width", SVG_SIZE);

document.body.appendChild(svg);

// Cria o <path>
const path = document.createElementNS(
  "http://www.w3.org/2000/svg",
  "path"
);

path.style.fill = "transparent";
path.style.stroke = "#000";

svg.appendChild(path);

const coordenadas = [];

// ******************************************
// IMPORTANTE:
//
// Veja que estamos calculando as coordenadas
// a cada 90 graus. Mais adiante vamos
// calcular a cada grau
// ******************************************
for (let grau = 0; grau <= 360; grau = grau + 90) {
  const radianos = grau * (Math.PI / 180);

  const x = xyCentroTimer
            + (Math.sin(radianos) * raio);

  const y = xyCentroTimer
            - (Math.cos(radianos) * raio);

  if (grau === 0) {
    coordenadas.push(`M ${x} ${y}`);
  } else {
    coordenadas.push(`L ${x} ${y}`);
  }
}

//
// O join(' ') concatena o array separando cada
// item por um espaço (' ')
//
path.setAttribute("d", coordenadas.join(' '));

Abaixo temos o resultado da renderização calculando as coordenadas a cada:

  1. 90°
  2. 45°
Resultado do código acima com o loop for a cada 90°, 45° e 1° respectivamente

Criando o visual do Timer

Agora que estruturamos um circulo, vamos ajustar o script para criar dois circulos. O circulo do fundo será cinza claro e o circulo de cima será de outra cor para indicar o progresso do timer.

Para chegarmos neste resultado vamos ter as seguintes etapas:

  1. Calcular todas as coordenadas do circulo
  2. Passar todas as coordenadas para o circulo do fundo, para dar o visual de circulo cinza
  3. Passar algumas coordenadas para o circulo que representa o progresso do timer - depois vamos evoluir este ponto
const STROKE_CIRCULO_CIMA = "#FF0000";
const STROKE_CIRCULO_FUNDO = "#CCC";
const STROKE_WIDTH_CIRCULO_CIMA = 2;
const STROKE_WIDTH_CIRCULO_FUNDO = 1;
const SVG_SIZE = 80;

function criarCirculo(svg, stroke, strokeWidth) {
  const circulo = document.createElementNS(
    "http://www.w3.org/2000/svg",
    "path"
  );

  circulo.style.fill = "transparent";
  circulo.style.stroke = stroke;
  circulo.style.strokeWidth = strokeWidth;

  svg.appendChild(circulo);
  return circulo;
}

function calculaCoordenadas(graus, raio, xyCentroCirculo) {
  const radiano = graus * (Math.PI / 180);

  return {
    x: xyCentroCirculo
       + (Math.sin(radiano) * raio),
    y: xyCentroCirculo
       - (Math.cos(radiano) * raio),
  };
}

const svg = document.createElementNS(
  "http://www.w3.org/2000/svg",
  "svg"
);

svg.setAttribute("width", SVG_SIZE);
svg.setAttribute("height", SVG_SIZE);

document.body.appendChild(svg);

const xyCentroTimer = SVG_SIZE / 2;
const raio = (SVG_SIZE / 2) - STROKE_WIDTH_CIRCULO_CIMA * 2;

const circuloFundo = criarCirculo(
  svg,
  STROKE_CIRCULO_FUNDO,
  STROKE_WIDTH_CIRCULO_FUNDO + "px"
);

const circuloCima = criarCirculo(
  svg,
  STROKE_CIRCULO_CIMA,
  STROKE_WIDTH_CIRCULO_CIMA + "px"
);

const { x: x0, y: y0 } = calculaCoordenadas(0, raio, xyCentroTimer);
const coordenadas = [`M ${x0} ${y0}`];

for (let grau = 1; grau <= 360; grau++) {
  const { x, y } = calculaCoordenadas(grau, raio, xyCentroTimer);
  coordenadas.push(`L ${x} ${y}`);
}

circuloFundo.setAttribute("d", coordenadas.join(" "));

// **********************************************
// IMPORTANTE
//
// Temos 360 itens dentro do array coordenadas.
// Vamos pegar por exemplo, os 35 primeiros itens
// para desenhar um pedaço do circulo de cima 
// simulando uma pequena passagem de tempo
// **********************************************
circuloCima.setAttribute("d", coordenadas.slice(0, 35).join(" "));
Exemplo do timer carregado em 9,77% (os primeiros 35° de um total de 360°)

Até este ponto estruturamos todo o código. Ainda falta ajustarmos o script para que o circulo de cima cresça conforme o tempo for passando. Vamos fazer este ajuste direto na criação do componente Angular.

Criando o componente no Angular

O template do componente é bem simples. Basicamente temos o <svg> com dois <path> dentro. O primeiro <path> será o circulo cinza e o outro será a progressão do timer na cor vermelha:

<svg [attr.height]="svgSize"
     [attr.width]="svgSize">

  <path [attr.d]="circuloFundoCoordenadas"
        [attr.stroke]="circuloFundoStroke"
        [attr.stroke-width]="circuloFundoStrokeWidth"
        fill="transparent"></path>

  <path [attr.d]="circuloTimerCoordenadas"
        [attr.stroke]="circuloTimerStroke"
        [attr.stroke-width]="circuloTimerStrokeWidth"
        fill="transparent"></path>

</svg>
timer.component.html

Veja que temos algumas variáveis como svgSize, circuloTimerCoordenadas, entre outras. Essas variáveis estão na implementação da classe do componente conforme abaixo:

import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';

@Component({
  selector: 'app-timer',
  templateUrl: './timer.component.html',
})
export class TimerComponent implements OnChanges {
  @Input() tempoSegundos: number = 3;
  @Input() svgSize: number = 80;
  @Input() circuloFundoStroke: string = '#ccc';
  @Input() circuloFundoStrokeWidth: number = 1;
  @Input() circuloTimerStroke: string = '#ff0000';
  @Input() circuloTimerStrokeWidth: number = 4;

  coordenadas: Array<string> = [];
  circuloFundoCoordenadas: string = '';
  circuloTimerCoordenadas: string = '';

  _dataInicial: number = (new Date()).getTime();
  _segundosPassados: number = 0;

  constructor() {
    this.renderizarTimer();
  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes.svgSize || changes.tempo) {
      this.renderizarTimer();
    }
  }

  renderizarTimer() {
    const xyCentroTimer = this.svgSize / 2;
    const raio = (this.svgSize / 2) 
                 - this.circuloTimerStrokeWidth * 2;

    const { x: x0, y: y0 } = this.calculaCoordenadas(
      0,
      raio,
      xyCentroTimer
    );

    this.coordenadas = [`M ${x0} ${y0}`];

    for (let grau = 1; grau <= 360; grau++) {
      const { x, y } = this.calculaCoordenadas(
        grau,
        raio,
        xyCentroTimer
      );

      this.coordenadas.push(`L ${x} ${y}`);
    }

    this.circuloFundoCoordenadas = this.coordenadas.join(' ');
    this.circuloTimerCoordenadas = '';
  }

  iniciarContagem() {
    console.time();

    this._dataInicial = (new Date()).getTime();
    this._segundosPassados = 0;

    this.processarContagemRegressiva();
  }

  processarContagemRegressiva() {
    // Porcentagem do tempo que passou
    const porcentagem = this._segundosPassados
                        / this.tempoSegundos;

    this._segundosPassados = 
      ((new Date()).getTime() - this._dataInicial)
      / 1000;
  
    const novasCoordenadas = this.coordenadas
      .slice(0, porcentagem * this.coordenadas.length)
      .join(" ");
  
    this.circuloTimerCoordenadas = novasCoordenadas;
  
    // Esta condição determina se o timer já foi finalizado
    if (this._segundosPassados > this.tempoSegundos) {
      this.circuloTimerCoordenadas = this.coordenadas.join(' ');

      console.timeEnd();

      const tempoDecorrido =
        ((new Date()).getTime() - this._dataInicial)
        / 1000;
      console.log(
        'Tempo decorrido (segundos)',
        tempoDecorrido
      );

     // TODO aqui poderíamos emitir um valor
     // em um @Output para sinalizar que o timer
     // está completo

      return;
    }
  
    // O método é chamado novamente para processar
    // o progresso do timer
    setTimeout(
      this.processarContagemRegressiva.bind(this),
      250
    );
  }

  calculaCoordenadas(graus: number,
                     raio: number,
                     xyCentroCirculo: number): { x: number, y: number } {
    const radiano = graus * (Math.PI / 180);
  
    return {
      x: xyCentroCirculo
         + (Math.sin(radiano) * raio),
      y: xyCentroCirculo
         - (Math.cos(radiano) * raio),
    };
  }
}
timer.component.ts

Analisando o código podemos destacar alguns pontos:

  1. renderizarTimer() constrói o visual inicial do componente com o circulo cinza por trás e guarda as coordenadas do circulo completo (360°)
  2. ngOnChanges é chamado toda vez que houver uma alteração no valor de algum @Input de fora para dentro do componente. Por exemplo, considere o seguinte trecho:

    <app-timer [tempoSegundos]="xyz" ...

    Quando o componente receber o valor de xyzou quando este valor for alterado o ngOnChanges será chamado.
  3. processarContagemRegressiva é o responsável por verificar quanto tempo se passou desde que o método iniciarContagem foi chamado. Ele atualiza o valor da variável circuloTimerCoordenadas que por sua vez atualiza o tamanho do circulo do timer (veja no timer.component.html)

Implementação do módulo:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TimerComponent } from './timer.component';

@NgModule({
  declarations: [
    TimerComponent,
  ],
  imports: [
    CommonModule,
  ],
  exports: [
    TimerComponent,
  ],
})
export class TimerModule {

}
timer.module.ts

Código para testar o componente:

<div>
    <app-timer #timer
               [tempoSegundos]="4"></app-timer>
</div>
<div>
    <button (click)="timer.iniciarContagem()">Iniciar</button>
</div>
app.component.html
Componente timer em uso

Considerações

Com certeza este não é o timer mais bonito que você já viu, mas é um ótimo tema para estudo. Particularmente já utilizei o SVG para a construção de gráficos e o resultado ficou ótimo.

Este componente ainda pode ser evoluído. Podemos adicionar um @Output para nos avisar quando o timer finalizar, podemos adicionar um texto indicando quando tempo falta, etc.

Abaixo está o link com o código completo em funcionamento:

componente-timer-angular2 - StackBlitz
Exemplo de como construir um componente timer (contador regressivo)
Código fonte completo