1. Wprowadzenie
Historie to obecnie popularny komponent interfejsu. Aplikacje społecznościowe i informacyjne integrują je ze swoimi kanałami. W tym ćwiczeniu z programowania utworzymy komponent Story za pomocą biblioteki lit-element i TypeScript.
Tak będzie wyglądać komponent historii na końcu:

„Relacja” w mediach społecznościowych lub serwisie informacyjnym to zbiór kart, które są odtwarzane kolejno, podobnie jak pokaz slajdów. Relacje to dosłownie pokazy slajdów. Zwykle zawierają one obraz lub automatycznie odtwarzany film, a także dodatkowy tekst u góry. Oto co stworzymy:
Lista funkcji
- karty z obrazem lub filmem w tle;
- Przesuń palcem w lewo lub w prawo, aby przejść do kolejnej części historii.
- autoodtwarzanie filmów,
- możliwość dodawania tekstu lub dostosowywania kart w inny sposób;
Jeśli chodzi o wygodę programisty, dobrze byłoby móc określać karty z historiami w zwykłym znaczniku HTML, np. tak:
<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
- Akceptuj serię kart w znacznikach HTML.
Dzięki temu każdy może używać naszego komponentu z materiałami, pisząc kod HTML. Jest to przydatne zarówno dla programistów, jak i osób, które nie programują. Działa wszędzie tam, gdzie HTML: w systemach zarządzania treścią, platformach itp.
Wymagania wstępne
- Shell, w którym możesz uruchamiać
gitinpm. - edytor tekstu,
2. Konfigurowanie
Zacznij od sklonowania tego repozytorium: story-viewer-starter
git clone git@github.com:PolymerLabs/story-viewer-starter.git
Środowisko jest już skonfigurowane pod kątem lit-element i TypeScript. Zainstaluj tylko zależności:
npm i
Jeśli używasz VS Code, zainstaluj rozszerzenie lit-plugin, aby korzystać z autouzupełniania, sprawdzania typów i lintowania szablonów lit-html.
Uruchom środowisko programistyczne, wpisując:
npm run dev
Możesz już zacząć kodowanie.
3. Komponent <story-card>
Podczas tworzenia komponentów złożonych czasami łatwiej jest zacząć od prostszych podkomponentów i stopniowo je rozbudowywać. Zacznijmy więc od utworzenia <story-card>. Powinien wyświetlać film lub obraz bez marginesów. Użytkownicy powinni mieć możliwość dalszego dostosowywania go, np. za pomocą tekstu nakładki.
Pierwszym krokiem jest zdefiniowanie klasy komponentu, która rozszerza LitElement. Dekorator customElement rejestruje za nas element niestandardowy. Teraz jest dobry moment, aby włączyć dekoratory w pliku tsconfig za pomocą flagi experimentalDecorators (jeśli używasz repozytorium startowego, jest ona już włączona).
Wklej 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 <story-card> jest użytecznym elementem niestandardowym, ale nie ma jeszcze nic do wyświetlenia. Aby zdefiniować strukturę wewnętrzną elementu, zdefiniuj metodę instancji render. W tym miejscu podamy szablon elementu, używając tagu html lit-html.
Co powinno się znaleźć w szablonie tego komponentu? Użytkownik powinien mieć możliwość podania 2 elementów: elementu multimedialnego i nakładki. Dodamy więc po jednym <slot> dla każdego z nich.
Gniazda określają, jak mają być renderowane elementy podrzędne elementu niestandardowego. Więcej informacji znajdziesz w tym przewodniku po używaniu slotó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 boksu pomoże nam kierować na niego reklamy, np. dodawać styl pełnoekranowy i autoodtwarzanie filmów. Umieść drugi slot (przeznaczony na niestandardowe nakładki) w elemencie kontenera, abyśmy mogli później dodać domyślny odstęp.
Komponent <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 okropnie:

