Story-Komponente mit Lichtelement erstellen

1. Einführung

Stories sind heutzutage eine beliebte UI-Komponente. Sie werden in den Feeds von Social-Media- und Nachrichten-Apps eingebunden. In diesem Codelab erstellen wir eine Story-Komponente mit Lichtelementen und TypeScript.

So sieht die Story-Komponente am Ende aus:

Eine fertige Story-Viewer-Komponente, die drei Kaffeebilder zeigt

Wir können uns eine „Story“ in den sozialen Medien oder in den Nachrichten als eine Sammlung von Karten vorstellen, die nacheinander abgespielt werden, ähnlich wie eine Diashow. Geschichten sind im wahrsten Sinne des Wortes Diashows. Die Karten werden in der Regel von einem Bild oder einem automatisch abgespielten Video dominiert und können zusätzlichen Text enthalten. Folgendes wird erstellt:

Liste der Funktionen

  • Karten mit einem Bild- oder Videohintergrund
  • Wische nach links oder rechts, um in der Story zu navigieren.
  • Die automatische Wiedergabe von Videos
  • Möglichkeit, Karten Text hinzuzufügen oder anderweitig anzupassen.

Was die Entwicklererfahrung dieser Komponente angeht, wäre es schön, Story-Karten in einfachem HTML-Markup wie im folgenden Beispiel zu spezifizieren:

<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>

Also fügen wir das auch der Liste der Funktionen hinzu.

Funktionsliste

  • Akzeptiert eine Reihe von Karten in HTML-Markup.

So kann jeder unsere Story-Komponente verwenden, indem er einfach HTML-Code schreibt. Diese Methode eignet sich sowohl für Programmierer als auch für Nicht-Programmierer und funktioniert überall, wo HTML funktioniert: in Content-Management-Systemen, Frameworks usw.

Vorbereitung

  • Eine Shell, in der Sie git und npm ausfü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 wurde bereits mit Litigation-Element und TypeScript eingerichtet. Installieren Sie einfach die Abhängigkeiten:

npm i

Installieren Sie für VS Code-Nutzer die Erweiterung lit-plugin, um die automatische Vervollständigung, Typprüfung und Linting von Lit-HTML-Vorlagen zu nutzen.

Starten Sie die Entwicklungsumgebung, indem Sie folgenden Befehl ausführen:

npm run dev

Sie können jetzt mit dem Codieren beginnen.

3. Die Komponente <story-card>

Beim Erstellen zusammengesetzter Komponenten ist es manchmal einfacher, mit den einfacheren Unterkomponenten zu beginnen und dann darauf aufzubauen. Beginnen wir also mit <story-card>. Es sollte ein Video oder ein Bild im Vollformat anzeigen können. Der Text sollte vom Nutzer weiter angepasst werden können, zum Beispiel durch Overlay-Text.

Im ersten Schritt definieren wir die Klasse unserer Komponente, die von LitElement abgeleitet ist. Der customElement-Dekorator kümmert sich um die Registrierung des benutzerdefinierten Elements. Jetzt sollten Sie prüfen, ob Sie mit dem Flag experimentalDecorators die Decorators in Ihrer tsconfig-Datei aktivieren. Wenn Sie das Start-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 brauchbares benutzerdefiniertes Element. Es gibt jedoch noch nichts zum Anzeigen. Um die interne Struktur des Elements zu definieren, definieren Sie die Instanzmethode render. Hier geben wir die Vorlage für das Element mit dem html-Tag von lit-html an.

Was sollte in der Vorlage dieser Komponente enthalten sein? Der Nutzer sollte zwei Dinge angeben können: ein Medienelement und ein Overlay. Also fügen wir jeweils eine <slot> hinzu.

Mithilfe von Slots legen wir fest, dass untergeordnete Elemente eines benutzerdefinierten Elements gerendert werden sollen. Weitere Informationen findest du in dieser 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 Medienelement in eine eigene Fläche unterteilen, können wir dieses Element beispielsweise für das Hinzufügen von Stilen in voller Seitenbreite oder für die automatische Wiedergabe von Videos ausrichten. Platziere den zweiten Slot (den für benutzerdefinierte Overlays) in einem Containerelement, damit wir später ein Standardabstand festlegen können.

Die Komponente <story-card> 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 unkonventionelle Zuschauerin, die ein Bild von Kaffee zeigt

Stil wird hinzugefügt

