Codelab: ストーリー コンポーネントの作成

この Codelab では、ウェブで Instagram ストーリーなどのエクスペリエンスを構築する方法について説明します。まずは HTML、CSS、JavaScript の順でコンポーネントを構築していきます。

このコンポーネントの作成中に行われる段階的な機能強化については、ストーリー コンポーネントの構築に関するブログ投稿をご覧ください。

設定

  1. [Remix to Edit] をクリックしてプロジェクトを編集可能にします。
  2. app/index.html を開きます。

HTML

私は常にセマンティック HTML を使用することを目指しています。各友達はストーリーをいくつでも持てるので、<section> 要素を各友達に、<article> 要素をストーリーごとに使用することに意味があると思っています。では最初から始めましょう。まず ストーリーコンポーネントの コンテナが必要です

<div> 要素を <body> に追加します。

<div class="stories">

</div>

友人を表す <section> 要素をいくつか追加します。

<div class="stories">
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
  <section class="user"></section>
</div>

ストーリーを表す <article> 要素をいくつか追加します。

<div class="stories">
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/480/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/840);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/481/841);"></article>
  </section>
  <section class="user">
    <article class="story" style="--bg: url(https://picsum.photos/482/840);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/843);"></article>
    <article class="story" style="--bg: url(https://picsum.photos/482/844);"></article>
  </section>
</div>
  • Google では、画像サービス(picsum.com)を使用してストーリーのプロトタイプを作成しています。
  • <article>style 属性は、プレースホルダの読み込み手法の一部です。これについては、次のセクションで詳しく説明します。

CSS

コンテンツがスタイリッシュに。その骨を人間が触れたくなるものに 変えていきましょう今日はモバイルファーストで取り組みます。

.stories

<div class="stories"> コンテナには、水平スクロール コンテナが必要です。これは次の方法で実現できます。

  • コンテナをグリッドにする
  • 行トラックを埋めるように各子を設定する
  • 各子の幅をモバイル デバイスのビューポートの幅にする

すべての HTML 要素がマークアップに配置されるまで、グリッドは 100vw 幅の新しい列を前の列の右に引き続き配置します。

全幅レイアウトを示すグリッド表示の Chrome と DevTools が開きます。
Chrome DevTools でグリッドの列のオーバーフローが表示され、水平方向のスクローラーが表示されている。

app/css/index.css の最後に次の CSS を追加します。

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
}

ビューポートの範囲外に拡張するコンテンツを用意できたので、次はそのコンテナにその処理方法を指定します。ハイライト表示されたコード行を .stories ルールセットに追加します。

.stories {
  display: grid;
  grid: 1fr / auto-flow 100%;
  gap: 1ch;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior: contain;
  touch-action: pan-x;
}

水平方向のスクロールが必要なため、overflow-xauto に設定します。ユーザーがスクロールしたら、コンポーネントを次のストーリーで静かに停止させるため、scroll-snap-type: x mandatory を使用します。この CSS について詳しくは、ブログ記事の CSS スクロール スナップ ポイントoverscroll-behavior のセクションをご覧ください。

親コンテナと子の両方がスクロールのスナップに同意する必要があるため、それについて考えてみましょう。app/css/index.css の最後に次のコードを追加します。

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}

アプリはまだ動作しませんが、次の動画では、scroll-snap-type を有効または無効にするとどうなるかを示しています。有効にすると、横方向にスクロールするたびに次のストーリーにスナップします。無効にすると、ブラウザはデフォルトのスクロール動作を使用します。

友人をスクロールするのは面倒ですが、まだ解決すべきストーリーの問題があります。

.user

.user セクションに、これらの子ストーリー要素を所定の位置にラングリングするレイアウトを作成しましょう。この問題を解決するために、便利な積み重ねテクニックを使います。基本的には、行と列に同じグリッド エイリアス [story] を持つ 1x1 のグリッドを作成します。各記事のグリッド アイテムはそのスペースを確保しようとし、スタックになります。

ハイライト表示されたコードを .user ルールセットに追加します。

.user {
  scroll-snap-align: start;
  scroll-snap-stop: always;
  display: grid;
  grid: [story] 1fr / [story] 1fr;
}

