Creare un componente Storia con elemento lit

1. Introduzione

Le storie sono un componente dell'interfaccia utente molto utilizzato al giorno d'oggi. Le app social e di notizie le stanno integrando nei propri feed. In questo codelab creeremo un componente di storia con lit-element e TypeScript.

Questo è l'aspetto finale del componente della storia:

Un componente completato per lo spettatore di storia che mostra tre immagini di caffè

Possiamo considerare una "storia" di social media o notizie come una raccolta di schede da riprodurre in sequenza, in modo simile a una presentazione. In realtà, le Storie sono letteralmente presentazioni. Le schede sono in genere dominate da un'immagine o da un video in riproduzione automatica e possono avere del testo aggiuntivo in alto. Ecco cosa creeremo:

Elenco delle funzionalità

  • Schede con un'immagine o un video di sfondo.
  • Scorri verso sinistra o verso destra per esplorare la storia.
  • Riproduzione automatica dei video.
  • Possibilità di aggiungere testo o personalizzare le schede in altro modo.

Per quanto riguarda l'esperienza dello sviluppatore di questo componente, sarebbe opportuno specificare le schede delle storie in markup HTML normale, ad esempio:

<story-viewer>
  <story-card>
    <img slot="media" src="some/image.jpg" />
    <h1>Title</h1>
  </story-card>
  <story-card>
    <video slot="media" src="some/video.mp4" loop playsinline></video>
    <h1>Whatever</h1>
    <p>I want!</p>
  </story-card>
</story-viewer>

Aggiungiamo anche questa funzionalità all'elenco.

Elenco delle funzionalità

  • Accetta una serie di schede nel markup HTML.

In questo modo, chiunque può utilizzare il nostro componente della storia semplicemente scrivendo HTML. È ideale sia per i programmatori che per i non programmatori e funziona ovunque in HTML: sistemi di gestione dei contenuti, framework e così via.

Prerequisiti

  • Una shell in cui puoi eseguire git e npm
  • Un editor di testo

2. Configurazione

Inizia clonando questo repository: story-viewer-starter

git clone git@github.com:PolymerLabs/story-viewer-starter.git

L'ambiente è già configurato con lit-element e TypeScript. Basta installare le dipendenze:

npm i

Gli utenti di VS Code devono installare l'estensione lit-plugin per usufruire della compilazione automatica, del controllo del tipo e del linting dei modelli lit-html.

Avvia l'ambiente di sviluppo eseguendo:

npm run dev

Ora puoi iniziare a scrivere codice.

3. Componente <story-card>

Quando crei componenti composti, a volte è più facile iniziare con i componenti secondari più semplici e poi procedere per gradi. Iniziamo creando <story-card>. Deve essere in grado di visualizzare un video o un'immagine a tutta pagina. Gli utenti dovrebbero poterla personalizzare ulteriormente, ad esempio con il testo in overlay.

Il primo passaggio consiste nel definire la classe del componente, che estende LitElement. Il decoratore customElement si occupa di registrare l'elemento personalizzato per noi. Questo è un buon momento per assicurarti di attivare i decoratori in tsconfig con il flag experimentalDecorators (se utilizzi il repository iniziale, è già attivo).

Inserisci il seguente codice in story-card.ts:

import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('story-card')
export class StoryCard extends LitElement {
}

Ora <story-card> è un elemento personalizzato utilizzabile, ma non c'è ancora nulla da visualizzare. Per definire la struttura interna dell'elemento, definisci il metodo di istanza render. Qui forniremo il modello per l'elemento utilizzando il tag html di lit-html.

Che cosa deve contenere il modello di questo componente? L'utente deve essere in grado di fornire due elementi: un elemento multimediale e un overlay. Aggiungeremo un <slot> per ogni elemento.

Le aree sono il modo in cui specifichiamo i figli di un elemento personalizzato da visualizzare. Per ulteriori informazioni, consulta questa guida all'utilizzo degli slot.

import { html } from 'lit';

