1. Introduzione
Le storie sono un componente dell'interfaccia utente molto diffuso al giorno d'oggi. Le app di social media e notizie le stanno integrando nei loro feed. In questo codelab creeremo un componente della storia con lit-element e TypeScript.
Ecco come apparirà il componente della storia al termine:

Possiamo considerare una "storia" di social media o notizie come una raccolta di schede da riprodurre in sequenza, un po' come una presentazione. In realtà, le storie sono letteralmente presentazioni. Le schede sono in genere dominate da un'immagine o da un video con riproduzione automatica e possono avere testo aggiuntivo in primo piano. Ecco cosa creeremo:
Elenco delle funzionalità
- Schede con un'immagine o un video di sfondo.
- Scorri verso sinistra o verso destra per navigare nella storia.
- Video con riproduzione automatica.
- Possibilità di aggiungere testo o personalizzare le schede.
Per quanto riguarda l'esperienza di sviluppo di questo componente, sarebbe utile specificare le schede della storia nel markup HTML semplice, come segue:
<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 questo all'elenco delle funzionalità.
Elenco delle funzionalità
- Accetta una serie di schede nel markup HTML.
In questo modo, chiunque può utilizzare il nostro componente della storia semplicemente scrivendo codice HTML. Questa soluzione è ideale sia per i programmatori sia per chi non lo è e funziona ovunque funzioni l'HTML: sistemi di gestione dei contenuti, framework e così via.
Prerequisiti
- Una shell in cui puoi eseguire
gitenpm - 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. Installa le dipendenze:
npm i
Per gli utenti di VS Code, installa l'estensione lit-plugin per ottenere il completamento automatico, il controllo dei tipi e il linting dei modelli lit-html.
Avvia l'ambiente di sviluppo eseguendo:
npm run dev
È tutto pronto per iniziare a scrivere codice.
3. Il componente <story-card>
Quando crei componenti composti, a volte è più facile iniziare con i sottocomponenti più semplici e poi procedere. Iniziamo quindi creando <story-card>. Dovrebbe essere in grado di visualizzare un video o un'immagine a tutta pagina. Gli utenti dovrebbero poterlo personalizzare ulteriormente, ad esempio con testo in overlay.
Il primo passo è definire la classe del componente, che estende LitElement. Il decoratore customElement si occupa della registrazione dell'elemento personalizzato. Ora è un buon momento per assicurarti di abilitare i decoratori in tsconfig con il flag experimentalDecorators (se utilizzi il repository di avvio, è 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 dell'istanza render. Qui forniremo il modello per l'elemento utilizzando il tag html di lit-html.
Cosa dovrebbe essere presente nel modello di questo componente? L'utente dovrebbe essere in grado di fornire due elementi: un elemento multimediale e un overlay. Aggiungeremo quindi uno <slot> per ciascuno di questi elementi.
Gli slot sono il modo in cui specifichiamo il rendering degli elementi secondari di un elemento personalizzato. Per ulteriori informazioni, ecco una fantastica guida 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>
`;
}
}
La separazione dell'elemento multimediale nel proprio slot ci aiuterà a indirizzare l'elemento per operazioni come l'aggiunta di uno stile 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 padding predefinito in un secondo momento.
Il componente <story-card> può ora essere utilizzato come segue:
<story-card>
<img slot="media" src="some/image.jpg" />
<h1>My Title</h1>
<p>my description</p>
</story-card>
Ma ha un aspetto terribile:

Aggiunta di uno stile
Aggiungiamo uno stile. Con lit-element, questa operazione viene eseguita definendo una proprietà statica styles e restituendo una stringa di modello taggata con css. Qualsiasi CSS scritto qui si applica solo al nostro elemento personalizzato. Il CSS con Shadow DOM è molto utile in questo modo.
Applichiamo uno stile all'elemento multimediale inserito nello slot in modo che copra il <story-card>. A questo punto, possiamo fornire una formattazione ottimale per gli elementi nel secondo slot. In questo modo, gli utenti dei componenti possono inserire <h1>s, <p>s o qualsiasi altro elemento e visualizzare qualcosa di piacevole 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;
}
`;
}

