1. Einführung
Stories sind heutzutage eine beliebte UI-Komponente. Social-Media- und Nachrichten-Apps integrieren sie in ihre Feeds. In diesem Codelab erstellen wir eine Story-Komponente mit lit-element und TypeScript.
So sieht die Story-Komponente am Ende aus:

Eine „Story“ in sozialen Medien oder Nachrichten kann als Sammlung von Karten betrachtet werden, die sequenziell abgespielt werden, ähnlich einer Diashow. Stories sind im Grunde Diashows. Die Karten enthalten in der Regel ein Bild oder ein automatisch abgespieltes Video und können zusätzlich Text enthalten. Das werden wir erstellen:
Funktionsliste
- Karten mit einem Bild- oder Videohintergrund.
- Wische nach links oder rechts, um durch die Story zu navigieren.
- Videos werden automatisch wiedergegeben.
- Karten können mit Text versehen oder anderweitig angepasst werden.
Aus Entwicklersicht wäre es schön, wenn sich Story-Karten in einfachem HTML-Markup angeben ließen, etwa so:
<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>
Fügen wir das also auch der Liste der Funktionen hinzu.
Funktionsliste
- Eine Reihe von Karten im HTML-Markup akzeptieren.
So kann jeder unsere Story-Komponente verwenden, indem er einfach HTML schreibt. Das ist sowohl für Programmierer als auch für Nicht-Programmierer ideal und funktioniert überall dort, wo HTML verwendet wird, z. B. in Content-Management-Systemen und Frameworks.
Vorbereitung
- Eine Shell, in der Sie
gitundnpmausführen können - Ein Texteditor
2. Einrichten
Klonen Sie zuerst dieses Repository: story-viewer-starter
git clone git@github.com:PolymerLabs/story-viewer-starter.git
Die Umgebung ist bereits mit „lit-element“ und TypeScript eingerichtet. Installieren Sie einfach die Abhängigkeiten:
npm i
VS Code-Nutzer sollten die Erweiterung lit-plugin installieren, um die automatische Vervollständigung, die Typüberprüfung und das Linting von lit-html-Vorlagen zu nutzen.
Starten Sie die Entwicklungsumgebung mit folgendem Befehl:
npm run dev
Jetzt können Sie mit dem Programmieren beginnen.
3. Die Komponente <story-card>
Beim Erstellen zusammengesetzter Komponenten ist es manchmal einfacher, mit den einfacheren Unterkomponenten zu beginnen und sie dann zu erweitern. Beginnen wir also mit der Erstellung von <story-card>. Es sollte ein Video oder Bild ohne Rand anzeigen können. Nutzer sollten sie weiter anpassen können, z. B. mit Overlay-Text.
Zuerst definieren wir die Klasse unserer Komponente, die LitElement erweitert. Der customElement-Decorator registriert das benutzerdefinierte Element für uns. Jetzt ist ein guter Zeitpunkt, um sicherzustellen, dass Sie Dekoratoren in Ihrer tsconfig mit dem Flag experimentalDecorators aktivieren (wenn Sie das Starter-Repository verwenden, ist es bereits aktiviert).
Fügen Sie den folgenden Code in „story-card.ts“ ein:
import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('story-card')
export class StoryCard extends LitElement {
}
<story-card> ist jetzt ein benutzerdefiniertes Element, das verwendet werden kann, aber es gibt noch nichts, was angezeigt werden könnte. Um die interne Struktur des Elements zu definieren, definieren Sie die render-Instanzmethode. Hier stellen wir die Vorlage für das Element bereit und verwenden dazu das html-Tag von lit-html.
Was sollte in der Vorlage dieser Komponente enthalten sein? Der Nutzer sollte zwei Dinge angeben können: ein Media-Element und ein Overlay. Wir fügen also für jede eine <slot> hinzu.
Mit Slots wird angegeben, wie die untergeordneten Elemente eines benutzerdefinierten Elements gerendert werden sollen. Hier finden Sie eine Anleitung zur Verwendung von Slots.
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>
`;
}
}
Wenn Sie das Media-Element in einen eigenen Slot einfügen, können wir es besser für Funktionen wie das Hinzufügen von Full-Bleed-Stilen und die automatische Videowiedergabe optimieren. Platzieren Sie den zweiten Slot (für benutzerdefinierte Overlays) in einem Containerelement, damit wir später einen Standardabstand festlegen können.
Die <story-card>-Komponente kann jetzt so verwendet werden:
<story-card>
<img slot="media" src="some/image.jpg" />
<h1>My Title</h1>
<p>my description</p>
</story-card>
Aber es sieht furchtbar aus:

Stil hinzufügen
Fügen wir nun etwas Stil hinzu. Mit lit-element definieren wir dazu eine statische styles-Eigenschaft und geben einen mit css getaggten Vorlagenstring zurück. Das hier geschriebene CSS gilt nur für unser benutzerdefiniertes Element. CSS mit Shadow-DOM ist in dieser Hinsicht wirklich praktisch.
Wir weisen dem Media-Element im Slot einen Stil zu, damit es <story-card> abdeckt. Wir können die Elemente im zweiten Slot auch gleich formatieren. So können Komponentenbenutzer einige <h1>s, <p>s oder was auch immer einfügen und standardmäßig etwas Schönes sehen.
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;
}
`;
}

