Tworzenie komponentu fabularnego z elementem świetlnym

1. Wprowadzenie

Relacje są obecnie popularnym elementem interfejsu użytkownika. Aplikacje społecznościowe i wiadomościowe integrują je w swoich kanałach. W ramach tego ćwiczenia w programie utworzymy komponent artykułu za pomocą elementów świetlnych i TypeScriptu.

Tak będzie wyglądał komponent historii na końcu:

Ukończony element czytający opowieść wyświetlający 3 zdjęcia kawy

O mediach społecznościowych i „materiałach informacyjnych” można wyobrazić sobie kolekcję kart, które można odtwarzać sekwencyjnie, jak w pokazie slajdów. Relacje to dosłownie pokazy slajdów. Karty zwykle zawierają obraz lub automatycznie odtwarzany film, a na górze mogą mieć dodatkowy tekst. Oto, co utworzymy:

Lista funkcji

  • Karty z obrazem lub tłem wideo.
  • Przesuń palcem w lewo lub w prawo, aby poruszać się po relacji.
  • Autoodtwarzanie filmów.
  • możliwość dodawania tekstu lub dostosowywania kart na inne sposoby;

Jeśli chodzi o funkcję programistyczną tego komponentu, dobrze byłoby określić karty artykułów za pomocą zwykłych znaczników HTML, na przykład:

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

Dodajmy to też do listy funkcji.

Lista funkcji

  • Akceptuje serię kart w oznacznikach HTML.

Dzięki temu każdy może korzystać z naszego komponentu historii, po prostu pisząc HTML. Jest to świetne rozwiązanie zarówno dla programistów, jak i nieprogramistów. Działa wszędzie tam, gdzie działa HTML: w systemach zarządzania treścią, ramach itp.

Wymagania wstępne

  • powłoka, w której można uruchomić git i npm;
  • Edytor tekstu

2. Konfigurowanie

Zacznij od skopiowania tego repozytorium: story-viewer-starter.

git clone git@github.com:PolymerLabs/story-viewer-starter.git

Środowisko jest już skonfigurowane przy użyciu elementów świetlnych i TypeScriptu. Wystarczy zainstalować zależności:

npm i

Użytkownicy VS Code muszą zainstalować rozszerzenie lit-plugin, aby korzystać z funkcji autouzupełniania, sprawdzania typu i lintowania szablonów lit-html.

Uruchom środowisko programistyczne, wykonując te czynności:

npm run dev

Możesz zacząć kodować.

3. Komponent <story-card>

Podczas budowania komponentów złożonych czasami łatwiej jest zacząć od prostszych składowych składowych i stopniowo je rozbudowywać. Zacznijmy od utworzenia <story-card>. Powinien on umożliwiać wyświetlanie obrazu lub filmu w pełnej wielkości. Użytkownicy powinni mieć możliwość dalszego dostosowywania obrazu, na przykład za pomocą nakładki tekstowej.

Pierwszym krokiem jest zdefiniowanie klasy komponentu, która obejmuje zakres LitElement. Dekorator customElement rejestruje element niestandardowy za nas. Teraz warto włączyć dekoratory w pliku tsconfig za pomocą flagi experimentalDecorators (jeśli używasz repozytorium startowego, dekoratory są już włączone).

Dodaj ten kod do pliku story-card.ts:

import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';

@customElement('story-card')
export class StoryCard extends LitElement {
}

Teraz element <story-card> jest elementem niestandardowym, ale nie ma jeszcze nic do wyświetlenia. Aby zdefiniować wewnętrzną strukturę elementu, zdefiniuj metodę instancji render. Tutaj udostępnimy szablon elementu za pomocą tagu html w lit-html.

Co powinno zawierać szablon tego komponentu? Użytkownik powinien mieć możliwość przesłania 2 rzeczy: elementu multimedialnego i nakładki. Dodajemy więc po jednym <slot> do każdego z nich.

Przedziały to sposób, w jaki określamy elementy podrzędne elementu niestandardowego, które mają być renderowane. Więcej informacji znajdziesz w świetnym przewodniku po korzystaniu z przedziałów.

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

Oddzielenie elementu multimedialnego do osobnego slotu ułatwi nam jego kierowanie na potrzeby takich działań, jak dodawanie stylizacji na całą stronę i automatyczne odtwarzanie filmów. Umieść drugi boks (ten dla nakładek niestandardowych) wewnątrz elementu kontenera, abyśmy mogli później zapewnić domyślne dopełnienie.

Komponentu <story-card> można teraz używać w ten sposób:

<story-card>
  <img slot="media" src="some/image.jpg" />
  <h1>My Title</h1>
  <p>my description</p>
</story-card>

Ale wygląda to strasznie:

niesformatowany podgląd historii z obrazem kawy

Dodaję styl

Dobierzmy trochę stylu. W przypadku elementu lit definiujemy stałą właściwość styles i zwracamy ciąg znaków szablonu oznaczony tagiem css. Każdy napisany tu kod CSS ma zastosowanie tylko do naszego elementu niestandardowego. W tym przypadku CSS z shadow DOM sprawdza się naprawdę dobrze.

