Creare un componente Storia con elemento lit

Creare un componente Storia con elemento lit

Informazioni su questo codelab

subjectUltimo aggiornamento: ott 1, 2021
account_circleScritto da: Steven Traversi

1. Introduzione

Le storie sono un componente dell'interfaccia utente molto diffuso 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 pensare a una "storia" di social media o di notizie come una raccolta di schede da riprodurre in sequenza, come una presentazione. In realtà, le storie sono letteralmente presentazioni. Le schede sono generalmente predominanti da un'immagine o un video con riproduzione automatica e possono avere del testo aggiuntivo nella parte superiore. 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 degli sviluppatori con questo componente, sarebbe utile specificare le schede delle storie in markup HTML semplice, come nel seguente 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 quindi questo elemento all'elenco delle caratteristiche.

Elenco delle funzionalità

  • Accetta una serie di schede nel markup HTML.

In questo modo, chiunque può utilizzare il nostro componente storia semplicemente scrivendo l'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 possono installare l'estensione lit-plugin per ottenere il completamento automatico, il controllo del tipo e il lint dei modelli lit-html.

Avvia l'ambiente di sviluppo eseguendo:

npm run dev

Puoi iniziare a programmare.

3. <story-card> Componente

Quando si creano componenti composti, a volte è più facile iniziare con i sottocomponenti più semplici e creare. Iniziamo creando <story-card>. Deve essere in grado di visualizzare un video al vivo o un'immagine. Gli utenti devono essere in grado di personalizzarla ulteriormente, ad esempio con 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 al posto nostro. Questo è un buon momento per assicurarti di attivare i decorator 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 al momento non c'è nulla da visualizzare. Per definire la struttura interna dell'elemento, definisci il metodo dell'istanza render. È qui che forniremo il modello per l'elemento, utilizzando il tag html di lit-html.

Cosa deve essere contenuto nel modello di questo componente? L'utente deve essere in grado di fornire due elementi: un elemento multimediale e un overlay. Quindi, aggiungeremo un <slot> per ciascuno di questi elementi.

Le aree sono il modo in cui specifichiamo i figli di un elemento personalizzato da visualizzare. Per ulteriori informazioni, ecco un'ottima procedura dettagliata sull'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 un'area dedicata ci aiuterai a sceglierlo come target per elementi come l'aggiunta di stili al vivo e la riproduzione automatica dei video. Inserisci la seconda area (quella per gli overlay personalizzati) all'interno di un elemento contenitore in modo da poter fornire una spaziatura interna predefinita in un secondo momento.

Ora il componente <story-card> può essere utilizzato in questo modo:

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

Ma sembra terribile:

uno spettatore senza stile che mostra l&#39;immagine di un 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. Qualunque elemento CSS qui riportato si applica solo al nostro elemento personalizzato. CSS con shadow DOM è davvero utile in questo modo.

Definisci lo stile dell'elemento multimediale slotted per coprire <story-card>. Anche se siamo qui, possiamo fornire una formattazione piacevole per gli elementi nel secondo spazio. In questo modo, gli utenti dei componenti possono inserire alcuni valori <h1>, <p> o qualsiasi altro elemento e vedere qualcosa di interessante 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;
   
}
 
`;
}

uno spettatore di storie in stile che mostra l&#39;immagine di un caffè

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

4. <story-viewer> Componente

Il nostro elemento <story-viewer> è l'elemento principale di <story-card>. Sarà responsabile di disporre le schede orizzontalmente e consentirci di passare da una all'altra. Inizieremo come abbiamo fatto per StoryCard. Desideriamo aggiungere le schede delle storie 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>`;
 
}
}

Poi c'è un layout orizzontale. Possiamo affrontare questo problema assegnando a tutti i <story-card> in slot il posizionamento assoluto e traducendolo in base al relativo 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 nostre schede delle storie 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 lo decori 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. Di solito, eseguiamo una query per lo slot ed eseguiamo il loop 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 a 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

Successivamente, aggiungeremo un modo per navigare tra le schede e una barra di avanzamento.

Aggiungiamo alcune funzioni helper a StoryViewer per esplorare la storia. Imposterà l'indice per noi bloccandolo su 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, aggiungiamo "precedente" e "Avanti" pulsanti per <story-viewer>. Quando viene fatto clic su uno dei due pulsanti, vogliamo chiamare la funzione helper next o previous. lit-html semplifica l'aggiunta di listener di eventi agli elementi; possiamo 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 aggiungere listener di eventi incorporati nei 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 applicare uno stile ai pulsanti:

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

Per la barra di avanzamento, useremo la griglia CSS per definire le piccole caselle, una per ogni scheda della storia. Possiamo utilizzare la proprietà index per aggiungere in modo condizionale alle caselle le classi per indicare se sono state "viste" o meno. Potremmo utilizzare un'espressione condizionale come i <= this.index : 'watched': '', ma se aggiungiamo altre classi, il risultato potrebbe diventare dettagliato. Fortunatamente, lit-html invia un'istruzione chiamata classMap per aiutarti. Innanzitutto, importa classMap:

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

Aggiungi il seguente markup in fondo al 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 avanzamento e navigazione completate. Ora aggiungi un tocco di stile.

6. Scorrimento

Per implementare lo scorrimento, utilizzeremo la libreria di controlli dei gesti Hammer.js. Martello rileva gesti speciali come le padelle e invia gli eventi con informazioni pertinenti (come ilta X) che possiamo consumare.

Ecco come possiamo utilizzare Martello per rilevare le pentole e aggiornare automaticamente l'elemento ogni volta che si verifica un evento pan:

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 dello stesso StoryViewer o this. Quindi, utilizzando l'API di Hammer, gli chiediamo di rilevare il "pan" e impostare le informazioni sulla panoramica su una nuova proprietà _panData.

Se decori la proprietà _panData con @state, LitElement osserverà le modifiche a _panData ed eseguirà un aggiornamento, ma non ci sarà un attributo HTML associato per la proprietà.

Ora incrementa 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 delle storie 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 abbiamo uno 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 l'obiettivo è una scheda di una storia, è necessario che venga riprodotto il video in background, se presente. Quando la scheda di una storia non è più in primo piano, dobbiamo mettere in pausa il video.

Lo implementeremo inviando "inserito" ed "esci" eventi personalizzati sugli elementi secondari appropriati ogni volta che l'indice cambia. Tra StoryCard, riceveremo questi eventi e riprodurremo o metteremo in pausa i video esistenti. Perché scegliere di inviare eventi agli elementi secondari invece di chiamare "in entrata" ed "esci" metodi di istanza definiti su StoryCard? Con i metodi, gli utenti del componente non avrebbero altra scelta se non scrivere un elemento personalizzato se volessero scrivere la propria scheda di storia con animazioni personalizzate. Con gli eventi, possono semplicemente collegare un listener 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 terminare la funzionalità di riproduzione automatica, aggiungeremo i listener di eventi per "inserito" ed "esci" nel costruttore StoryCard che riproduce e mette in pausa il video.

Ricorda che l'utente del componente può assegnare o meno all'<story-card> un elemento video nell'area multimediale. Potrebbero non includere nemmeno un elemento nello spazio 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 effetto di scalabilità. Torniamo ancora una volta al metodo update di StoryViewer. Vengono effettuati alcuni calcoli per ottenere il valore della costante scale. Uguale a 1.0 per l'elemento secondario attivo e a minScale, altrimenti viene interpolata 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.