Criando um componente stories - Angular

O componente Stories é um recurso popular, utilizado por diversas redes sociais. Neste texto é apresentado como podemos construir um componente stories utilizando um pouco de CSS, HTML e Angular.

Banner Como construir um componente stories com Angular
Como construir um componente stories com Angular

O componente stories é amplamente utilizado por diversos sites e aplicativos. Na minha opinião ele tem algumas semelhanças com o carrossel, que inclusive utilizei bastante no passado em conjunto com a lib jQuery. Neste texto gostaria de compartilhar como podemos construir um componente stories simples, utilizando Angular e um pouco de HTML e CSS. Ao longo do texto também vou abordar alguns assuntos pontuais que podem ser úteis no cotidiano do desenvolvedor.

Estruturando o projeto

Para estruturar o projeto utilizei as seguintes versões:

$ ng version

Angular CLI: 15.2.4
Node: 18.14.2
Package Manager: npm 9.5.0
OS: darwin arm64

Criando o projeto para desenvolver o componente stories:

# Cria o diretório
$ mkdir consolelog-stories

# Acessa o diretório recém criado
$ cd consolelog-stories/

# Cria o projeto Angular
#
# Adicionei o skip-tests e skip-git porque
# é um ambiente de estudo, então não é necessário
# criar testes unitários nem inicializar o
# repositório git
$ ng new projeto-stories --skip-tests --skip-git

? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS

$ cd projeto-stories

# Executa o projeto:
$ npm run start

Executando o projeto na porta padrão (4200):

Navegador mostrando o conteúdo do endereço http://localhost:4200
Projeto em execução

Agora que o projeto está criado, vamos partir para os arquivos que irão fazer parte do componente stories.

Estruturando o componente

Utilizei o CLI do Angular para criar os arquivos:

$ ng generate module stories
CREATE src/app/stories/stories.module.ts (193 bytes)

$ ng generate component stories --module stories --skip-tests
CREATE src/app/stories/stories.component.css (0 bytes)
CREATE src/app/stories/stories.component.html (22 bytes)
CREATE src/app/stories/stories.component.ts (206 bytes)
UPDATE src/app/stories/stories.module.ts (273 bytes)

$ ng generate class stories/StoriesItem --skip-tests --type model
CREATE src/app/stories/stories-item.model.ts (29 bytes)

Observação: repare que na criação da classe StoriesItem o valor do parâmetro --type é adicionado no final do nome do arquivo: nome-do-arquivo.<valor do type>.ts.

Configurando o prettier (opcional)

Escrevi "opcional" neste tópico porque o procedimento descrito não é obrigatório para o componente que será construído, mas vale investir alguns minutos caso você nunca tenha lido ou usado a lib prettier.

Esta lib, prettier, ajuda a padronizar a formatação do código, é um ótimo recurso para criar um padrão consistente de formatação. Para iniciar sua configuração basta adicioná-la ao projeto utilizando o npm:

npm i prettier --save-dev

O --save-dev instala pacotes que serão usados ao longo de desenvolvimento, mas que não são usados para executar o projeto.

The --save-dev option allows you to save packages under the devDependencies object in your package.json file. Any packages listed under the devDependencies will not be installed when you are in the production environment.

https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&ved=2ahUKEwjmt9mNq6_-AhW1r5UCHUVgCC0QFnoECAsQAw&url=https%3A%2F%2Fsebhastian.com%2Fnpm-save-dev%2F&usg=AOvVaw1SaVueiuPop8E7eAGivIlJ

Após a instalação criei o arquivo .prettierrc na raiz do projeto (no mesmo nível do arquivo package.json) para indicar alguns padrões de formatação como quantidade máxima de caracteres em uma linha, tamanho da tabulação, entre outros.

{
  "bracketSameLine": false,
  "bracketSpacing": true,
  "printWidth": 60,
  "singleQuote": true,
  "tabWidth": 2,
  "useTabs": false
}
.prettierrc