Nadajmy stylowi multimediów w boksach, aby zakrywały <story-card>. Przy okazji możemy też zastosować ładne formatowanie elementów w drugim slocie. Dzięki temu użytkownicy komponentu mogą dodać <h1>, <p> lub cokolwiek innego i zobaczyć coś ładnego domyślnie.

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

Stylowany podgląd historii z obrazem kawy

Teraz mamy karty historii z elementami tła, na których można umieścić, co tylko zechcemy. Super! Za chwilę wrócimy do klasy StoryCard, aby zaimplementować autoodtwarzanie filmów.

4. Komponent &lt;story-viewer&gt;

Nasz element <story-viewer> jest elementem nadrzędnym wobec elementów <story-card>. Będzie ona odpowiadać za rozmieszczanie kart poziomo i umożliwianie przesuwania się między nimi. Zaczniemy tak samo jak w przypadku StoryCard. Chcemy dodać karty historii jako elementy podrzędne elementu <story-viewer>, więc dodaj boks dla tych elementów podrzędnych.

Wstaw ten kod do pliku 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>`;
  }
}

Następny jest układ poziomy. Możemy to zrobić, podając pozycjonowanie bezwzględne wszystkich <story-card> w ramkach slotu i przekształcając je zgodnie z ich indeksem. Możemy kierować reklamy na sam element <story-viewer> za pomocą selektora :host.

static styles = css`
  :host {
    display: block;
    position: relative;
    /* Default size */
    width: 300px;
    height: 800px;
  }
  ::slotted(*) {
    position: absolute;
    width: 100%;
    height: 100%;
  }`;

Użytkownik może kontrolować rozmiar kart artykułów, zastępując domyślną wysokość i szerokość na hoście. W ten sposób:

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

Aby śledzić aktualnie wyświetlaną kartę, dodamy do klasy StoryViewer zmienną instancji index. Dekorowanie go obiektem @property LitElement spowoduje, że po zmianie jego wartości komponent zostanie ponownie wyrenderowany.

import { property } from 'lit/decorators.js';

export class StoryViewer extends LitElement {
  @property({type: Number}) index: number = 0;
}

Każdą kartę należy przesunąć poziomo na odpowiednie miejsce. Zastosujmy te tłumaczenia w metodzie update cyklu życia elementu świetlnego. Metoda aktualizacji będzie uruchamiana za każdym razem, gdy zmieni się zaobserwowana właściwość tego komponentu. Zwykle wysyłamy zapytanie o przedział i pętlę na podstawie slot.assignedElements(). Ponieważ mamy tylko 1 nienazwany slot, jest to to samo, co użycie this.children. Dla wygody wybierzmy 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);
  }
}

<story-card> ma już ósemkę. Nadal działa z innymi elementami jako podrzędnymi, o ile odpowiednio je sformatujemy:

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

Kliknij build/index.html i usuń znacznik komentarza z pozostałych elementów karty relacji. Teraz skonfigurujmy przekierowanie do nich.

5. Pasek postępu i nawigacja

Następnie dodamy sposób nawigacji między kartami oraz pasek postępu.

Dodaj do funkcji StoryViewer kilka funkcji pomocniczych, które umożliwią poruszanie się po historii. Ustawią nam indeks, jednocześnie zawężając go do prawidłowego zakresu.

W pliku story-viewer.ts w klasie StoryViewer dodaj:

/** 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));
}

Aby umożliwić użytkownikowi nawigację, dodamy do <story-viewer> przyciski „Wstecz” i „Dalej”. Po kliknięciu dowolnego przycisku chcemy wywołać funkcję pomocniczą next lub previous. Dzięki bibliotece lit-html można łatwo dodawać do elementów detektory zdarzeń. Możemy renderować przyciski i dodawać detektory kliknięć jednocześnie.

Zmień metodę render na:

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

Zobacz, jak możemy dodać detektory zdarzeń w tekście do nowych przycisków SVG bezpośrednio w metodzie render. Ta opcja sprawdza się w przypadku każdego wydarzenia. Wystarczy dodać do elementu wiązanie w postaci @eventname=${handler}.

Dodaj do właściwości static styles ten kod:

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

W przypadku paska postępu użyjemy siatki CSS, aby nadać styl małym kartom, po jednej dla każdej karty historii. Przy użyciu właściwości index możemy warunkowo dodawać do pól klasy wskazujące, czy „wyświetlono” czy nie. Można użyć wyrażeń warunkowych, takich jak i <= this.index : 'watched': '', ale wyniki mogą stać się bardziej szczegółowe, jeśli dodamy więcej klas. Na szczęście lit-html zawiera dyrektywę o nazwie classMap, która może pomóc. Najpierw zaimportuj plik classMap:

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

Dodaj ten znacznik u dołu metody render:

<div id="progress">
  ${Array.from(this.children).map((_, i) => html`
    <div
      class=${classMap({watched: i <= this.index})}
      @click=${() => this.index = i}
    ></div>`
  )}
</div>

Dodaliśmy też kilka dodatkowych elementów obsługi kliknięć, dzięki którym użytkownicy mogą przejść bezpośrednio do konkretnej karty historii.

Oto nowe style, które możesz dodać do 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;
}

Nawigacja i pasek postępu są gotowe. Teraz dodajmy trochę uroku.

6. Przesuwanie

Aby zaimplementować gest przesuwania, użyjemy biblioteki sterowania gestami Hammer.js. Młotek wykrywa specjalne gesty, takie jak patelnie, i wysyła zdarzenia z odpowiednimi informacjami (np. delta X), których możemy użyć.

Oto jak za pomocą Młotka wykrywamy przesunięcia i automatycznie aktualizujemy element po wystąpieniu zdarzenia przesuwania:

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

Konstruktor klasy LitElement to kolejne świetne miejsce do dołączania detektorów zdarzeń do samego elementu hosta. Konstruktor Hammer przyjmuje element, w którym ma wykrywać gesty. W naszym przypadku chodzi o sam StoryViewer, czyli this. Następnie za pomocą interfejsu API Hammera prosimy o wykrywanie gestu „przesuwania” i ustawiamy informacje o przesuniętych elementach w nowej właściwości _panData.

Jeśli oznaczysz właściwość _panData właściwością @state, LitElement będzie obserwować zmiany w elemencie _panData i przeprowadzić aktualizację. Do usługi nie będzie jednak przypisanego atrybutu HTML.

Następnie rozszerzmy logikę update, aby używać danych o przesuwaniu:

// 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);
}

Teraz możemy przeciągać karty w obie strony. Aby wszystko przebiegło sprawnie, wróć do elementu static get styles i dodaj transition: transform 0.35s ease-out; do selektora ::slotted(*):

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

Teraz płynnie przesuwaj palcem:

Nawigacja po kartach artykułów dzięki płynnemu przesuwaniu

7. Autoodtwarzanie

Ostatnia funkcja, którą dodamy, to automatyczne odtwarzanie filmów. Gdy karta staje się aktywna, chcemy, aby odtworzono film tła (jeśli istnieje). Gdy karta artykułu nie skupia się na głównej treści, należy wstrzymać odtwarzanie filmu.

Aby to zrobić, będziemy wysyłać zdarzenia niestandardowe „entered” i „exited” do odpowiednich podrzędnych elementów za każdym razem, gdy zmieni się indeks. W StoryCard będziemy otrzymywać te zdarzenia i odtwarzać lub wstrzymywać wszystkie istniejące filmy. Dlaczego lepiej wysyłać zdarzenia do elementów podrzędnych zamiast wywoływać metody instancji „entered” i „exited” zdefiniowane na StoryCard? Dzięki metodom użytkownicy, którzy korzystają z komponentu, nie mają innego wyboru niż napisać element niestandardowy, jeśli chcą stworzyć własną kartę historii z niestandardowymi animacjami. W przypadku zdarzeń mogą one po prostu dołączyć odbiornik zdarzeń.

Przeprowadźmy refaktoryzację właściwości index elementu StoryViewer, aby użyć metody ustawiającej, która zapewnia wygodną ścieżkę kodu do wysyłania zdarzeń:

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

Aby zakończyć funkcję autoodtwarzania, dodamy do StoryCard konstruktora odbiorniki zdarzeń „wprowadzone” i „zamknięte” do konstruktora, które odtwarzają i wstrzymują odtwarzanie filmu.

Pamiętaj, że użytkownik komponentu może, ale nie musi, dodać do elementu <story-card> element wideo w boksie multimedialnym. Mogą nawet nie zawierać elementu w boksie multimedialnym. Musimy uważać, aby nie wywoływać funkcji play na elemencie img ani na null.

Z powrotem do pliku Story-card.ts dodaj te elementy:

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

Autoodtwarzanie zakończone. ✅

8. Przechyl waga

Teraz, gdy mamy już wszystkie najważniejsze funkcje, dodajmy jeszcze jedną: fajny efekt skalowania. Wróćmy jeszcze raz do metody update funkcji StoryViewer. Aby uzyskać wartość w stałym wyrażeniu scale, musisz wykonać pewne obliczenia. Będzie ona równa 1.0 w przypadku aktywnego elementu podrzędnego i minScale w innych przypadkach, a także będzie interpolowana między tymi dwoma wartościami.

Zmień pętlę w metodzie update w pliku story-viewer.ts, tak aby wyglądała tak:

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})`;
  });
  // ...
}

To wszystko! W tym poście omówiliśmy wiele zagadnień, takich jak funkcje LitElement i lit-html, elementy boksów HTML oraz sterowanie gestami.

Pełną wersję tego komponentu znajdziesz na stronie https://github.com/PolymerLabs/story-viewer.