export class StoryCard extends LitElement {
  render() {
    return html`
      <div id="media">
        <slot name="media"></slot>
      </div>
      <div id="content">
        <slot></slot>
      </div>
    `;
  }
}

Separando l'elemento multimediale in uno slot dedicato, potremo impostarlo come target per operazioni come l'aggiunta di stili a tutta pagina e la riproduzione automatica dei video. Inserisci il secondo slot (quello per gli overlay personalizzati) all'interno di un elemento contenitore in modo da poter fornire un po' di spaziatura predefinita in un secondo momento.

Ora il componente <story-card> può essere utilizzato nel seguente modo:

<story-card>
  <img slot="media" src="some/image.jpg" />
  <h1>My Title</h1>
  <p>my description</p>
</story-card>

Ma sembra terribile:

Un visualizzatore di storie non stilizzato che mostra un&#39;immagine di caffè

Aggiunta dello stile in corso...

Aggiungiamo un po' di stile. Con lit-element, lo facciamo definendo una proprietà styles statica e restituendo una stringa di modello taggata con css. Qualsiasi codice CSS scritto qui si applica solo al nostro elemento personalizzato. In questo senso, il CSS con shadow DOM è davvero utile.

Definisci lo stile dell'elemento multimediale sloted per coprire <story-card>. Anche se siamo qui, possiamo fornire una formattazione piacevole per gli elementi nel secondo spazio. In questo modo, gli utenti del componente possono inserire alcuni <h1>, <p> o altro e vedere qualcosa di bello per impostazione predefinita.

import { css } from 'lit';

export class StoryCard extends LitElement {
  static styles = css`
    #media {
      height: 100%;
    }
    #media ::slotted(*) {
      width: 100%;
      height: 100%;
      object-fit: cover;
    }

    /* Default styles for content */
    #content {
      position: absolute;
      top: 0;
      right: 0;
      bottom: 0;
      left: 0;
      padding: 48px;
      font-family: sans-serif;
      color: white;
      font-size: 24px;
    }
    #content > slot::slotted(*) {
      margin: 0;
    }
  `;
}

un visualizzatore di storie stilizzato che mostra un&#39;immagine di caffè

Ora abbiamo le schede delle storie con contenuti multimediali di sfondo e possiamo inserire tutto ciò che vogliamo sopra. Bene! Tra poco torneremo al corso StoryCard per implementare i video con riproduzione automatica.

4. Componente <story-viewer>

Il nostro elemento <story-viewer> è l'elemento principale degli elementi <story-card>. Sarà responsabile della disposizione delle schede in orizzontale e ci consentirà di scorrere tra di esse. Inizieremo nello stesso modo in cui abbiamo fatto per StoryCard. Vogliamo aggiungere schede della storia come elementi secondari dell'elemento <story-viewer>, quindi aggiungi uno spazio per questi elementi secondari.

Inserisci il seguente codice in story-viewer.ts:

import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('story-viewer')
export class StoryViewer extends LitElement {
  render() {
    return html`<slot></slot>`;
  }
}

Passiamo ora a un layout orizzontale. Possiamo risolvere il problema assegnando a tutti gli elementi <story-card> inseriti in un riquadro il posizionamento assoluto e traducendoli in base al loro indice. Possiamo scegliere come target l'elemento <story-viewer> stesso utilizzando il selettore :host.

static styles = css`
  :host {
    display: block;
    position: relative;
    /* Default size */
    width: 300px;
    height: 800px;
  }
  ::slotted(*) {
    position: absolute;
    width: 100%;
    height: 100%;
  }`;

L'utente può controllare le dimensioni delle schede della storia semplicemente sostituendo esternamente l'altezza e la larghezza predefinite sull'host. Esempio:

story-viewer {
  width: 400px;
  max-width: 100%;
  height: 80%;
}

Per tenere traccia della scheda attualmente visualizzata, aggiungiamo una variabile di istanza index alla classe StoryViewer. Se viene decorato con @property di LitElement, il componente verrà sottoposto nuovamente a rendering ogni volta che il suo valore cambia.

import { property } from 'lit/decorators.js';

