Tworzenie komponentu fabularnego z elementem świetlnym

Tworzenie komponentu fabularnego z elementem świetlnym

Informacje o tym ćwiczeniu (w Codelabs)

subjectOstatnia aktualizacja: paź 1, 2021
account_circleAutorzy: Steven Traversi

1. Wprowadzenie

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

Tak będzie wyglądać część relacji na końcu:

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

Możemy wymyślić „relację” w mediach społecznościowych lub wiadomości jako zbiór kart, które można odtwarzać sekwencyjnie, jak w pokazie slajdów. Historie to dosłownie pokazy slajdów. Większość kart jest zdominowana przez obraz lub automatycznie odtwarzany film, a u góry może znajdować się 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 i dostosowywania kart w inny sposób.

Jeśli chodzi o funkcjonalność 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 więc je do listy funkcji.

Lista funkcji

  • Akceptuj serię kart, używając znaczników HTML.

Dzięki temu każdy może korzystać z naszego komponentu historii, po prostu pisząc HTML. Znakomicie sprawdza się on zarówno w przypadku programistów, jak i innych osób, ponieważ sprawdza się wszędzie tam, gdzie dostępny jest kod HTML: w systemach zarządzania treścią, schematach itp.

Wymagania wstępne

  • Powłoka, w której możesz uruchamiać git i npm
  • Edytor tekstu

2. Konfigurowanie

Najpierw skopiuj to 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 mogą zainstalować rozszerzenie lit-plugin, aby korzystać z funkcji autouzupełniania, sprawdzania typu i lintowania szablonów lit-html.

Uruchom środowisko programistyczne, uruchamiając polecenie:

npm run dev

Możesz już zacząć kodować.

3. <karta-historia> Komponent

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 całego filmu lub obrazu. Użytkownicy powinni mieć możliwość dostosowania reklamy np. przez użycie nakładki z tekstem.

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

Umieść ten kod w pliku Story-card.ts.

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

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

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

Co powinno zawierać szablon tego komponentu? Użytkownik powinien być w stanie dostarczyć 2 elementy: element multimedialny i nakładkę. 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>
   
`;
 
}
}

Przydzielenie elementu multimedialnego do własnego boksu pomoże nam kierować na niego reklamy m.in. przez dodawanie stylów i autoodtwarzania 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:

niestylowa osoba przeglądająca historię pokazująca zdjęcie kawy

Dodaję styl

Dobierzmy trochę stylu. W przypadku elementu litowego definiujemy statyczną właściwość styles i zwracamy ciąg szablonu z tagiem css. Każdy napisany tu kod CSS ma zastosowanie tylko do naszego elementu niestandardowego. Kod CSS z shadow DOM działa bardzo dobrze.

Nadajmy stylowi multimediów w boksach, aby zakrywały <story-card>. Na razie możemy zapewnić ładne formatowanie elementów w drugim boksie. Dzięki temu użytkownicy komponentów będą mogli wpaść w elementy <h1>, <p> itp. i domyślnie zobaczyć coś fajnego.

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

stylizowana osoba wyświetlająca opowieść ze zdjęciem 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. <story-viewer> Komponent

Nasz element <story-viewer> jest elementem nadrzędnym wobec elementów <story-card>. Odpowiada za rozłożenie kart w poziomie i umożliwienie nam ich przesuwania 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.

Umieść ten kod w 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 osiągnąć, podając bezwzględne pozycjonowanie <story-card> w boksach i tłumaczą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ć wyświetlaną kartę, dodajmy do klasy StoryViewer zmienną wystąpienia 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ż jednak mamy tylko 1 boks bez nazwy, działa tak samo jak przy korzystaniu z 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ż swój rząd. Funkcja ta nadal będzie funkcjonowała jako elementy podrzędne, o ile zadbamy o odpowiedni styl:

<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. Zróbmy to teraz, żebyśmy mogli do nich dotrzeć.

5. Pasek postępu i nawigacja

Następnie dodamy sposób poruszania się między kartami i pasek postępu.

Dodajmy do elementu StoryViewer kilka funkcji pomocniczych, które ułatwią poruszanie się po artykule. 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 zapewnić użytkownikowi nawigację, dodamy „poprzednie” i „Dalej” do <story-viewer>. Po kliknięciu dowolnego z tych przycisków chcemy wywołać funkcję pomocniczą next lub previous. lit-html ułatwia dodawanie do elementów detektorów zdarzeń; możemy jednocześnie renderować przyciski i dodać odbiornik kliknięć.

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 powiązanie typu @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;
}

Na pasku postępu użyjemy siatki CSS, by dodać styl do małych pól, po jednym na każdą kartę historii. Przy użyciu właściwości index możemy warunkowo dodawać do pól klasy wskazujące, czy były „widoczne”. lub 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';

Na dole metody render dodaj te znaczniki:

<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 również więcej modułów obsługi kliknięć, aby użytkownicy mogli od razu przechodzić do kart konkretnych artykułów.

Oto nowe style do dodania 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ę blasku.

6. Przesuwanie

Aby wdrożyć przesuwanie, użyj 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 Młot wykrywa gesty za pomocą jakiegoś elementu. W naszym przypadku chodzi o sam StoryViewer, czyli this. Następnie za pomocą interfejsu API Hammera nakazujemy mu wykrycie i ustaw informacje o ruchu do nowej właściwości _panData.

Gdy 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 górę i w tył. Aby wszystko przebiegło płynnie, 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 w centrum uwagi znajduje się karta artykułu, chcemy odtworzyć film w tle, jeśli taki istnieje. Gdy karta artykułu nie skupia się na głównej treści, należy wstrzymać odtwarzanie filmu.

Wdrożymy to, wysyłając polecenie „entered” (wprowadzono). i „zakończył” zdarzeń niestandardowych na odpowiednie elementy podrzędne po każdej zmianie indeksu. Za StoryCard będziemy odbierać te zdarzenia i odtworzyć lub wstrzymać odtwarzanie istniejących filmów. Dlaczego warto wysyłać zdarzenia dotyczące wydawców podrzędnych zamiast wywoływać stan „włączono” i „zakończył” zdefiniowano metody instancji 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ń wystarczy tylko dołączyć detektor 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 detektory zdarzeń dla „wprowadzonego” i „zakończył” w konstruktorze StoryCard, 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ć, żeby nie wywoływać funkcji play na podstawie obrazu ani wartości 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

Skoro znamy już wszystkie najważniejsze funkcje, dodajmy jeszcze jeden: uroczy efekt skalowania. Wróćmy jeszcze raz do metody update metody StoryViewer. Aby obliczyć wartość stałej scale, trzeba wykonać kilka obliczeń. W przypadku aktywnego elementu podrzędnego będzie równa 1.0, a w innym przypadku – minScale. W przeciwnym razie ta wartość będzie interpolowana między tymi 2 wartościami.

Zmień pętlę metody update w pliku Story-viewer.ts na:

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.