1. Introduzione
Le storie sono un componente UI molto diffuso al giorno d'oggi. Le app social e di 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 alla fine:

Possiamo pensare a una "storia" di social media o notizie come a una raccolta di schede da riprodurre in sequenza, un po' come una presentazione. In realtà, le storie sono letteralmente delle presentazioni. Le schede sono in genere dominate da un'immagine o da un video in 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 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 bello specificare le schede delle storie nel markup HTML semplice, in questo modo:
<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>
Aggiungiamolo anche 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. È ideale sia per i programmatori che per chi non lo è e funziona ovunque sia supportato 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 solo 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
Ora puoi iniziare a programmare.
3. Il componente <story-card>
Quando crei componenti composti, a volte è più facile iniziare con i sottocomponenti più semplici e procedere gradualmente. Iniziamo quindi a creare <story-card>. Deve essere in grado di visualizzare un video o un'immagine a pagina intera. Gli utenti dovrebbero essere in grado di personalizzarlo ulteriormente, ad esempio con un testo in overlay.
Il primo passaggio consiste nel 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 attivare i decoratori in tsconfig con il flag experimentalDecorators (se utilizzi il repository iniziale, sono già attivi).
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.
Cosa deve contenere il 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.
Gli slot sono il modo in cui specifichiamo il rendering degli elementi secondari di un elemento personalizzato. Per ulteriori informazioni, consulta questa 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 nella propria area ci aiuterà a indirizzare questo elemento per operazioni come l'aggiunta di uno stile a tutta pagina e la riproduzione automatica dei video. Inserisci il secondo slot (quello per le sovrapposizioni personalizzate) all'interno di un elemento contenitore in modo da poter fornire un padding predefinito 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 ha un aspetto orribile:

Aggiunta dello stile in corso…
Aggiungiamo un po' di stile. Con lit-element, lo facciamo 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 è davvero utile in questo modo.
Applichiamo uno stile all'elemento multimediale con slot per coprire <story-card>. A questo punto, possiamo fornire una formattazione ottimale per gli elementi nel secondo spazio. In questo modo, gli utenti dei componenti possono inserire alcuni <h1>, <p> o altro e visualizzare 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;
}
`;
}

Ora abbiamo schede della storia con contenuti multimediali di sfondo e possiamo mettere quello che vogliamo in primo piano. Bene! Torneremo alla classe StoryCard tra un po' per implementare la riproduzione automatica dei video.
4. Il componente <story-viewer>
Il nostro elemento <story-viewer> è l'elemento principale degli elementi <story-card>. Sarà responsabile della disposizione orizzontale delle schede e dello scorrimento 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 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>`;
}
}
Il layout orizzontale è il successivo. Possiamo risolvere il problema assegnando a tutti gli <story-card> inseriti un 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 nostre schede di 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à 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 carta deve essere traslata orizzontalmente nella posizione corretta. Applichiamo queste traduzioni nel metodo del ciclo di vita update di lit-element. Il metodo di aggiornamento viene eseguito ogni volta che cambia una proprietà osservata di questo componente. In genere, eseguiamo una query per lo slot e scorriamo slot.assignedElements(). Tuttavia, poiché abbiamo un solo slot senza nome, questa operazione equivale a utilizzare this.children. Per comodità, 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);
}
}
I nostri <story-card> sono ora tutti in fila. Funziona comunque con altri elementi come elementi secondari, a condizione che ci occupiamo di applicare lo 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 dagli altri elementi della scheda della storia. Ora, facciamo in modo di poter navigare fino a loro.
5. Barra di avanzamento e navigazione
Successivamente, aggiungeremo un modo per spostarsi tra le schede e una barra di avanzamento.
Aggiungiamo alcune funzioni di assistenza 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 mostrare la navigazione all'utente finale, aggiungeremo i pulsanti "Precedente" e "Avanti" a <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 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. Questa operazione funziona per qualsiasi evento. Ti basta aggiungere un binding del modulo @eventname=${handler} a un elemento.
Aggiungi quanto segue alla proprietà static styles per applicare lo 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 lo 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 prolisse se aggiungiamo altre classi. Fortunatamente, lit-html fornisce una direttiva chiamata classMap per aiutarti. Innanzitutto, importa classMap:
import { classMap } from 'lit/directives/class-map';
e 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 di clic in modo che gli utenti possano passare direttamente a una scheda di storia specifica, se vogliono.
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 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 l'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, si tratta di StoryViewer o this. Quindi, utilizzando l'API di Hammer, gli diciamo di rilevare il gesto di panoramica e di impostare le informazioni di 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à.
A questo punto, aumentiamo la logica update per utilizzare i dati di 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 lo scorrimento è fluido:

7. Riproduzione automatica
L'ultima funzionalità che aggiungeremo è la riproduzione automatica dei video. Quando una scheda della storia viene messa in primo piano, vogliamo che il video in background venga riprodotto, se esiste. Quando una scheda della storia perde lo stato attivo, il video deve essere messo in pausa.
Implementeremo questa funzionalità inviando eventi personalizzati "entered" (inserito) e "exited" (uscito) sui figli 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 delle istanze "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.
Refactorizziamo la proprietà index di StoryViewer per utilizzare un setter, che fornisce un percorso del codice conveniente 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 "entered" e "exited" nel costruttore StoryCard che riproducono e mettono in pausa il video.
Ricorda che l'utente del componente potrebbe o meno inserire un elemento video nello spazio multimediale.<story-card> Potrebbero anche non fornire alcun elemento nello spazio multimediale. Dobbiamo fare attenzione a non chiamare play su un'immagine o su un valore nullo.
In 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. Ribaltare la situazione
Ora che abbiamo tutte le funzionalità essenziali, aggiungiamone un'altra: un bell'effetto di ridimensionamento. Torniamo ancora una volta al metodo update di StoryViewer. Vengono eseguiti alcuni calcoli per ottenere il valore della costante scale. Sarà uguale a 1.0 per il bambino attivo e a minScale altrimenti, interpolando anche tra questi due valori.
Modifica il ciclo 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 la pagina https://github.com/PolymerLabs/story-viewer.