export class StoryViewer extends LitElement {
  @property({type: Number}) index: number = 0;
}

Ogni scheda deve essere tradotta orizzontalmente in posizione. Applichiamo queste traduzioni nel metodo del ciclo di vita update di lit-element. Il metodo di aggiornamento viene eseguito ogni volta che una proprietà osservata di questo componente cambia. In genere, effettuiamo una query per lo slot e eseguiamo un ciclo su slot.assignedElements(). Tuttavia, poiché abbiamo una sola area senza nome, è come utilizzare this.children. Usiamo this.children, per comodità.

import { PropertyValues } from 'lit';

export class StoryViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    const width = this.clientWidth;
    Array.from(this.children).forEach((el: Element, i) => {
      const x = (i - this.index) * width;
      (el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
    });
    super.update(changedProperties);
  }
}

I nostri <story-card> sono ora tutti di fila. Funziona ancora con altri elementi, ad esempio quelli per bambini, a patto che applichiamo uno stile adeguato:

<story-viewer>
  <!-- A regular story-card child... -->
  <story-card>
    <video slot="media" src="some/video.mp4"></video>
    <h1>This video</h1>
    <p>is so cool.</p>
  </story-card>
  <!-- ...and other elements work too! -->
  <img style="object-fit: cover" src="some/img.png" />
</story-viewer>

Vai su build/index.html e rimuovi il commento dagli altri elementi della scheda storia. Creiamole in modo da poterle raggiungere!

5. Barra di avanzamento e navigazione

Aggiungeremo poi un modo per spostarsi tra le schede e una barra di avanzamento.

Aggiungiamo alcune funzioni helper a StoryViewer per esplorare la storia. Imposteranno l&#39;indice per noi e lo limiteranno a un intervallo valido.

In story-viewer.ts, nel corso StoryViewer, aggiungi:

/** Advance to the next story card if possible **/
next() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index + 1));
}

/** Go back to the previous story card if possible **/
previous() {
  this.index = Math.max(0, Math.min(this.children.length - 1, this.index - 1));
}

Per mostrare la navigazione all'utente finale, aggiungeremo i pulsanti "Precedente" e "Avanti" a <story-viewer>. Quando viene fatto clic su uno dei due pulsanti, è possibile chiamare la funzione helper next o previous. lit-html semplifica l'aggiunta di listener di eventi agli elementi; è possibile eseguire il rendering dei pulsanti e aggiungere contemporaneamente un listener di clic.

Aggiorna il metodo render come segue:

export class StoryViewer extends LitElement {
  render() {
    return html`
      <slot></slot>

      <svg id="prev" viewBox="0 0 10 10" @click=${() => this.previous()}>
        <path d="M 6 2 L 4 5 L 6 8" stroke="#fff" fill="none" />
      </svg>
      <svg id="next" viewBox="0 0 10 10" @click=${() => this.next()}>
        <path d="M 4 2 L 6 5 L 4 8" stroke="#fff" fill="none" />
      </svg>
    `;
  }
}

Scopri come possiamo aggiungere ascoltatori di eventi in linea sui nostri nuovi pulsanti SVG, direttamente nel metodo render. Questo vale per qualsiasi evento. Basta aggiungere un'associazione del modulo @eventname=${handler} a un elemento.

Aggiungi quanto segue alla proprietà static styles per impostare lo stile dei pulsanti:

svg {
  position: absolute;
  top: calc(50% - 25px);
  height: 50px;
  cursor: pointer;
}
#next {
  right: 0;
}

Per la barra di avanzamento, utilizzeremo la griglia CSS per applicare uno stile alle piccole caselle, una per ogni scheda della storia. Possiamo utilizzare la proprietà index per aggiungere alle caselle classi condizionali per indicare se sono state "visualizzate" o meno. Potremmo utilizzare un'espressione condizionale come i <= this.index : 'watched': '', ma le cose potrebbero diventare troppo verbose se aggiungiamo altre classi. Fortunatamente, lit-html invia un'istruzione chiamata classMap per aiutarti. Innanzitutto, importa classMap:

