Создайте компонент истории с помощьюlit-element

1. Введение

В наши дни элементы Stories пользуются большой популярностью в качестве компонента пользовательского интерфейса. Социальные сети и новостные приложения интегрируют их в свои ленты. В этом практическом занятии мы создадим компонент Stories с использованием 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> . Он должен уметь отображать видео или изображение во всю ширину экрана. Пользователи должны иметь возможность дополнительно настраивать его, например, добавляя текст.

Первый шаг — определить класс нашего компонента, который наследует 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`. Метод `update` будет выполняться всякий раз, когда изменяется наблюдаемое свойство этого компонента. Обычно мы запрашиваем слот и перебираем его с помощью 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 и раскомментируйте остальные элементы story-card. Теперь давайте сделаем так, чтобы мы могли переходить к ним!

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 Grid для оформления небольших блоков, по одному для каждой карточки истории. Мы можем использовать свойство 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. Автовоспроизведение

Последняя функция, которую мы добавим, — это автоматическое воспроизведение видео. Когда карточка истории попадает в фокус, мы хотим, чтобы воспроизводилось фоновое видео, если оно есть. Когда карточка истории покидает фокус, мы должны приостанавливать воспроизведение видео.

Мы реализуем это, отправляя пользовательские события «entered» и «exited» соответствующим дочерним элементам всякий раз, когда изменяется индекс. В StoryCard мы будем получать эти события и воспроизводить или приостанавливать любые существующие видео. Почему мы выбрали отправку событий дочерним элементам вместо вызова методов экземпляра «entered» и «exited», определенных в 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 обработчики событий для "entered" и "exited", которые будут воспроизводить и приостанавливать видео.

Помните, что пользователь компонента может добавить видеоэлемент в медиаслот <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 .