lit-element を使用してストーリー コンポーネントを作成する

ストーリーは最近人気の UI コンポーネントで、種々のソーシャル アプリやニュースアプリのフィードに組み込まれています。この Codelab では、lit-element と TypeScript を使用してストーリー コンポーネントを作成します。

最終的なストーリー コンポーネントは次のようになります。

3 つのコーヒー画像を表示した完成形の story-viewer コンポーネント

ソーシャル メディアやニュースの「ストーリー」とは、スライドショーのように順番に再生されるカードの集まりと考えることができます。実際、ストーリーはスライドショーです。カードは通常、画像または自動再生動画が中心となり、上部に追加のテキストが表示されることもあります。今回作成する内容は次のとおりです。

機能リスト

  • 画像または動画の背景を含むカード。
  • 左右にスワイプしてストーリー内を移動。
  • 自動再生動画。
  • テキストの追加またはカードのカスタマイズに対応。

デベロッパー側のコンポーネントの扱いやすさで言えば、次のようにプレーンな 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 が動作する場所であればどこにでもこのコンポーネントを適用できます。

必要なもの

  • gitnpm を実行できるシェル
  • テキスト エディタ

最初にリポジトリ story-viewer-starter のクローンを作成します。

git clone git@github.com:PolymerLabs/story-viewer-starter.git

環境は、すでに lit-element と TypeScript を使用して設定されています。依存関係のインストールのみ行ってください。

npm i

VS Code ユーザーであれば、lit-plugin 拡張機能をインストールすると、lit-html テンプレートのオートコンプリート、型チェック、lint チェックを実行できるようになります。

次のコマンドで開発環境を開始します。

npm run dev

これでコーディングを開始できます。

複合コンポーネントを作成する際は、比較的シンプルなサブコンポーネントから構築していく方が簡単な場合があります。したがって、ここでは <story-card> の作成から始めましょう。このコンポーネントは、フルブリードの動画または画像を表示できる必要があります。また、ユーザーがオーバーレイ テキストなどを使ってさらにカスタマイズできることも必要です。

最初に、LitElement を拡張するコンポーネントのクラスを定義します。この customElement デコレータによりカスタム要素の登録が行われます。tsconfig でデコレータが有効になっていない場合は、このタイミングで experimentalDecorators フラグを使用して有効化します(スターター リポジトリを使用している場合は、すでに有効になっています)。

次のコードを story-card.ts に配置します。

import { LitElement, customElement } from 'lit-element';

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

これで <story-card> が使用可能なカスタム要素になりました。ただしこの時点ではまだ何も表示されません。要素の内部構造を定義するには、render インスタンス メソッドを定義します。ここで、lit-html の html タグを使ってこの要素のテンプレートを指定しましょう。

このコンポーネントのテンプレートには何を含める必要があるか考えてみてください。ユーザーは、メディア要素とオーバーレイの 2 つを指定できなければなりません。したがって、それぞれに <slot> を 1 つずつ追加します。

スロットは、カスタム要素の「子」のレンダリングを指定する手段となります。詳細については、スロットの使用に関するチュートリアルをご覧ください。

import { html } from 'lit-html';

export class StoryCard extends LitElement {
  render() {
    return html`
      <div id="media">
        <slot name="media"></slot>
      </div>
      <div id="content">
        <slot></slot>
      </div>
    `;
  }
}

メディア要素を専用のスロットに分離することで、フルブリードのスタイルや自動再生動画を追加するといった作業で要素のターゲット指定が容易になります。2 番目のスロット(カスタム オーバーレイ用スロット)をコンテナ要素内に配置しましょう。ここにデフォルトのパディングを後で指定します。

現在、<story-card> コンポーネントは次のように使用できます。

<story-card>
  <img slot="media" src="some/image.jpg" />
  <h1>My Title</h1>
  <p>my description</p>
</story-card>

ただし現時点では表示が適切ではありません。

スタイル未設定の story-viewer でコーヒー画像を表示

スタイルを追加する

スタイルを追加しましょう。これには lit-element を使用して、静的な styles プロパティを定義し、css のタグが設定されたテンプレート文字列を返します。ここに記述されている CSS はすべて、今回のカスタム要素にのみ適用されるものです。Shadow DOM を使った CSS はこの方法でうまく機能します。

<story-card> を覆うようにスロット内のメディア要素のスタイルを設定しましょう。2 番目のスロットの要素にもここで適切なフォーマットを指定します。そうすれば、コンポーネントのユーザーに <h1><p> などの要素がデフォルトで美しく表示されます。

import { css } from 'lit-element';

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

スタイル設定された story-viewer でコーヒー画像を表示

背景のメディアを含むストーリー カードを設定できたら、前面に任意の要素を配置できます。次は StoryCard クラスに戻って自動再生動画を実装しましょう。

<story-viewer> 要素は <story-card> の親にあたります。この要素の役割は、カードを横方向にレイアウトし、カード間をスワイプできるようにすることです。それでは、StoryCard と同じように設定を始めましょう。ストーリー カードを <story-viewer> 要素の子として追加したいので、子要素用のスロットを 1 つ追加します。

次のコードを story-viewer.ts に配置します。

import { LitElement, customElement, html } from 'lit-element';

@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%;
}