import { classMap } from 'lit/directives/class-map';

Aggiungi il seguente markup alla fine del metodo render:

<div id="progress">
  ${Array.from(this.children).map((_, i) => html`
    <div
      class=${classMap({watched: i <= this.index})}
      @click=${() => this.index = i}
    ></div>`
  )}
</div>

Abbiamo anche aggiunto altri gestori dei clic per consentire agli utenti di passare direttamente alla scheda di una storia specifica, se lo desiderano.

Ecco i nuovi stili da aggiungere a static styles:

::slotted(*) {
  position: absolute;
  width: 100%;
  /* Changed this line! */
  height: calc(100% - 20px);
}

#progress {
  position: relative;
  top: calc(100% - 20px);
  height: 20px;
  width: 50%;
  margin: 0 auto;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  grid-gap: 10px;
  align-content: center;
}
#progress > div {
  background: grey;
  height: 4px;
  transition: background 0.3s linear;
  cursor: pointer;
}
#progress > div.watched {
  background: white;
}

Barra di navigazione e di avanzamento completate. Ora aggiungiamo un tocco di stile.

6. Scorrimento

Per implementare lo scorrimento, utilizzeremo la libreria di controllo dei gesti Hammer.js. Hammer rileva gesti speciali come le panoramiche e invia eventi con informazioni pertinenti (ad esempio delta X) che possiamo utilizzare.

Ecco come possiamo utilizzare Hammer per rilevare le panoramiche e aggiornare automaticamente il nostro elemento ogni volta che si verifica un evento di panoramica:

import { state } from 'lit/decorators.js';
import 'hammerjs';

export class StoryViewer extends LitElement {
  // Data emitted by Hammer.js
  @state() _panData: {isFinal?: boolean, deltaX?: number} = {};

  constructor() {
    super();
    this.index = 0;
    new Hammer(this).on('pan', (e: HammerInput) => this._panData = e);
  }
}

Il costruttore di una classe LitElement è un altro ottimo posto per collegare listener di eventi sull'elemento host stesso. Il costruttore Martello prende un elemento per rilevare i gesti. Nel nostro caso, si tratta del StoryViewer stesso o di this. Poi, utilizzando l'API di Hammer, diciamo di rilevare il gesto di "pan" e di impostare le informazioni sulla panoramica in una nuova proprietà _panData.

Decorando la proprietà _panData con @state, LitElement osserverà le modifiche apportate a _panData ed eseguirà un aggiornamento, ma non verrà associato alcun attributo HTML alla proprietà.

A questo punto, aumentiamo la logica update per utilizzare i dati della panoramica:

// Update is called whenever an observed property changes.
update(changedProperties: PropertyValues) {
  // deltaX is the distance of the current pan gesture.
  // isFinal is whether the pan gesture is ending.
  let { deltaX = 0, isFinal = false } = this._panData;
  // When the pan gesture finishes, navigate.
  if (!changedProperties.has('index') && isFinal) {
    deltaX > 0 ? this.previous() : this.next();
  }
  // We don't want any deltaX when releasing a pan.
  deltaX = isFinal ? 0 : deltaX;
  const width = this.clientWidth;
  Array.from(this.children).forEach((el: Element, i) => {
    // Updated this line to utilize deltaX.
    const x = (i - this.index) * width + deltaX;
    (el as HTMLElement).style.transform = `translate3d(${x}px,0,0)`;
  });

  // Don't forget to call super!
  super.update(changedProperties);
}

Ora possiamo trascinare le schede della storia avanti e indietro. Per semplificare le cose, torniamo a static get styles e aggiungiamo transition: transform 0.35s ease-out; al selettore ::slotted(*):

::slotted(*) {
  ...
  transition: transform 0.35s ease-out;
}

Ora lo scorrimento è fluido:

Navigare tra le schede delle storie con scorrimento fluido

7. Riproduzione automatica

L'ultima funzionalità che aggiungeremo è la riproduzione automatica dei video. Quando una scheda della storia diventa attiva, vogliamo che venga riprodotto il video in background, se esistente. Quando una scheda della storia non è più attiva, dobbiamo mettere in pausa il video.

