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

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

このコンポーネントの構築時に行われたプログレッシブ エンハンスメントについては、ブログ投稿「Building a Stories component」をご覧ください。

セットアップ

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

HTML

私は常にセマンティック HTML を使用するように心がけています。各友だちには任意の数のストーリーがあるため、友だちごとに <section> 要素を使用し、ストーリーごとに <article> 要素を使用するのが適切だと考えました。ただし、最初からやり直しましょう。まず、ストーリー コンポーネントのコンテナが必要です。

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

<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>
  • ストーリーのプロトタイプ作成を支援するために、画像サービス(picsum.com)を使用しています。
  • <article>style 属性は、プレースホルダ読み込み手法の一部です。これについては次のセクションで詳しく説明します。

CSS

コンテンツのスタイル設定の準備が整いました。これらの骨格を、ユーザーが操作したくなるようなものに変えていきましょう。本日はモバイル ファーストで作業します。

.stories

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

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

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

Chrome と DevTools が開き、全幅レイアウトを示すグリッド ビジュアルが表示されている
Chrome DevTools にグリッド列のオーバーフローが表示され、水平スクロールバーが表示されている。

次の CSS を app/css/index.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 つの背景画像を定義すると、読み込み中の墓石と呼ばれる 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;
  }
}

.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 の最初の行では、プライマリ HTML 要素のルートへの参照を取得して保存します。次の行では、要素の中央の位置を計算して、タップが前進か後退かを判断できるようにしています。

次に、ロジックに関連する状態を含む小さなオブジェクトを作成します。この場合、現在のストーリーのみに関心があります。HTML マークアップでは、最初の友だちとその最新のストーリーを取得することでアクセスできます。ハイライト表示されたコードを 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
    }
  }
}

試してみる

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

まとめ

コンポーネントに関する私のニーズは以上です。このコードをベースに、データを活用して、独自のコードを作成してください。