Fügen wir etwas Stil hinzu. Bei lit-Elementen definieren wir dazu ein statisches styles-Attribut und geben einen mit css getaggten Vorlagenstring zurück. Der hier geschriebene CSS-Code gilt nur für unser benutzerdefiniertes Element. CSS mit Shadow DOM ist auf diese Weise sehr gut.

Passen wir den Stil des Medienelements mit Steckplatz so an, dass es die <story-card> abdeckt. Während wir hier sind, können wir Ihnen eine schöne Formatierung für die Elemente in der zweiten Anzeigenfläche bieten. So können Nutzer der Komponente 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;
    }
  `;
}

Eine Person im Stil eines Artikels, die ein Bild von Kaffee zeigt

Jetzt haben wir Story-Karten mit Hintergrundmedien, auf denen wir beliebige Inhalte platzieren können. Sehr gut! Wir kehren gleich zur Klasse StoryCard zurück, um automatisch abgespielte Videos zu implementieren.

4. Die Komponente <story-viewer>

Unser <story-viewer>-Element ist dem <story-card>-Element übergeordnet. Sie ist für das horizontale Anlegen der Karten und für das Wischen zwischen ihnen verantwortlich. 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üge 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 sehen wir uns ein horizontales Layout an. Wir können dies erreichen, indem wir allen <story-card>s mit Steckplatz eine absolute Position zuweisen und sie entsprechend ihrem Index verschieben. Mit der :host-Auswahl können wir das <story-viewer>-Element selbst als Targeting-Element verwenden.

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 Storykarten steuern, indem er die Standardhöhe und -breite auf dem Host extern überschreibt. Ein Beispiel:

story-viewer {
  width: 400px;
  max-width: 100%;
  height: 80%;
}

Fügen Sie der Klasse StoryViewer die Instanzvariable index hinzu, um die aktuell angezeigte Karte im Blick zu behalten. Wenn du sie mit dem @property von LitElement dekorierst, wird die Komponente jedes Mal 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 Lebenszyklusmethode update des Lit-Elements an. Die Aktualisierungsmethode wird immer dann ausgeführt, wenn sich eine beobachtete Eigenschaft dieser Komponente ändert. Normalerweise würden wir nach dem Slot fragen und eine Schleife über slot.assignedElements() ausführen. Da wir jedoch nur einen unbenannten Slot haben, entspricht dies der Verwendung von this.children. Nehmen wir zur Vereinfachung 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 liegen jetzt alle in einer Reihe. Es funktioniert auch mit anderen Elementen als untergeordneten Elementen, solange wir sie entsprechend stylen:

<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 Kommentarzeichen für die restlichen Elemente der Storykarte. Jetzt machen wir es so, dass wir zu ihnen navigieren können!

5. Fortschrittsanzeige und Navigation

Als Nächstes fügen wir eine Möglichkeit zur Navigation zwischen den Karten und eine Fortschrittsanzeige hinzu.

Als Nächstes fügen wir StoryViewer einige Hilfsfunktionen zum Navigieren in der Story hinzu. Der Index wird für uns festgelegt, während er an einen gültigen Bereich gebunden wird.

Fügen Sie in der Klasse „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 sehen kann, fügen wir der <story-viewer> die Schaltflächen „Zurück“ und „Weiter“ hinzu. Wenn auf eine der Schaltflächen geklickt wird, soll entweder die next- oder die previous-Hilfsfunktion aufgerufen werden. Mit lit-html können Elemente ganz einfach mit Ereignis-Listenern versehen werden. Wir können die Schaltflächen also gleichzeitig rendern und 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 wir uns an, wie wir Ereignis-Listener inline in unseren neuen SVG-Schaltflächen direkt in der render-Methode hinzufügen können. Das funktioniert für alle Ereignisse. Fügen Sie einem Element einfach eine Bindung im Format @eventname=${handler} hinzu.

Fügen Sie der Property static styles Folgendes hinzu, um die Schaltflächen zu formatieren:

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

Für die Fortschrittsanzeige verwenden wir das CSS-Raster, um kleine Felder zu stylen, eines für jede Storycard. Mit der Property 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 schnell unübersichtlich werden, wenn wir weitere Klassen hinzufügen. Glücklicherweise bietet lit-html eine Anweisung namens classMap an, um dir zu helfen. Importieren Sie zuerst classMap:

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

Fügen Sie am Ende der Methode render 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 Klick-Handler hinzugefügt, damit Nutzer bei Bedarf direkt zu einer bestimmten Storykarte springen können.

Dies sind die neuen Stile, die zu static styles hinzugefügt werden 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;
}

Navigation und Fortschrittsanzeige sind fertig. Jetzt können wir etwas Flair hinzufügen.

6. Wischen

Wischbewegungen lassen sich mit der Hammer.js-Bibliothek für die Gestensteuerung implementieren. Hammer erkennt spezielle Touch-Gesten wie Schwenks und sendet Ereignisse mit relevanten Informationen (z. B. Delta X), die wir verarbeiten können.

So können wir Pfannen mit Hammer erkennen und unser Element automatisch aktualisieren, wenn ein Schwenk-Ereignis auftritt:

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 eine weitere gute Stelle, um Ereignis-Listener am Hostelement selbst anzuhängen. Der Hammer-Konstruktor verwendet ein Element, für das Touch-Gesten erkannt werden. In unserem Fall ist es das StoryViewer selbst oder this. Anschließend weisen wir ihn über die API von Hammer an, die „Schwenken“-Geste zu erkennen und die Informationen zum Schwenken auf eine neue _panData-Eigenschaft zu setzen.

Wenn die _panData-Eigenschaft mit @state dekoriert wird, beobachtet LitElement Änderungen an _panData und führt eine Aktualisierung durch. Es gibt jedoch kein verknüpftes HTML-Attribut für die Eigenschaft.

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 Storykarten jetzt hin und her ziehen. Kehren wir zu static get styles zurück und fügen wir der Auswahl ::slotted(*) das Element transition: transform 0.35s ease-out; hinzu:

::slotted(*) {
  ...
  transition: transform 0.35s ease-out;
}

Jetzt haben wir das flüssige Wischen:

Durch Wischen zwischen Story-Karten wechseln

7. Autoplay

Die letzte Funktion, die wir hinzufügen, ist die automatische Wiedergabe von Videos. Wenn eine Storycard in den Fokus rückt, soll das Hintergrundvideo abgespielt werden, falls vorhanden. Wenn die Storycard nicht mehr im Fokus ist, sollte das Video pausiert werden.

Zur Implementierung werden die benutzerdefinierten Ereignisse "entered" und "exited" für die entsprechenden untergeordneten Elemente ausgelöst, sobald sich der Index ändert. In StoryCard erhalten wir diese Ereignisse und spielen alle vorhandenen Videos ab oder pausieren sie. Warum werden Ereignisse an die untergeordneten Elemente gesendet, anstatt die auf der StoryCard definierten Instanzmethoden „entered“ und „exited“ aufzurufen? Mit Methoden blieben die Nutzenden der Komponente keine andere Wahl, als ein benutzerdefiniertes Element zu schreiben, wenn sie eine eigene Storycard mit benutzerdefinierten Animationen schreiben wollten. Mit -Ereignissen können sie einfach einen Event-Listener anhängen.

Lassen Sie uns das Attribut index von StoryViewer so refaktorieren, dass ein Setter verwendet wird, der einen praktischen Codepfad zum Auslösen 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 Autoplay-Funktion abzuschließen, fügen wir im StoryCard-Konstruktor Ereignis-Listener für "entered" und "exited" hinzu, die das Video wiedergeben und pausieren.

Der Komponentennutzer kann dem <story-card> auf der Media-Fläche ein Videoelement zuweisen. Möglicherweise wird im Medien-Slot gar kein Element bereitgestellt. Wir müssen darauf achten, play nicht für ein Bild oder auf null aufzurufen.

Fügen Sie in der Datei „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. Die Waage schwingen

Jetzt haben wir alle wichtigen Funktionen. Fügen wir noch einen hinzu: einen coolen Skalierungseffekt. Sehen wir uns noch einmal die update-Methode von StoryViewer an. Der Wert der scale-Konstante wird berechnet, um den Wert zu erhalten. Der Wert entspricht 1.0 für das aktive untergeordnete Element und andernfalls minScale und wird auch zwischen diesen beiden Werten interpoliert.

Ändere die Schleife in der update-Methode in story-viewer.ts in Folgendes:

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 viele Themen behandelt, darunter einige LitElement- und lit-html-Funktionen, HTML-Steckplatzelemente und die Gestensteuerung.

Die vollständige Version dieser Komponente finden Sie unter https://github.com/PolymerLabs/story-viewer.