Dodawanie stylu
Dodajmy trochę stylu. W lit-element robimy to, definiując statyczną właściwość styles i zwracając ciąg szablonu oznaczony tagiem css. Każdy kod CSS napisany w tym miejscu ma zastosowanie tylko do naszego elementu niestandardowego. CSS z Shadow DOM jest pod tym względem bardzo przydatny.
Sformatujmy element multimedialny w slocie, aby zakryć <story-card>. Przy okazji możemy zastosować ładne formatowanie elementów w drugim slocie. Dzięki temu użytkownicy komponentu mogą wstawiać <h1>, <p> itp. i domyślnie widzieć coś ładnego.
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;
}
`;
}

Mamy teraz karty z mediami w tle, na których możemy umieścić dowolne elementy. Super! Za chwilę wrócimy do klasy StoryCard, aby wdrożyć autoodtwarzanie filmów.
4. Komponent <story-viewer>
Element <story-viewer> jest elementem nadrzędnym elementów <story-card>. Będzie ona odpowiadać za rozmieszczenie kart w poziomie i umożliwiać przełączanie się między nimi. Zaczniemy tak samo jak w przypadku StoryCard. Chcemy dodać karty historii jako elementy podrzędne elementu <story-viewer>, więc dodajmy dla nich slot.
Wpisz 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ąć, nadając wszystkim elementom <story-card> pozycjonowanie bezwzględne i przesuwają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 z relacjami, zastępując domyślną wysokość i szerokość na stronie hosta. W ten sposób:
story-viewer {
width: 400px;
max-width: 100%;
height: 80%;
}
Aby śledzić aktualnie wyświetlaną kartę, dodajmy zmienną instancji index do klasy StoryViewer. Dekorowanie go za pomocą @property LitElement spowoduje ponowne renderowanie komponentu za każdym razem, gdy zmieni się jego wartość.
import { property } from 'lit/decorators.js';
export class StoryViewer extends LitElement {
@property({type: Number}) index: number = 0;
}
Każda karta musi być przesunięta w poziomie na odpowiednią pozycję. Zastosujmy te tłumaczenia w updatemetodzie cyklu życia lit-element. Metoda aktualizacji będzie uruchamiana za każdym razem, gdy zmieni się obserwowana właściwość tego komponentu. Zwykle wysyłamy zapytanie o slot i przechodzimy w pętli po slot.assignedElements(). Ponieważ mamy tylko 1 nieokreślone miejsce, jest to to samo co użycie this.children. Dla wygody użyjemy 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);
}
}
Nasze <story-card> są teraz w jednym rzędzie. Nadal działa z innymi elementami jako elementami podrzędnymi, o ile zadbamy o odpowiednie style:
<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>
Otwórz build/index.html i odkomentuj pozostałe elementy karty z relacją. Teraz sprawimy, że będzie można do nich przejść.
5. Pasek postępu i nawigacja
Następnie dodamy sposób przechodzenia między kartami i pasek postępu.
Dodajmy do StoryViewer funkcje pomocnicze, które ułatwią poruszanie się po historii. Ustawią dla nas indeks, ograniczają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 udostępnić nawigację użytkownikowi, dodamy do <story-viewer> przyciski „Poprzedni” i „Następny”. Gdy klikniesz dowolny przycisk, chcemy wywołać funkcję pomocniczą next lub previous. Biblioteka lit-html ułatwia dodawanie detektorów zdarzeń do elementów. Możemy renderować przyciski i jednocześnie dodawać detektor 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 dodawać detektory zdarzeń w wierszu w przypadku nowych przycisków SVG bezpośrednio w metodzie render. Działa to w przypadku każdego wydarzenia. Wystarczy dodać wiązanie formularza @eventname=${handler} do elementu.
Aby określić styl przycisków, dodaj do właściwości static styles te elementy:
svg {
position: absolute;
top: calc(50% - 25px);
height: 50px;
cursor: pointer;
}
#next {
right: 0;
}
W przypadku paska postępu użyjemy siatki CSS, aby ostylować małe pola – po jednym dla każdej karty historii. Możemy użyć właściwości index, aby warunkowo dodawać klasy do pól, które wskazują, czy zostały one „zobaczone”. Możemy użyć wyrażenia warunkowego, np. i <= this.index : 'watched': '', ale jeśli dodamy więcej klas, kod może się rozrosnąć. Na szczęście lit-html udostępnia dyrektywę o nazwie classMap, która może w tym pomóc. Najpierw zaimportuj classMap:
import { classMap } from 'lit/directives/class-map';
i dodaj ten znacznik na końcu 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ż więcej funkcji obsługi kliknięć, aby użytkownicy mogli w razie potrzeby przejść bezpośrednio do konkretnej karty z informacjami.
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;
}
Pasek nawigacji i postępu został ukończony. Teraz dodajmy trochę stylu.
6. Przesuwanie
Aby zaimplementować przesuwanie, użyjemy biblioteki sterowania gestami Hammer.js. Hammer wykrywa specjalne gesty, takie jak przesuwanie, i wysyła zdarzenia z odpowiednimi informacjami (np. delta X), które możemy wykorzystać.
Oto jak możemy użyć Hammera do wykrywania panoramowania i automatycznego aktualizowania elementu za każdym razem, gdy wystąpi zdarzenie panoramowania:
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 na dołączanie detektorów zdarzeń do samego elementu hosta. Konstruktor Hammer przyjmuje element, na którym mają być wykrywane gesty. W naszym przypadku jest to sam znak StoryViewer, czyli this. Następnie za pomocą interfejsu API Hammera informujemy go, aby wykrywał gest „przesunięcia” i ustawiał informacje o przesunięciu w nowej właściwości _panData.
Dekorując właściwość _panData za pomocą @state, LitElement będzie obserwować zmiany w _panData i przeprowadzać aktualizację, ale nie będzie powiązanego atrybutu HTML dla tej właściwości.
Następnie rozszerzmy logikę update, aby korzystać z 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);
}
Możemy teraz przeciągać karty historii w obie strony. Aby ułatwić sobie pracę, wróćmy do static get styles i dodajmy transition: transform 0.35s ease-out; do selektora ::slotted(*):
::slotted(*) {
...
transition: transform 0.35s ease-out;
}
Teraz mamy płynne przesuwanie:

7. Autoodtwarzanie
Ostatnią funkcją, którą dodamy, będzie autoodtwarzanie filmów. Gdy karta z opowiadaniem zostanie wyróżniona, chcemy, aby odtwarzał się film w tle (jeśli istnieje). Gdy karta relacji przestaje być w centrum uwagi, powinniśmy wstrzymać odtwarzanie filmu.
Zaimplementujemy to, wysyłając niestandardowe zdarzenia „entered” (wszedł) i „exited” (wyszedł) w odpowiednich elementach podrzędnych, gdy tylko zmieni się indeks. W StoryCard będziemy otrzymywać te zdarzenia i odtwarzać lub wstrzymywać odtwarzanie istniejących filmów. Dlaczego warto wysyłać zdarzenia do elementów podrzędnych zamiast wywoływać metody instancji „entered” i „exited” zdefiniowane w komponencie StoryCard? W przypadku metod użytkownicy komponentu nie mieliby wyboru i musieliby napisać element niestandardowy, jeśli chcieliby utworzyć własną kartę z niestandardowymi animacjami. W przypadku zdarzeń wystarczy dołączyć detektor zdarzeń.
Zrefaktoryzujmy właściwość StoryViewerindex, aby używać funkcji 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 dokończyć funkcję autoodtwarzania, dodamy do konstruktora StoryCard detektory zdarzeń „entered” i „exited”, które będą odtwarzać i wstrzymywać film.
Pamiętaj, że użytkownik komponentu może, ale nie musi, umieścić element wideo w miejscu na multimedia.<story-card> Mogą nawet w ogóle nie wyświetlać elementu w miejscu na reklamy. Musimy uważać, aby nie wywoływać funkcji play na elemencie img ani na wartości null.
Wróć do pliku story-card.ts i dodaj ten kod:
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. Przechylenie szali
Mamy już wszystkie niezbędne funkcje, więc dodajmy jeszcze jedną: efekt skalowania. Wróćmy jeszcze raz do metody update StoryViewer. Aby uzyskać wartość stałej scale, wykonuje się pewne obliczenia matematyczne. W przypadku aktywnego elementu podrzędnego będzie ona równa 1.0, a w pozostałych przypadkach – minScale. Wartości te będą też interpolowane.
Zmień pętlę w metodzie 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 kwestii, w tym niektóre funkcje LitElement i lit-html, elementy gniazd HTML i sterowanie gestami.
Pełną wersję tego komponentu znajdziesz na stronie https://github.com/PolymerLabs/story-viewer.