Para saber mais detalhes sobre as opções de configuração consulte este link.

Com isto já podemos utilizar o prettier para formatar um código:

  1. abra o arquivo stories.module.ts que foi gerado pelo CLI do Angular
  2. pressione ctrl + shift + p no Windows ou command + shift + P no MacOS para abrir as opções no Visual Studio Code.
  3. escolha a opção Format Document With...
  4. selecione prettier
Lista de opções de formatação de código no Visual Studio Code
Selecionando o formatador de código

Antes da formatação:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoriesComponent } from './stories.component';



@NgModule({
  declarations: [
    StoriesComponent
  ],
  imports: [
    CommonModule
  ]
})
export class StoriesModule { }
app.module.ts (antes da formatação)

Após a formatação:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoriesComponent } from './stories.component';

@NgModule({
  declarations: [StoriesComponent],
  imports: [CommonModule],
})
export class StoriesModule {}
app.module.ts (depois da formatação)

Aqui vale uma dica, podemos criar um script dentro do package.json para formatar todos os arquivos desejados. Criei o script format para executar esta tarefa:

{
    "ng": "ng",
    "format": "npx prettier --write \"src/**/*.ts\" \"src/**/*.css\" \"src/**/*.html\"",
    "start": "ng serve",
    "build": "ng build",
    "watch": "ng build --watch --configuration development",
    "test": "ng test"
  }
trecho do arquivo package.json

Executando o script:

$ npm run format


> [email protected] format
> npx prettier --write "src/**/*.ts" "src/**/*.css" "src/**/*.html"

src/app/app.component.ts 90ms
src/app/app.module.ts 4ms
src/app/stories/stories.component.ts 3ms
src/app/stories/stories.module.ts 4ms
src/main.ts 4ms
src/app/app.component.css 11ms
src/app/stories/stories.component.css 1ms
src/styles.css 4ms
src/app/app.component.html 113ms
src/app/stories/stories.component.html 7ms
src/index.html 3ms

Agora todos os arquivo estão formatados 😬

O legal do uso do prettier é que conseguimos criar uma consistência na formatação do código, assim o projeto inteiro terá a mesma padronização em sua formatação.

Implementação

Voltando ao componente stories, podemos perceber que o stories.component.ts já foi declarado no stories.module.ts, isto porque indicamos o módulo na criação do componente no comando do CLI. Então a única coisa necessária é adicionar o componente no exports para expor o StoriesComponent para quem importar o StoriesModule:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { StoriesComponent } from './stories.component';

@NgModule({
  declarations: [StoriesComponent],
  exports: [StoriesComponent],
  imports: [CommonModule],
})
export class StoriesModule {}
stories.module.ts

O arquivo stories-item.model.ts terá um modelo de dados para representar o conteúdo que o componente stories irá mostrar. Para isto vamos considerar que cada slide (imagem) que irá aparecer no componente será representada pelo seguinte conteúdo:

export interface StoriesItem {
  descricao: string;
  descricaoCorHex: string;
  status: StoriesItemStatus;
  urlImagem: string;
}

export enum StoriesItemStatus {
  'ativo' = 'ativo',
  'aguardando' = 'aguardando',
  'finalizado' = 'finalizado',
}
stories-item.model.ts
  • descricao: texto que irá aparecer no meio da imagem
  • descricaoCorHex: cor em hexadecimal do texto (acima)
  • status: será utilizado no HTML e também na lógica para controlar a transição das imagens
  • urlImagem: endereço da imagem que será exibida

O mecanismo para gerenciar o conteúdo que é apresentado na tela ficará no stories.component.ts. Acrescentei alguns comentários para facilitar o entendimento:

import {
  Component,
  Input,
  OnChanges,
  OnDestroy,
} from '@angular/core';
import { Subscription, timer } from 'rxjs';
import {
  StoriesItem,
  StoriesItemStatus,
} from './stories-item.model';