次のルールセットを app/css/index.css の最後に追加します。

.story {
  grid-area: story;
}

絶対位置指定、浮動小数点数、または要素をフローから外すその他のレイアウト ディレクティブがなければ、フローは継続されます。しかも ほとんどコードですこの点については、動画とブログ投稿で詳しく説明しています。

.story

あとは、ストーリー アイテム自体にスタイルを設定するだけです。

前述したように、各 <article> 要素の style 属性はプレースホルダの読み込み手法の一部です。

<article class="story" style="--bg: url(https://picsum.photos/480/840);"></article>

ここでは、CSS の background-image プロパティを使用します。これにより、複数の背景画像を指定できます。ユーザー画像を順番に並べて 読み込み完了時に自動的に表示されるようにしますこれを有効にするには、画像の URL をカスタム プロパティ(--bg)に入れ、CSS 内でそれを使用して読み込みプレースホルダと階層化します。

まず、読み込みが完了したら、.story ルールセットを更新してグラデーションを背景画像に置き換えます。ハイライト表示されたコードを .story ルールセットに追加します。

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));
}

background-sizecover に設定すると、画像によって埋められるため、ビューポートに空白がなくなります。2 つの背景画像を定義すると、tombstone の読み込みと呼ばれる、便利な CSS ウェブトリックを作成できます。

  • 背景画像 1(var(--bg))は、HTML でインラインで渡した URL です。
  • 背景画像 2(linear-gradient(to top, lch(98 0 0), lch(90 0 0)) は URL の読み込み中に表示するグラデーションです)

画像のダウンロードが完了すると、CSS によってグラデーションが自動的に画像に置き換えられます。

次に、CSS を追加して動作を取り除き、ブラウザの動作を速くします。 ハイライト表示されたコードを .story ルールセットに追加します。

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;
}
  • user-select: none は、ユーザーが誤ってテキストを選択するのを防ぐ
  • touch-action: manipulation は、これらの操作をタップイベントとして扱うようブラウザに指示します。これにより、ブラウザはユーザーが URL をクリックしているかどうか判断する必要がなくなります。

最後に、CSS を追加して、ストーリーの遷移をアニメーション化しましょう。ハイライト表示されたコードを .story ルールセットに追加します。

.story {
  grid-area: story;

  background-size: cover;
  background-image:
    var(--bg),
    linear-gradient(to top, lch(98 0 0), lch(90 0 0));

  user-select: none;
  touch-action: manipulation;

  transition: opacity .3s cubic-bezier(0.4, 0.0, 1, 1);

  &.seen {
    opacity: 0;
    pointer-events: none;
  }
}

Exit が必要なストーリーに .seen クラスが追加されます。マテリアル デザインのイージングガイドからカスタム イージング関数(cubic-bezier(0.4, 0.0, 1,1))を入手しました(高速イージングのセクションまでスクロールします)。

pointer-events: none 宣言に気づかれた方もいらっしゃるでしょう。これが現時点でのソリューションの 唯一の欠点ですこれが必要なのは、.seen.story 要素が上部に配置され、非表示であってもタップを受け取るためです。pointer-eventsnone に設定すると、ガラス ストーリーがウィンドウになり、ユーザー インタラクションがなくなることはありません。CSS での管理も難しくないでしょうz-index をジャグリングしていません。まだ元気ですよ。

JavaScript

ストーリー コンポーネントの操作はユーザーにとって非常にシンプルです。右側をタップすると先へ進み、左側をタップすると戻ることができます。ユーザーにとってはシンプルなことが デベロッパーにとっては大変な作業ですただし、多くのことは Google が処理します。

設定

まず、できるだけ多くの情報を計算して保存しましょう。app/js/index.js に次のコードを追加します。

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

JavaScript の 1 行目は、主要な HTML 要素ルートへの参照を取得して保存します。次の行で要素の中央が計算されるので、タップが前進か後退かを判断できます。

状態

次に、ロジックに関連する状態を持つ小さなオブジェクトを作成します。この例では、現在のストーリーにのみ関心があります。この HTML マークアップでは、1 人目の友人とその最新の記事を取得してアクセスできます。ハイライト表示されたコードを app/js/index.js に追加します。

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