Lo implementeremo inviando eventi personalizzati "entered" ed "exited" agli elementi secondari appropriati ogni volta che l'indice cambia. In StoryCard, riceveremo questi eventi e riprodurremo o metteremo in pausa i video esistenti. Perché scegliere di inviare eventi ai figli anziché chiamare i metodi di istanza "entered" ed "exited" definiti in StoryCard? Con i metodi, gli utenti del componente non avrebbero altra scelta che scrivere un elemento personalizzato se volessero scrivere la propria scheda della storia con animazioni personalizzate. Con gli eventi, possono semplicemente allegare un ascoltatore di eventi.

Eseguiamo il refactoring della proprietà index di StoryViewer in modo da utilizzare un setter, che fornisce un comodo percorso di codice per inviare gli eventi:

class StoryViewer extends LitElement {
  @state() private _index: number = 0
  get index() {
    return this._index
  }
  set index(value: number) {
    this.children[this._index].dispatchEvent(new CustomEvent('exited'));
    this.children[value].dispatchEvent(new CustomEvent('entered'));
    this._index = value;
  }
}

Per completare la funzionalità di riproduzione automatica, aggiungeremo nel costruttore StoryCard gli ascoltatori di eventi per "entered" e "exited" che riprodurranno e metteranno in pausa il video.

Ricorda che l'utente del componente può assegnare o meno all'<story-card> un elemento video nell'area multimediale. Potrebbero anche non fornire alcun elemento nell'area multimediale. Dobbiamo fare attenzione a non chiamare play in img o null.

Tornando a story-card.ts, aggiungi quanto segue:

import { query } from 'lit/decorators.js';

class StoryCard extends LitElement {
  constructor() {
    super();

    this.addEventListener("entered", () => {
      if (this._slottedMedia) {
        this._slottedMedia.currentTime = 0;
        this._slottedMedia.play();
      }
    });

    this.addEventListener("exited", () => {
      if (this._slottedMedia) {
        this._slottedMedia.pause();
      }
    });
  }

 /**
  * The element in the "media" slot, ONLY if it is an
  * HTMLMediaElement, such as <video>.
  */
 private get _slottedMedia(): HTMLMediaElement|null {
   const el = this._mediaSlot && this._mediaSlot.assignedNodes()[0];
   return el instanceof HTMLMediaElement ? el : null;
 }

  /**
   * @query(selector) is shorthand for
   * this.renderRoot.querySelector(selector)
   */
  @query("slot[name=media]")
  private _mediaSlot!: HTMLSlotElement;
}

Riproduzione automatica completata. ✅

8. Punta la bilancia

Ora che abbiamo tutte le funzionalità essenziali, aggiungiamone un'altra: un delizioso effetto di scala. Torniamo ancora una volta al metodo update di StoryViewer. Viene eseguita un'operazione matematica per ottenere il valore nella costante scale. Sarà uguale a 1.0 per l'elemento secondario attivo e a minScale in caso contrario, eseguendo l'interpolazione anche tra questi due valori.

Modifica il loop nel metodo update in story-viewer.ts in modo che sia:

update(changedProperties: PropertyValues) {
  // ...
  const minScale = 0.8;
  Array.from(this.children).forEach((el: Element, i) => {
    const x = (i - this.index) * width + deltaX;

    // Piecewise scale(deltaX), looks like: __/\__
    const u = deltaX / width + (i - this.index);
    const v = -Math.abs(u * (1 - minScale)) + 1;
    const scale = Math.max(v, minScale);
    // Include the scale transform
    (el as HTMLElement).style.transform = `translate3d(${x}px,0,0) scale(${scale})`;
  });
  // ...
}

Abbiamo terminato. In questo post abbiamo affrontato molti argomenti, tra cui alcune funzionalità di LitElement e lit-html, elementi di slot HTML e controllo tramite gesti.

Per una versione completa di questo componente, visita: https://github.com/PolymerLabs/story-viewer.