@Component({
  selector: 'app-stories',
  templateUrl: 'stories.component.html',
  styleUrls: ['stories.component.css'],
})
export class StoriesComponent
  implements OnChanges, OnDestroy
{
  private controleIntervalo!: Subscription;

  @Input()
  intervaloEntreSlidesEmSegundos: number = 3;

  @Input()
  itens: StoriesItem[] = [];

  /**
   * Retorna o index do array "itens" que
   * está com o status ativo
   */
  get slideAtivoIndex(): number {
    return this.itens.findIndex(
      (a) => a.status === StoriesItemStatus.ativo
    );
  }

  /**
   * Retorna o item do array "itens" que
   * está com o status ativo
   */
  get slideAtivo(): StoriesItem {
    return this.itens[this.slideAtivoIndex];
  }

  // Quando o componente receber um valor
  // em algums @Input, este método será
  // executado
  ngOnChanges() {
    this.reset();
  }

  ngOnDestroy() {
    this.pararTimer();
  }

  reset() {
    this.pararTimer();

    this.itens.forEach(
      (a) => (a.status = StoriesItemStatus.aguardando)
    );
    this.itens[0].status = StoriesItemStatus.ativo;

    this.iniciarTimer();
  }

  iniciarTimer(): void {
    this.controleIntervalo = timer(
      this.intervaloEntreSlidesEmSegundos * 1e3
    ).subscribe(() => {
      this.avancarSlide();
    });
  }

  avancarSlide(): void {
    this.irParaSlide(this.slideAtivoIndex + 1);
  }

  pararTimer(): void {
    this.controleIntervalo?.unsubscribe();
  }

  irParaSlide(index: number) {
    this.pararTimer();

    // Faz um ajuste no valor do index:
    //      index = 1
    //         │
    //         ▼
    // ┌───┐ ┌───┐ ┌───┐
    // │ 0 │ │ 1 │ │ 2 │
    // └───┘ └───┘ └───┘
    //      index = 3
    //         │    ajusta o valor
    //   ┌─────┘    para 0
    //   ▼
    // ┌───┐ ┌───┐ ┌───┐
    // │ 0 │ │ 1 │ │ 2 │
    // └───┘ └───┘ └───┘
    //
    //      index = -1
    //         │    ajusta o valor
    //         └─────┐para 2
    //               ▼
    // ┌───┐ ┌───┐ ┌───┐
    // │ 0 │ │ 1 │ │ 2 │
    // └───┘ └───┘ └───┘
    //
    // Obs.: Para desenhar assim:
    // https://asciiflow.com/#/
    //
    if (index >= this.itens.length) {
      index = 0;
    } else if (index < 0) {
      index = this.itens.length - 1;
    }

    // O trecho abaixo ajusta os status:
    // Itens cujo o index seja menor que o valor da
    // variável "index" são marcados como finalizados
    for (let i = 0; i < index; i++) {
      this.itens[i].status = StoriesItemStatus.finalizado;
    }

    this.itens[index].status = StoriesItemStatus.ativo;

    // Itens cujo o index seja maior que o valor da
    // variável "index" são marcados como aguardando
    for (let i = index + 1; i < this.itens.length; i++) {
      this.itens[i].status = StoriesItemStatus.aguardando;
    }

    this.iniciarTimer();
  }
}
stories.component.ts

Para testar a lógica vamos adicionar o seguinte conteúdo no template:

<div>
  <pre><code>{{ slideAtivo | json }}</code></pre>
</div>
<div>
  <hr />
  <pre><code>{{ itens | json }}</code></pre>
</div>
stories.component.html

Também não podemos esquecer de importar o StoriesModule no AppModule e ajustar o template do AppComponent:

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';