リスナー

これで、ユーザー イベントをリッスンして指示するのに十分なロジックができました。

ネズミ

まず、ストーリー コンテナで 'click' イベントをリッスンします。ハイライト表示されたコードを app/js/index.js に追加します。

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

<article> 要素以外でクリックが発生した場合は、そのまま何もしません。 記事の場合は、clientX でマウスまたは指の水平位置を取得します。navigateStories はまだ実装していませんが、受け取る引数が進む方向を指定します。ユーザー位置が中央値より大きい場合は next に移動し、それ以外の場合は prev(前)に移動する必要があることがわかります。

キーボード

それでは、キーボードの押下をリッスンしましょう。下矢印が押された場合は、next に移動します。上矢印の場合は、prev に移動します。

ハイライト表示されたコードを app/js/index.js に追加します。

const stories = document.querySelector('.stories')
const median = stories.offsetLeft + (stories.clientWidth / 2)

const state = {
  current_story: stories.firstElementChild.lastElementChild
}

stories.addEventListener('click', e => {
  if (e.target.nodeName !== 'ARTICLE')
    return

  navigateStories(
    e.clientX > median
      ? 'next'
      : 'prev')
})

document.addEventListener('keydown', ({key}) => {
  if (key !== 'ArrowDown' || key !== 'ArrowUp')
    navigateStories(
      key === 'ArrowDown'
        ? 'next'
        : 'prev')
})

ストーリーのナビゲーション

今度は、ストーリーの独自のビジネス ロジックと、そのストーリーが有名になった UX に取り組む時間です。かなり複雑に見えますが 1 行ずつ見れば理解しやすいでしょう

最初に、友人にスクロールするか、ストーリーを表示/非表示にするかを判断するためのセレクタが隠されています。HTML が作業を行う場所であるため、友だち(ユーザー)またはストーリー(ストーリー)の存在の有無をクエリします。

これらの変数により、「あるストーリー x について、「次」とは、この同じ友人から別のストーリーに移ることを意味するのか、別の友人に移ることを意味するのか、といった疑問を解決できます。構築したツリー構造を使って 親とその子にリーチしました

app/js/index.js の最後に次のコードを追加します。

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling
}

自然言語にできるだけ近いビジネス ロジックの目標は次のとおりです。

  • タップの処理方法を指定します。
    • 次または前のストーリーがある場合: そのストーリーを表示します。
    • 友人の最後または最初のお話の場合: 新しい友人を見せます。
    • その方向に進む記事がない場合: 何もしない
  • 新しい現在のストーリーを state に保存

ハイライト表示されたコードを navigateStories 関数に追加します。

const navigateStories = direction => {
  const story = state.current_story
  const lastItemInUserStory = story.parentNode.firstElementChild
  const firstItemInUserStory = story.parentNode.lastElementChild
  const hasNextUserStory = story.parentElement.nextElementSibling
  const hasPrevUserStory = story.parentElement.previousElementSibling

  if (direction === 'next') {
    if (lastItemInUserStory === story && !hasNextUserStory)
      return
    else if (lastItemInUserStory === story && hasNextUserStory) {
      state.current_story = story.parentElement.nextElementSibling.lastElementChild
      story.parentElement.nextElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.classList.add('seen')
      state.current_story = story.previousElementSibling
    }
  }
  else if(direction === 'prev') {
    if (firstItemInUserStory === story && !hasPrevUserStory)
      return
    else if (firstItemInUserStory === story && hasPrevUserStory) {
      state.current_story = story.parentElement.previousElementSibling.firstElementChild
      story.parentElement.previousElementSibling.scrollIntoView({
        behavior: 'smooth'
      })
    }
    else {
      story.nextElementSibling.classList.remove('seen')
      state.current_story = story.nextElementSibling
    }
  }
}

試してみる

  • サイトをプレビューするには、[アプリを表示] を押してから、全画面表示 全画面表示 を押します。

まとめ

以上で、このコンポーネントに必要だった機能の説明は終わりです。それを基に自由に構築し、データで動かして、通常は自分用にカスタマイズしてください。