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

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

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

セットアップ

  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 にグリッド列のオーバーフローが表示され、水平スクローラーが作成されます。

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-x を auto に設定します。ユーザーがスクロールしたときに、コンポーネントが次のストーリーに緩やかに移動するように、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 セクションに、子ストーリー要素を適切な場所に配置するレイアウトを作成しましょう。これを解決するために、便利なスタッキング トリックを使用します。基本的には、行と列に同じ Grid エイリアス [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-size を cover に設定すると、画像がビューポートを埋め尽くすため、ビューポートに空きスペースがなくなります。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))は、マテリアル デザインのイージング ガイド([Accerlerated easing] セクションまでスクロール)から取得しました。

鋭い方なら、pointer-events: none 宣言に気づいて、今頃首をかしげているかもしれません。これまでのところ、このソリューションの唯一の欠点と言えます。.seen.story 要素は最前面に配置され、非表示であってもタップを受け取るため、この設定が必要です。pointer-events を none に設定することで、グラスストーリーをウィンドウに変換し、ユーザー インタラクションを奪わないようにします。トレードオフとしては悪くなく、現在の CSS での管理もそれほど難しくありません。z-index をジャグリングしているわけではありません。この点については 今でも自信があります

JavaScript

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

セットアップ

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

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

JavaScript の最初の行は、プライマリ 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
    }
  }
}

試してみる

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

まとめ

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