Jetzt haben wir Story-Karten mit Hintergrundmedien und können alles darauf platzieren, was wir möchten. Sehr gut! Wir kehren gleich zur Klasse StoryCard zurück, um das automatische Abspielen von Videos zu implementieren.
4. Die Komponente <story-viewer>
Unser <story-viewer>-Element ist das übergeordnete Element von <story-card>-Elementen. Sie ist dafür verantwortlich, die Karten horizontal anzuordnen und zwischen ihnen zu wischen. Wir beginnen genauso wie bei StoryCard. Wir möchten Storykarten als untergeordnete Elemente des <story-viewer>-Elements hinzufügen. Fügen Sie also einen Slot für diese untergeordneten Elemente hinzu.
Fügen Sie den folgenden Code in „story-viewer.ts“ ein:
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('story-viewer')
export class StoryViewer extends LitElement {
render() {
return html`<slot></slot>`;
}
}
Als Nächstes haben wir ein horizontales Layout. Wir können das Problem lösen, indem wir allen <story-card>-Elementen eine absolute Positionierung zuweisen und sie entsprechend ihrem Index verschieben. Mit dem Selektor :host können wir das <story-viewer>-Element selbst als Ziel festlegen.
static styles = css`
:host {
display: block;
position: relative;
/* Default size */
width: 300px;
height: 800px;
}
::slotted(*) {
position: absolute;
width: 100%;
height: 100%;
}`;
Der Nutzer kann die Größe unserer Story-Karten steuern, indem er die Standardhöhe und ‑breite auf dem Host extern überschreibt. Ein Beispiel:
story-viewer {
width: 400px;
max-width: 100%;
height: 80%;
}
Um die aktuell angezeigte Karte im Blick zu behalten, fügen wir der Klasse StoryViewer eine Instanzvariable index hinzu. Wenn Sie sie mit @property von LitElement dekorieren, wird die Komponente neu gerendert, wenn sich ihr Wert ändert.
import { property } from 'lit/decorators.js';
export class StoryViewer extends LitElement {
@property({type: Number}) index: number = 0;
}
Jede Karte muss horizontal in Position gebracht werden. Wenden wir diese Übersetzungen in der update-Lebenszyklusmethode von LitElement an. Die Update-Methode wird immer dann ausgeführt, wenn sich eine beobachtete Eigenschaft dieser Komponente ändert. Normalerweise würden wir den Slot abfragen und slot.assignedElements() durchlaufen. Da wir jedoch nur einen unbenannten Slot haben, ist das dasselbe wie die Verwendung von this.children. Der Einfachheit halber verwenden wir 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);
}
}
Unsere <story-card>s sind jetzt alle in einer Reihe. Es funktioniert weiterhin mit anderen Elementen als untergeordnete Elemente, solange wir darauf achten, sie entsprechend zu gestalten:
<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>
Rufen Sie build/index.html auf und entfernen Sie die Auskommentierung für die restlichen Story-Karten-Elemente. Jetzt müssen wir dafür sorgen, dass wir zu ihnen navigieren können.
5. Fortschrittsanzeige und Navigation
Als Nächstes fügen wir eine Möglichkeit zum Navigieren zwischen den Karten und eine Fortschrittsanzeige hinzu.
Fügen wir StoryViewer einige Hilfsfunktionen hinzu, um die Navigation in der Geschichte zu erleichtern. Sie legen den Index für uns fest und beschränken ihn gleichzeitig auf einen gültigen Bereich.
Fügen Sie in „story-viewer.ts“ in der Klasse StoryViewer Folgendes hinzu:
/** 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));
}
Damit Endnutzer die Navigation nutzen können, fügen wir dem <story-viewer> die Schaltflächen „Zurück“ und „Weiter“ hinzu. Wenn auf eine der beiden Schaltflächen geklickt wird, soll entweder die Hilfsfunktion next oder previous aufgerufen werden. Mit lit-html ist es ganz einfach, Elementen Event-Listener hinzuzufügen. Wir können die Schaltflächen rendern und gleichzeitig einen Klick-Listener hinzufügen.
Aktualisieren Sie die Methode render so:
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>
`;
}
}
Sehen Sie sich an, wie wir Inline-Event-Listener für unsere neuen SVG-Schaltflächen direkt in der render-Methode hinzufügen können. Das funktioniert für jedes Ereignis. Fügen Sie einfach eine Bindung des Formulars @eventname=${handler} zu einem Element hinzu.
Fügen Sie dem Attribut static styles Folgendes hinzu, um die Schaltflächen zu gestalten:
svg {
position: absolute;
top: calc(50% - 25px);
height: 50px;
cursor: pointer;
}
#next {
right: 0;
}
Für den Fortschrittsbalken verwenden wir das CSS-Grid, um kleine Kästchen zu gestalten, eines für jede Story-Karte. Mit der Eigenschaft index können wir den Feldern bedingt Klassen hinzufügen, um anzugeben, ob sie „gesehen“ wurden oder nicht. Wir könnten einen bedingten Ausdruck wie i <= this.index : 'watched': '' verwenden, aber das würde sehr ausführlich werden, wenn wir weitere Klassen hinzufügen. Zum Glück bietet lit-html eine Direktive namens classMap, die dabei helfen kann. Importieren Sie zuerst classMap:
import { classMap } from 'lit/directives/class-map';
Fügen Sie am Ende der render-Methode das folgende Markup hinzu:
<div id="progress">
${Array.from(this.children).map((_, i) => html`
<div
class=${classMap({watched: i <= this.index})}
@click=${() => this.index = i}
></div>`
)}
</div>
Außerdem haben wir einige weitere Click-Handler hinzugefügt, damit Nutzer bei Bedarf direkt zu einer bestimmten Story-Karte springen können.
Das sind die neuen Styles, die Sie static styles hinzufügen müssen:
::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;
}
Navigation und Fortschrittsanzeige sind fertig. Jetzt können wir das Ganze noch etwas aufpeppen.
6. Wischen
Für die Implementierung von Wischbewegungen verwenden wir die Hammer.js-Bibliothek zur Gestensteuerung. Hammer erkennt spezielle Touchgesten wie Schwenks und sendet Ereignisse mit relevanten Informationen (z. B. Delta X), die wir nutzen können.
So können wir mit Hammer Schwenks erkennen und unser Element automatisch aktualisieren, wenn ein Schwenkereignis eintritt:
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);
}
}
Der Konstruktor einer LitElement-Klasse ist ein weiterer guter Ort, um Event-Listener an das Host-Element anzuhängen. Der Hammer-Konstruktor verwendet ein Element, auf dem Gesten erkannt werden sollen. In unserem Fall ist das StoryViewer oder this. Dann weisen wir Hammer über die API an, die Geste „Schwenken“ zu erkennen und die Schwenkinformationen in einer neuen _panData-Property festzulegen.
Wenn Sie die _panData-Property mit @state dekorieren, beobachtet LitElement Änderungen an _panData und führt ein Update durch. Es gibt jedoch kein zugehöriges HTML-Attribut für die Property.
Als Nächstes erweitern wir die update-Logik, um die Schwenkdaten zu verwenden:
// 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);
}
Wir können unsere Story-Karten jetzt hin- und herbewegen. Damit alles reibungslos funktioniert, kehren wir zu static get styles zurück und fügen dem Selektor ::slotted(*) Folgendes hinzu: transition: transform 0.35s ease-out;:
::slotted(*) {
...
transition: transform 0.35s ease-out;
}
Jetzt haben wir ein flüssiges Wischen:

7. Autoplay
Die letzte Funktion, die wir hinzufügen, ist die automatische Videowiedergabe. Wenn eine Story-Karte in den Fokus rückt, soll das Hintergrundvideo abgespielt werden, sofern es vorhanden ist. Wenn eine Story-Karte nicht mehr im Fokus ist, sollte das Video pausiert werden.
Dazu werden benutzerdefinierte „entered“- und „exited“-Ereignisse für die entsprechenden untergeordneten Elemente gesendet, wenn sich der Index ändert. In StoryCard werden diese Ereignisse empfangen und alle vorhandenen Videos werden abgespielt oder pausiert. Warum werden Ereignisse für die untergeordneten Elemente ausgelöst, anstatt die Instanzmethoden „entered“ und „exited“ aufzurufen, die für StoryCard definiert sind? Bei Methoden hätten die Nutzer der Komponente keine andere Wahl, als ein benutzerdefiniertes Element zu schreiben, wenn sie eine eigene Story-Karte mit benutzerdefinierten Animationen erstellen möchten. Bei Ereignissen können sie einfach einen Event-Listener anhängen.
Wir refaktorieren die index-Eigenschaft von StoryViewer, um einen Setter zu verwenden, der einen praktischen Codepfad zum Senden der Ereignisse bietet:
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;
}
}
Um die Funktion für die automatische Wiedergabe zu vervollständigen, fügen wir im StoryCard-Konstruktor Ereignis-Listener für „entered“ und „exited“ hinzu, die das Video abspielen und pausieren.
Der Nutzer der Komponente kann dem <story-card> ein Videoelement im Media-Slot zur Verfügung stellen. Möglicherweise wird im Media-Slot überhaupt kein Element bereitgestellt. Wir müssen darauf achten, play nicht für ein img-Element oder für „null“ aufzurufen.
Fügen Sie in „story-card.ts“ Folgendes hinzu:
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;
}
Automatische Wiedergabe abgeschlossen. ✅
8. Das Zünglein an der Waage
Jetzt, da wir alle wichtigen Funktionen haben, fügen wir noch einen tollen Skalierungseffekt hinzu. Kehren wir noch einmal zur update-Methode von StoryViewer zurück. Der Wert in der Konstanten scale wird durch Berechnungen ermittelt. Er entspricht 1.0 für das aktive untergeordnete Element und minScale für alle anderen Elemente. Außerdem wird zwischen diesen beiden Werten interpoliert.
Ändern Sie die Schleife in der Methode update in story-viewer.ts in:
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})`;
});
// ...
}
Fertig! In diesem Beitrag haben wir viel behandelt, darunter einige LitElement- und lit-html-Funktionen, HTML-Slot-Elemente und die Gestensteuerung.
Eine vollständige Version dieser Komponente finden Sie unter https://github.com/PolymerLabs/story-viewer.