1. Введение
В наши дни истории — популярный компонент пользовательского интерфейса. Социальные и новостные приложения интегрируют их в свои ленты. В этой лаборатории кода мы создадим компонент истории с помощьюlit-element и TypeScript.
Вот как будет выглядеть компонент истории в конце:
Мы можем думать о социальных сетях или новостных «историях» как о наборе карточек, которые нужно разыгрывать последовательно, что-то вроде слайд-шоу. На самом деле истории — это буквально слайд-шоу. На карточках обычно преобладает изображение или автоматически воспроизводимое видео, а сверху может быть дополнительный текст. Вот что мы построим:
Список функций
- Открытки с изображением или видеофоном.
- Проведите пальцем влево или вправо, чтобы перемещаться по истории.
- Автовоспроизведение видео.
- Возможность добавлять текст или иным образом настраивать карточки.
Что касается опыта разработчиков этого компонента, было бы неплохо указать карточки-истории в простой HTML-разметке, например:
<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>
Итак, давайте также добавим это в список функций.
Список функций
- Примите серию карточек в HTML-разметке.
Таким образом, любой может использовать наш компонент истории, просто написав HTML. Это отлично подходит как для программистов, так и для непрограммистов, и работает везде, где есть HTML: в системах управления контентом, фреймворках и т. д.
Предварительные условия
- Оболочка, в которой вы можете запускать
git
иnpm
- Текстовый редактор
2. Настройка
Начните с клонирования этого репозитория: Story-Viewer-Starter.
git clone git@github.com:PolymerLabs/story-viewer-starter.git
Среда уже настроена с помощьюlit-element и TypeScript. Просто установите зависимости:
npm i
Для пользователей VS Code установите расширениеlit -plugin , чтобы получить возможность автодополнения, проверки типов и проверки шаблоновlit-html.
Запустите среду разработки, выполнив:
npm run dev
Вы готовы начать кодирование!
3. Компонент <story-card>
При создании составных компонентов иногда проще начать с более простых подкомпонентов и затем наращивать их. Итак, давайте начнем с создания <story-card>
. Он должен иметь возможность отображать видео или изображение без полей. Пользователи должны иметь возможность настраивать его, например, с помощью наложения текста.
Первым шагом является определение класса нашего компонента, который расширяет LitElement
. Декоратор customElement
позаботится о регистрации пользовательского элемента. Сейчас самое время убедиться, что вы включили декораторы в своем tsconfig с помощью флага experimentalDecorators
(если вы используете стартовый репозиторий, он уже включен).
Поместите следующий код в Story-card.ts:
import { LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('story-card')
export class StoryCard extends LitElement {
}
Теперь <story-card>
— это полезный пользовательский элемент, но пока нечего отображать. Чтобы определить внутреннюю структуру элемента, определите метод экземпляра render
. Здесь мы предоставим шаблон для элемента, используя тег html
сайтаlit-html.
Что должно быть в шаблоне этого компонента? Пользователь должен иметь возможность предоставить две вещи: медиа-элемент и наложение. Итак, мы добавим по одному <slot>
для каждого из них.
Слоты — это то, как мы указываем, как должны отображаться дочерние элементы пользовательского элемента. Для получения дополнительной информации, вот отличное пошаговое руководство по использованию слотов .
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>
`;
}
}
Выделение медиа-элемента в отдельный слот поможет нам использовать этот элемент для таких вещей, как добавление стилей без полей и автоматическое воспроизведение видео. Поместите второй слот (тот, который предназначен для пользовательских наложений) внутри элемента контейнера, чтобы мы могли позже предоставить некоторые отступы по умолчанию.
Компонент <story-card>
теперь можно использовать следующим образом:
<story-card>
<img slot="media" src="some/image.jpg" />
<h1>My Title</h1>
<p>my description</p>
</story-card>
Но выглядит это ужасно:
Добавление стиля
Давайте добавим немного стиля. С помощьюlit-element мы делаем это, определяя свойство статических styles
и возвращая строку шаблона, помеченную css
. Какой бы CSS здесь ни был написан, он применим только к нашему пользовательскому элементу! В этом отношении CSS с теневым DOM действительно удобен.
Давайте стилизуем медиа-элемент с прорезями так, чтобы он закрывал <story-card>
. Пока мы здесь, мы можем обеспечить хорошее форматирование элементов во втором слоте. Таким образом, пользователи компонентов могут добавить некоторые <h1>
, <p>
или что-то еще и увидеть что-то приятное по умолчанию.
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;
}
`;
}
Теперь у нас есть карточки-истории с фоновым медиа, и мы можем разместить сверху все, что захотим. Хороший! Чуть позже мы вернемся к классу StoryCard
, чтобы реализовать автоматическое воспроизведение видео.
4. Компонент <story-viewer>
Наш элемент <story-viewer>
является родительским элементом <story-card>
. Он будет отвечать за горизонтальное расположение карточек и возможность перелистывания между ними. Мы начнем его так же, как и для StoryCard
. Мы хотим добавить карточки историй в качестве дочерних элементов элемента <story-viewer>
, поэтому добавьте слот для этих дочерних элементов.
Поместите следующий код в 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>`;
}
}
Далее идет горизонтальная планировка. Мы можем подойти к этому, задав абсолютное позиционирование всех помещенных в слот <story-card>
и преобразуя их в соответствии с их индексом. Мы можем выбрать сам элемент <story-viewer>
, используя селектор :host
.
static styles = css`
:host {
display: block;
position: relative;
/* Default size */
width: 300px;
height: 800px;
}
::slotted(*) {
position: absolute;
width: 100%;
height: 100%;
}`;
Пользователь может контролировать размер наших карточек историй, просто переопределив высоту и ширину по умолчанию на хосте. Так:
story-viewer {
width: 400px;
max-width: 100%;
height: 80%;
}
Чтобы отслеживать просматриваемую в данный момент карточку, давайте добавим index
переменной экземпляра в класс StoryViewer
. Украшение его @property
LitElement приведет к повторному рендерингу компонента при каждом изменении его значения.
import { property } from 'lit/decorators.js';
export class StoryViewer extends LitElement {
@property({type: Number}) index: number = 0;
}
Каждую карту нужно перевести горизонтально в нужное положение. Давайте применим эти переводы в методе жизненного цикла update
элементаlit-element. Метод обновления будет запускаться всякий раз, когда изменяется наблюдаемое свойство этого компонента. Обычно мы запрашиваем слот и перебираем slot.assignedElements()
. Однако, поскольку у нас есть только один безымянный слот, это то же самое, что и использование this.children
. Давайте для удобства воспользуемся 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>
теперь все в ряд. Он по-прежнему работает с другими элементами как с дочерними элементами, если мы позаботимся о их соответствующем стиле:
<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>
Перейдите в build/index.html
и раскомментируйте остальные элементы карточки-истории. Теперь давайте сделаем так, чтобы мы могли перемещаться к ним!
5. Индикатор выполнения и навигация
Далее мы добавим возможность навигации между карточками и индикатор выполнения.
Давайте добавим в StoryViewer
несколько вспомогательных функций для навигации по истории. Они установят для нас индекс, ограничивая его допустимым диапазоном.
В Story-viewer.ts в классе StoryViewer
добавьте:
/** 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));
}
Чтобы предоставить навигацию конечному пользователю, мы добавим кнопки «Предыдущий» и «Далее» в <story-viewer>
. При нажатии любой кнопки мы хотим вызвать next
или previous
вспомогательную функцию. lit-html позволяет легко добавлять прослушиватели событий к элементам; мы можем визуализировать кнопки и одновременно добавить прослушиватель кликов.
Обновите метод render
следующим образом:
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>
`;
}
}
Узнайте, как мы можем добавить прослушиватели событий в наши новые кнопки svg прямо в методе render
. Это работает для любого мероприятия. Просто добавьте к элементу привязку вида @eventname=${handler}
.
Добавьте следующее в свойство static styles
, чтобы стилизовать кнопки:
svg {
position: absolute;
top: calc(50% - 25px);
height: 50px;
cursor: pointer;
}
#next {
right: 0;
}
Для индикатора выполнения мы будем использовать сетку CSS для оформления маленьких прямоугольников, по одному для каждой карточки истории. Мы можем использовать свойство index
для условного добавления классов к полям, чтобы указать, были ли они «просмотрены» или нет. Мы могли бы использовать условное выражение, например i <= this.index : 'watched': ''
, но если мы добавим больше классов, все может стать многословным. К счастью, вlit-html в помощь есть директива classMap
. Сначала импортируйте classMap
:
import { classMap } from 'lit/directives/class-map';
И добавьте следующую разметку в нижнюю часть метода render
:
<div id="progress">
${Array.from(this.children).map((_, i) => html`
<div
class=${classMap({watched: i <= this.index})}
@click=${() => this.index = i}
></div>`
)}
</div>
Мы также добавили еще несколько обработчиков кликов, чтобы пользователи могли сразу перейти к определенной карточке истории, если захотят.
Вот новые стили, которые можно добавить к 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;
}
Навигация и индикатор выполнения завершены. Теперь добавим немного изюминки!
6. Пролистывание
Чтобы реализовать пролистывание, мы будем использовать библиотеку управления жестами Hammer.js . Hammer обнаруживает специальные жесты, такие как панорамирование, и отправляет события с соответствующей информацией (например, дельтой X), которую мы можем использовать.
Вот как мы можем использовать Hammer для обнаружения панорамирования и автоматического обновления нашего элемента всякий раз, когда происходит событие панорамирования:
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);
}
}
Конструктор класса LitElement — еще одно отличное место для прикрепления прослушивателей событий к самому хост-элементу. Конструктор Hammer принимает элемент для обнаружения жестов. В нашем случае это сам StoryViewer
или this
. Затем, используя API Hammer, мы указываем ему обнаружить жест «панорамирование» и устанавливаем информацию о панорамировании в новое свойство _panData
.
Украсив свойство _panData
@state
, LitElement будет отслеживать изменения в _panData
и выполнять обновление, но для свойства не будет связанного HTML-атрибута.
Далее давайте расширим логику update
, чтобы использовать данные панорамирования:
// 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);
}
Теперь мы можем перетаскивать наши карты историй туда и обратно. Чтобы все было гладко, давайте вернемся к static get styles
и добавим transition: transform 0.35s ease-out;
к селектору ::slotted(*)
:
::slotted(*) {
...
transition: transform 0.35s ease-out;
}
Теперь у нас плавное смахивание:
7. Автозапуск
Последняя функция, которую мы добавим, — это автоматическое воспроизведение видео. Когда карточка-история попадает в фокус, мы хотим, чтобы воспроизводилось фоновое видео, если оно существует. Когда карточка-история выходит из фокуса, мы должны приостановить ее видео.
Мы реализуем это, отправляя пользовательские события «вход» и «выход» для соответствующих дочерних элементов при каждом изменении индекса. В StoryCard
мы будем получать эти события и воспроизводить или приостанавливать любые существующие видео. Зачем отправлять события дочерним элементам вместо вызова методов экземпляра «введено» и «выведено», определенных в StoryCard? При использовании методов у пользователей компонента не будет другого выбора, кроме как написать собственный элемент, если они захотят написать свою собственную карту истории с настраиваемой анимацией. С событиями они могут просто подключить прослушиватель событий!
Давайте реорганизуем свойство index
StoryViewer
, чтобы использовать установщик, который обеспечивает удобный путь кода для отправки событий:
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;
}
}
Чтобы завершить функцию автозапуска, мы добавим в конструктор StoryCard
прослушиватели событий «вход» и «выход», которые воспроизводят и приостанавливают видео.
Помните, что пользователь компонента может предоставить или не предоставить <story-card>
видеоэлемент в медиа-слоте. Они могут вообще не предоставлять элемент в медиа-слоте. Мы должны быть осторожны, чтобы не вызывать play
для img или null.
Вернувшись в Story-card.ts, добавьте следующее:
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;
}
Автовоспроизведение завершено. ✅
8. Склоните чашу весов
Теперь, когда у нас есть все основные функции, давайте добавим еще одну: приятный эффект масштабирования. Давайте еще раз вернемся к методу update
StoryViewer
. Чтобы получить значение scale
константы, выполняются некоторые математические действия. Оно будет равно 1.0
для активного дочернего элемента и minScale
в противном случае, также интерполируя между этими двумя значениями.
Измените цикл в методе update
в Story-viewer.ts следующим образом:
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})`;
});
// ...
}
Вот и все, ребята! В этом посте мы рассмотрели многое, в том числе некоторые функции LitElement иlit-html, элементы HTML-слотов и управление жестами.
Полную версию этого компонента можно найти по адресу: https://github.com/PolymerLabs/story-viewer .