現在表示中のカードを追跡できるように、インスタンス変数 indexStoryViewer クラスに追加しましょう。これを LitElement の @property でデコレートすると、値が変化するたびにコンポーネントが再レンダリングされます。

import { property } from 'lit-element';

export class StoryViewer extends LitElement {
  @property({type: Number}) index: number = 0;
}

それぞれのカードは、適切な横の位置に変換する必要があります。lit-element の update ライフサイクル メソッドでこれらの変換を適用しましょう。update メソッドは、このコンポーネントの観察対象のプロパティが変更されたときに毎回実行されます。通常ならスロットを照会して slot.assignedElements() に対しループを実行しますが、名前のないスロットが 1 つしかないため、this.children を使用しても同じです。そのため、今回は this.children を使用しましょう。

import { PropertyValues } from 'lit-element';

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 要素のコメントを解除してください。次はカード間を移動できるようにしましょう。

次は、カード間を移動する手段と進行状況バーを追加します。

ストーリー内を移動するためのヘルパー関数を 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));
}

エンドユーザーに表示するナビゲーションとして、戻る(previous)ボタンと次に進む(next)ボタンを <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>
    `;
  }
}

ここでは render メソッド内で、新しい svg ボタンにインラインでイベント リスナーを追加しています。これはどのイベントにも使用できる方法です。@eventname=${handler} の形式で要素にバインディングを追加するだけです。

ボタンのスタイルを設定するには、static styles プロパティに以下を追加します。

svg {
  position: absolute;
  top: calc(50% - 25px);
  height: 50px;
  cursor: pointer;
}
#next {
  right: 0;
}

進行状況バーには、CSS グリッドを使用して小さなボックスをスタイル設定します(ストーリー カードごとに 1 つのボックスを表示します)。カードが表示済みかどうかを示すには、index プロパティを使用してボックスに条件付きでクラスを追加します。i <= this.index : 'watched': '' のような条件式を使用することも考えられますが、クラスをさらに追加した場合、冗長になる可能性があります。幸い、lit-html では classMap というディレクティブを利用できます。まず classMap をインポートしましょう。

import { classMap } from 'lit-html/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;
}

これでナビゲーションと進行状況バーが完成しました。あとはさらに利便性を高めていきます。

スワイプ機能を実装するため、Hammer.js ジェスチャー コントロール ライブラリを使用します。Hammer ではパンなどの特殊なジェスチャーが検出され、関連情報(deltaX など)を含んだイベントがディスパッチされます。この情報を利用できます。

次のコードでは Hammer を使用してパンを検出し、パンのイベントが発生するたびに要素を自動的に更新しています。

import { internalProperty } from 'lit-element';
import 'hammerjs';

export class StoryViewer extends LitElement {
  // Data emitted by Hammer.js
  @internalProperty() _panData: {isFinal?: boolean, deltaX?: number} = {};

  constructor() {
    super();
    this.index = 0;
    new Hammer(this).on('pan', (e: HammerInput) => this._panData = e);
  }
}

ホスト要素自体にイベント リスナーをアタッチするには、LitElement クラスのコンストラクタも使えます。Hammer コンストラクタは、ジェスチャーを検出する要素(このケースでは StoryViewer 自体または this)を取ります。Hammer の API を使用して、コンストラクタで「pan」ジェスチャーを検出し、パンの情報を新しい _panData プロパティに設定します。

_panData プロパティを @internalProperty でデコレートすると、LitElement では _panData への変更が観察され更新が実行されます。ただしプロパティは属性に反映されません。

次に、パンのデータを使用するように 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;
}

これで滑らかにスワイプできるようになりました。

スワイプしてストーリー カード間を滑らかに移動

最後の機能として自動再生動画を追加します。ストーリー カードがフォーカスに入ったときには、背景動画が存在する場合はその動画を再生します。ストーリー カードがフォーカスから外れたときには、動画を一時停止します。

実装方法としては、インデックスが変更されたときに毎回、適切な子でカスタム イベントの「entered」と「exited」をディスパッチします。StoryCard で、これらのイベントを受け取って既存の動画を再生または一時停止するよう設定しましょう。StoryCard で定義された「entered」と「exited」のインスタンス メソッドを呼び出すのではなく、子でイベントをディスパッチするのには理由があります。メソッドを使用すると、コンポーネントのユーザーがカスタム アニメーションを使って独自のストーリー カードを記述したい場合に、カスタム要素を記述するしか方法がなくなるからです。イベントを使用すれば、イベント リスナーをアタッチするだけで済みます。

セッターを使用するよう StoryViewerindex プロパティをリファクタリングしましょう。これにより、イベントのディスパッチに便利なコードパスが提供されます。

class StoryViewer extends LitElement {
  @internalProperty() 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> にメディア スロットの動画要素を指定するかどうかはわかりません。メディア スロットの要素をまったく指定しないことも考えられます。img または null で play を呼び出さないように注意しましょう。

story-card.ts に戻り、次のコードを追加します。

import { query } from 'lit-element';

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

これで自動再生を設定できました。✅

基本的な機能がすべて揃ったので、最後にもう 1 つ、スケーリング効果を追加しましょう。もう一度 StoryViewerupdate メソッドに戻ります。scale 定数の値を取得するには計算を行います。有効な子の値は 1.0、それ以外は minScale で、この間を補間します。

story-viewer.ts の update メソッドのループを次のように変更します。

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 から取得できます。