Story-Komponente mit Lichtelement erstellen

1. Einführung

Stories sind heutzutage eine beliebte UI-Komponente. Social-Media- und Nachrichten-Apps binden sie in ihre Feeds ein. In diesem Codelab erstellen wir eine Story-Komponente mit lit-element und TypeScript.

So sieht die Story-Komponente am Ende aus:

Eine fertige Story-Viewer-Komponente mit drei Bildern von Kaffee

Eine „Story“ in sozialen Medien oder Nachrichten kann als Sammlung von Karten betrachtet werden, die sequenziell abgespielt werden, ähnlich wie eine Diashow. Stories sind im Grunde Diashows. Die Karten enthalten in der Regel ein Bild oder ein automatisch abgespieltes Video und können zusätzlichen 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 abgespielt.
  • Karten können mit Text versehen oder anderweitig angepasst werden.

Aus Entwicklersicht wäre es schön, wenn sich Storykarten 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 git und npm ausführen können
  • 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 Autovervollständigung, Typüberprüfung und Linting von lit-html-Vorlagen zu erhalten.

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 darauf aufzubauen. 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.

Als Erstes definieren wir die Klasse unserer Komponente, die von LitElement abgeleitet wird. 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 {
}

Jetzt ist <story-card> ein nutzbares benutzerdefiniertes Element, aber es gibt noch nichts anzuzeigen. 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 untergeordnete 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 Dinge wie das Hinzufügen von Full-Bleed-Styling und die automatische Videowiedergabe ausrichten. 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:

Eine nicht formatierte Story-Ansicht mit einem Bild von Kaffee

Stil hinzufügen

Fügen wir 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 den <story-card>-Container abdeckt. Wir können die Elemente im zweiten Slot auch gleich formatieren. So können Nutzer der Komponente einige <h1>s, <p>s oder Ähnliches 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;
    }
  `;
}

Ein stilisiertes Story-Viewer-Element mit einem Bild von Kaffee

Jetzt haben wir Story-Karten mit Hintergrundmedien und können alles darauf platzieren, was wir möchten. Sehr gut! Wir kommen gleich noch einmal auf die 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 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 geben und sie entsprechend ihrem Index verschieben. Mit dem Selektor :host können wir das <story-viewer>-Element selbst ausrichten.

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. Wir verwenden der Einfachheit halber 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 der 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 der Endnutzer die Navigation nutzen kann, 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 Click-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 Event-Listener inline in unsere neuen SVG-Schaltflächen einfügen können, direkt in der render-Methode. Das funktioniert für jedes Ereignis. Fügen Sie einfach eine Bindung der Form @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 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 bei weiteren Klassen sehr umständlich werden. Zum Glück gibt es in 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 Stile, die Sie static styles hinzufügen können:

::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;
}

Navigations- und Fortschrittsanzeige sind fertig. Jetzt können Sie Ihrem Video noch etwas mehr Flair verleihen.

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, für das Gesten erkannt werden sollen. In unserem Fall ist das StoryViewer oder this. Anschließend wird Hammer über die API angewiesen, die Touchgeste „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 eine Aktualisierung 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:

Flüssiges Wischen zwischen Story-Karten

7. Autoplay

Als Letztes fügen wir die automatische Videowiedergabe hinzu. Wenn eine Story-Karte in den Fokus rückt, soll das Hintergrundvideo (falls vorhanden) abgespielt werden. 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 wiedergegeben 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 wollten. 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 oder auch nicht. 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 sein

Jetzt, da wir alle wichtigen Funktionen haben, fügen wir noch einen tollen Skalierungseffekt hinzu. Kommen wir noch einmal auf die update-Methode von StoryViewer zurück. Der Wert in der scale-Konstante wird durch Berechnungen ermittelt. Er entspricht 1.0 für das aktive untergeordnete Element und minScale für alle anderen Elemente. Auch hier wird zwischen diesen beiden Werten interpoliert.

Ändern Sie die Schleife in der Methode update in „story-viewer.ts“ so:

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.