Story-Komponente mit Lichtelement erstellen

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 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 aus den sozialen Medien oder als Sammlung von Kacheln, die nacheinander abgespielt werden, ähnlich wie bei einer Bildschirmpräsentation. Geschichten sind im wahrsten Sinne des Wortes Diashows. Die Infokarten dominieren in der Regel ein Bild oder ein automatisch wiedergegebenes Video und können über dem Bild 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.
  • Automatische Wiedergabe von Videos.
  • Möglichkeit, Text hinzuzufügen oder Karten anderweitig anzupassen.

Was die Entwicklererfahrung dieser Komponente angeht, wäre es schön, Story-Karten als einfaches HTML-Markup anzugeben, wie in diesem Beispiel:

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

Liste der Funktionen

  • Akzeptiert eine Reihe von Karten in HTML-Markup.

So kann jeder unsere Story-Komponente nutzen, indem er einfach HTML 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 Programmieren beginnen.

3. Die <story-card> Komponente

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

Der erste Schritt besteht darin, die Klasse der Komponente zu definieren, die LitElement erweitert. Der customElement-Decorator übernimmt die Registrierung des benutzerdefinierten Elements für uns. Jetzt ist ein guter Zeitpunkt, um sicherzustellen, dass Sie die Decorators in Ihrer tsconfig-Datei mit dem Flag experimentalDecorators 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, aber es gibt noch nichts zum Anzeigen. Um die interne Struktur des Elements zu definieren, definieren Sie die Instanzmethode render. Hier stellen wir die Vorlage für das Element mithilfe des html-Tags von Lit-html bereit.

Was sollte in der Vorlage dieser Komponente enthalten sein? Der Nutzer sollte zwei Dinge zur Verfügung stellen 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 Schritt-für-Schritt-Anleitung zur Verwendung von Zeitblöcken.

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. Platzieren Sie die zweite Anzeigenfläche (die für benutzerdefinierte Overlays) in einem Containerelement, damit wir später ein Standard-Padding 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. Mit dem Lit-Element definieren wir 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 schön.

Wir passen das Slotted-Mediaelement so an, dass es <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. Auf diese Weise können Nutzer von Komponenten 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;
    }
  `;
}

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 später zur StoryCard-Klasse zurück, um automatisch wiedergegebene Videos zu implementieren.

4. Der <story-viewer> Komponente

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. Der Anfang ist genauso wie bei StoryCard. Wir möchten Story-Karten als untergeordnete Elemente des <story-viewer>-Elements hinzufügen, also fügen Sie 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 folgt das horizontale Layout. In diesem Fall können wir allen <story-card>s mit Slotting eine absolute Positionierung zuweisen und sie entsprechend ihrem Index übersetzen. Mit dem :host-Selektor können wir ein Targeting auf das <story-viewer>-Element selbst vornehmen.

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

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 die richtige Position verschoben 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 den Slot abfragen und eine Schleife über slot.assignedElements() durchführen. Da wir jedoch nur einen unbenannten Slot haben, ist dies das Gleiche wie bei der Verwendung von this.children. Lassen Sie uns der Einfachheit halber this.children verwenden.

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

Wir fügen StoryViewer jetzt 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));
}

Um die Navigation für den Endnutzer sichtbar zu machen, fügen wir „Vorherige“ und „Weiter“ zu <story-viewer> hinzufügen. Wenn auf eine der Schaltflächen geklickt wird, wird entweder die Hilfsfunktion next oder previous aufgerufen. Mit lit-html ist es einfach, Ereignis-Listener zu Elementen hinzuzufügen. können wir 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>
    `;
  }
}

Hier erfährst du, wie wir unseren neuen SVG-Schaltflächen direkt in der Methode render Ereignis-Listener hinzufügen können. Das funktioniert bei jedem Ereignis. Fügen Sie einem Element einfach eine Bindung im Format @eventname=${handler} hinzu.

Fügen Sie der Eigenschaft 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 die Fortschrittsanzeige verwenden wir das CSS-Raster, um kleine Boxen zu gestalten, eines für jede Storycard. 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 es könnte ausführlich 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 weitere Klick-Handler integriert, damit die Nutzer bei Bedarf direkt zu einer bestimmten Story-Karte 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 abgeschlossen. Verleihen Sie Ihrer Präsentation das gewisse Etwas!

6. Wischen

Zum Implementieren des Wischgestens verwenden wir die Hammer.js-Bibliothek für Gestensteuerung. 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 ein weiterer guter Ort, um Ereignis-Listener an das 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. Über die Hammer-API geben wir an, dass die und legen Sie für die Informationen zum Schwenken eine neue _panData-Eigenschaft fest.

Durch das Dekorieren der _panData-Eigenschaft mit @state 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);
}

Jetzt können wir unsere Story-Karten hin und her ziehen. Kehren wir zu static get styles zurück und fügen Sie transition: transform 0.35s ease-out; in den Selektor ::slotted(*) ein, um alles reibungsloser zu machen:

::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, sofern es vorhanden ist. Wenn eine Story-Karte den Fokus verlässt, sollten wir das Video pausieren.

Wir implementieren dies, indem wir "entered" und „Verlassen“ für die entsprechenden untergeordneten Elemente benutzerdefinierte Ereignisse hinzu, wenn sich der Index ändert. Wir empfangen diese Ereignisse im StoryCard. Vorhandene Videos werden abgespielt oder pausiert. Warum soll ich Ereignisse für die untergeordneten Elemente auslösen, anstatt "entered" aufzurufen? und „Verlassen“ auf StoryCard definierte Instanzmethoden? 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;
  }
}

Zur Fertigstellung der Autoplay-Funktion fügen wir Ereignis-Listener für und „Verlassen“ im StoryCard-Konstruktor, mit dem das Video wiedergegeben und angehalten wird.

Der Komponentennutzer kann dem <story-card> auf der Media-Fläche ein Videoelement zuweisen. Sie stellen unter Umständen gar kein Element in der Media-Fläche bereit. Wir müssen darauf achten, play nicht für ein Bild oder auf 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. Die Waage schwingen

Jetzt, da wir alle wesentlichen Funktionen kennen, können wir eine weitere hinzufügen: einen süßen Skalierungseffekt. Kehren wir noch einmal zur update-Methode von StoryViewer zurück. 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.

Ä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 Post haben wir viel behandelt, einschließlich einiger LitElement- und Lit-HTML-Funktionen, HTML-Slot-Elemente und Gestensteuerung.

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