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

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 .