import { AppComponent } from './app.component';
import { StoriesModule } from './stories/stories.module';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, StoriesModule],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}
app.module.ts
import { Component } from '@angular/core';
import {
  StoriesItem,
  StoriesItemStatus,
} from './stories/stories-item.model';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
})
export class AppComponent {
  itens: StoriesItem[] = [
    {
      descricao:
        'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Incidunt, modi!',
      descricaoCorHex: '#fff',
      status: StoriesItemStatus.aguardando,
      urlImagem:
        'https://wallpapers.com/images/hd/splashing-water-gradient-background-mobile-v5hlk4ta55rn5w3x.jpg',
    },
    {
      descricao:
        'Lorem ipsum dolor sit amet consectetur adipisicing elit. Saepe doloribus aliquam repudiandae officiis et itaque.',
      descricaoCorHex: '#fff',
      status: StoriesItemStatus.aguardando,
      urlImagem:
        'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQQQjw1nyTi1qvWVyCufBIEuFx8B6Zn9TOLVw&usqp=CAU',
    },
    {
      descricao: 'Lorem ipsum dolor sit amet.',
      descricaoCorHex: '#fff',
      status: StoriesItemStatus.aguardando,
      urlImagem:
        'https://marketplace.canva.com/EAFJd1mhO-c/1/0/900w/canva-colorful-watercolor-painting-phone-wallpaper-qq02VzvX2Nc.jpg',
    },
  ];
}
app.component.ts
<app-stories [itens]="itens"></app-stories>
app.component.html

Executando o projeto podemos observar a transição do slideAtivo e do valor do campo status nos itens:

Navegador no endereço localhost:4200 mostrando a atualização dos dados do componente stories a cada 3 segundos
Executando o projeto

Até este ponto sabemos que a lógica funciona bem, então podemos focar no HTML e CSS.

Criando o HTML e CSS

Para não deixar o texto tão extenso colei abaixo o código completo do HTML e CSS com vários comentários para facilitar o entendimento:

<div class="container">
  <!-- ###################################### -->
  <!-- BARRAS DE PROGRESSO                    -->
  <!-- ###################################### -->
  <div class="barra-progresso-container">
    <!--
      Faz um loop para criar uma barra de
      progresso para cada imagem.
      Este <div> será o background da
      barra de progresso.
    -->
    <div
      *ngFor="let item of itens; let i = index"
      (click)="irParaSlide(i)"
      class="barra-progresso"
    >
      <!--
        Indicador de percentual que avança
        conforme o tempo.
        
        O tempo é indicado no animation-duration,
        ou seja, o valor '3s' indica que a barra
        irá de 0% a 100% de largura em 3 segundos.

        O 'data-status' indica a situação da
        barra. Utilizamos este atributo como
        seletor no CSS.
      -->
      <div
        class="barra-progresso-percentual"
        [attr.data-status]="item.status"
        [style.animation-duration]="
          intervaloEntreSlidesEmSegundos + 's'
        "
      ></div>
    </div>
  </div>

  <!-- ###################################### -->
  <!-- IMAGEM E DESCRIÇÃO                     -->
  <!-- ###################################### -->
  <div
    class="imagem"
    [style.background-image]="
      'url(' + slideAtivo.urlImagem + ')'
    "
  >
    <!-- Descrição -->
    <div [style.color]="slideAtivo.descricaoCorHex">
      {{ slideAtivo.descricao }}
    </div>
  </div>

  <!-- ###################################### -->
  <!-- PREFETCH DAS IMAGENS                   -->
  <!--
    Faz o pre fetch das imagens. Assim não
    haverá um delay durante a primeira exibição
    de cada imagem.

    Comente este trecho com a opção Disable
    Cache marcada no seu DevTools para observar
    este comportamento.
  -->
  <!-- ###################################### -->
  <img
    *ngFor="let item of itens"
    [src]="item.urlImagem"
    style="width: 1px; height: 1px; opacity: 0"
  />