Ora abbiamo schede della storia con contenuti multimediali di sfondo e possiamo inserire qualsiasi elemento in primo piano. Bene. Torneremo alla classe StoryCard tra poco per implementare la riproduzione automatica dei video.
4. Il componente <story-viewer>
Il nostro elemento <story-viewer> è l'elemento principale di <story-card>s. Sarà responsabile della disposizione orizzontale delle schede e ci consentirà di scorrere tra di esse. Inizieremo come abbiamo fatto per StoryCard. Vogliamo aggiungere le schede della storia come elementi secondari dell'elemento <story-viewer>, quindi aggiungi uno slot 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>`;
}
}
Il passo successivo è un layout orizzontale. Possiamo farlo assegnando a tutte le <story-card> inserite nello slot un posizionamento assoluto e traducendole in base al loro indice. Possiamo indirizzare 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 dell'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 la decori con @property di LitElement, il componente verrà eseguito di nuovo 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 update verrà eseguito ogni volta che una proprietà osservata di questo componente cambia. In genere, eseguiamo una query per lo slot e scorriamo slot.assignedElements(). Tuttavia, poiché abbiamo un solo slot senza nome, è lo stesso che utilizzare this.children. Per praticità, utilizziamo this.children.
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);
}
}
Le nostre <story-card> sono ora tutte in fila. Funziona ancora con altri elementi come elementi secondari, a condizione che ci occupiamo di applicare loro uno stile appropriato:
<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 dal resto degli elementi story-card. Ora, facciamo in modo di poter navigare tra di essi.
5. Barra di avanzamento e navigazione
Aggiungeremo un modo per navigare tra le schede e una barra di avanzamento.
Aggiungiamo alcune funzioni helper a StoryViewer per navigare nella storia. Imposteranno l'indice per noi, limitandolo a un intervallo valido.
In story-viewer.ts, nella classe 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 esporre la navigazione all'utente finale, aggiungeremo i pulsanti "Precedente" e "Avanti" a <story-viewer>. Quando si fa 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 un listener di clic contemporaneamente.
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 listener di eventi in linea sui nostri nuovi pulsanti SVG, direttamente nel metodo render. Funziona per qualsiasi evento. Aggiungi un binding 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, utilizzeremo la griglia CSS per applicare uno stile a piccole caselle, una per ogni scheda della storia. Possiamo utilizzare la proprietà index per aggiungere in modo condizionale classi alle caselle per indicare se sono state "visualizzate" o meno. Potremmo utilizzare un'espressione condizionale come i <= this.index : 'watched': '', ma le cose potrebbero diventare verbose se aggiungiamo altre classi. Fortunatamente, lit-html fornisce una direttiva 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 di clic in modo che gli utenti possano passare direttamente a una scheda della 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;
}
La barra di navigazione e di avanzamento è completa. Ora aggiungiamo un po' 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 (come 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 i listener di eventi all'elemento host stesso. Il costruttore Hammer accetta un elemento su cui rilevare i gesti. Nel nostro caso, è lo stesso StoryViewer, ovvero this. Quindi, utilizzando l'API di Hammer, gli diciamo di rilevare il gesto "pan" e di 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 alla proprietà.
Ora 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 rendere le cose più fluide, 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:

7. Riproduzione automatica
L'ultima funzionalità che aggiungeremo è la riproduzione automatica dei video. Quando una scheda della storia entra in primo piano, vogliamo che il video di sfondo venga riprodotto, se esiste. Quando una scheda della storia esce dal focus, dobbiamo mettere in pausa il video.
Per implementare questa funzionalità, invieremo eventi personalizzati "entered" e "exited" agli elementi secondari appropriati ogni volta che l'indice cambia. In StoryCard, riceveremo questi eventi e riprodurremo o metteremo in pausa eventuali video esistenti. Perché scegliere di inviare eventi agli elementi secondari anziché chiamare i metodi dell'istanza "entered" e "exited" definiti in StoryCard? Con i metodi, gli utenti dei componenti 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 collegare un listener di eventi.
Eseguiamo il refactoring della proprietà index di StoryViewer per utilizzare un setter, che fornisce un percorso del codice pratico per l'invio degli 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 i listener di eventi per "entered" e "exited" nel costruttore StoryCard che riproducono e mettono in pausa il video.
Ricorda che l'utente del componente potrebbe o meno fornire un elemento video <story-card> nello slot multimediale. Potrebbe anche non fornire alcun elemento nello slot multimediale. Dobbiamo fare attenzione a non chiamare play su un'immagine o su un valore null.
Torna a story-card.ts e 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;
}
La riproduzione automatica è completa. ✅
8. Bilanciare la situazione
Ora che abbiamo tutte le funzionalità essenziali, aggiungiamone un'altra: un effetto di scalabilità ottimale. Torniamo ancora una volta al metodo update di StoryViewer. Vengono eseguiti alcuni calcoli per ottenere il valore nella costante scale. Sarà uguale a 1.0 per l'elemento secondario attivo e minScale in caso contrario, interpolando 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 trattato molti argomenti, tra cui alcune funzionalità di LitElement e lit-html, elementi slot HTML e controllo dei gesti.
Per una versione completa di questo componente, visita: https://github.com/PolymerLabs/story-viewer.