</div>
stories.component.html
/* ***************************************************** */
/* VARIÁVEIS UTILIZADAS AO LONGO DAS CLASSES             */
/* ***************************************************** */
::ng-deep :root {
  --altura-imagem: 80vh;
  --barra-progress-gap: 16px;
  --container-background: radial-gradient(#666, #000);
  --descricao-font: 32px Georgia;
  --largura-imagem: 60vw;
  --largura-maxima-imagem: 480px;
}

.container {
  background-repeat: no-repeat;
  background-size: cover;
  background: var(--container-background);
  height: 100%;
  left: 0;
  position: absolute;
  top: 0;
  width: 100%;
}

/* ****************** */
/* IMAGEM E TEXTO     */
/* ****************** */

.container .imagem {
  align-items: center;
  background-repeat: no-repeat;
  background-size: cover;
  border-radius: 8px;
  box-shadow: 0px 0px 10px #eee;
  display: flex;
  height: var(--altura-imagem);
  margin: 0 auto;
  max-width: var(--largura-maxima-imagem);
  transition: all 0.5s ease-in-out;
  width: var(--largura-imagem);
}

.container .imagem div {
  background: rgba(0, 0, 0, 0.5);
  flex-grow: 1;
  font: var(--descricao-font);
  padding: 12px;
  text-align: center;
}

/* ****************** */
/* BARRA DE PROGRESSO */
/* ****************** */
.barra-progresso-container {
  display: flex;
  gap: var(--barra-progress-gap);
  margin: 12px auto;
  max-width: var(--largura-maxima-imagem);
  width: var(--largura-imagem);
}

.barra-progresso,
.barra-progresso-percentual {
  border-radius: 8px;
  flex-grow: 1;
  height: 4px;
}

.barra-progresso {
  background: #fff;
  cursor: pointer;
  width: 100%;
}

.barra-progresso-percentual {
  background: #aaa;
  width: 0;
}

.barra-progresso-percentual[data-status='ativo'] {
  animation-fill-mode: forwards;
  animation-timing-function: linear;
  animation: animacao-progresso;
}

.barra-progresso-percentual[data-status='finalizado'] {
  width: 100%;
}

@keyframes animacao-progresso {
  0% {
    width: 0;
  }

  100% {
    width: 100%;
  }
}
stories.component.css

Resultado:

Sequência de imagens sendo exibidas uma a cada 3 segundos. Resultante do componente stories
Resultado

Aplicando eventos de teclado

Até este ponto o componente já troca as imagens a cada intervalo de tempo definido no componente. A ideia deste tópico é explorar um mecanismo para avançarmos ou retrocedermos a sequência de imagens através do teclado, das teclas direcionais esquerda e direita.

Olha o código podemos observar que já existe um método chamado avancarSlide que mostra a próxima imagem da sequência. Então para retroceder podemos criar um novo método conforme abaixo:

// método já existente
avancarSlide(): void {
  this.irParaSlide(this.slideAtivoIndex + 1);
}

// novo método
retrocederSlide(): void {
  this.irParaSlide(this.slideAtivoIndex - 1);
}
trecho do arquivo stories.component.ts

Para ativar um destes métodos quando as setas direita e esquerda do teclado  forem pressionadas, podemos utilizar o decorator HostListener passando o parâmetro document:keydown.<tecla>:

@HostListener('document:keydown.ArrowRight')
avancarSlide(): void {
  this.irParaSlide(this.slideAtivoIndex + 1);
}

@HostListener('document:keydown.ArrowLeft')
retrocederSlide(): void {
  this.irParaSlide(this.slideAtivoIndex - 1);
}
trecho do arquivo stories.component.ts

Veja no resultado abaixo que ao pressionar as setas do teclado (esquerda e direita) o componente avança ou retrocede a imagem:

Sequência de imagens sendo exibidas
Avançando/retrocedendo a imagem pressionando a seta esquerda ou direita do teclado

Considerações

Existem diversas formas de chegarmos neste mesmo resultado, a que apresentei aqui é apenas uma delas.

Deixo como sugestão, além de estudar o código, construir uma nova função para que a transição das imagens pause enquanto o botão do mouse estiver pressionado em cima da imagem.

Link com o exemplo e código completo:

https://stackblitz.com/edit/consolelog-construindo-componente-stories?file=src/main.ts