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-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 セクションに、これらの子ストーリー要素を配置するレイアウトを作成しましょう。この問題を解決するには、便利なグルーピング トリックを使用します。基本的に、行と列に同じ 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-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 マークアップでは、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 であるため、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
    }
  }
}

試してみる

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

まとめ

コンポーネントに関する要件は以上です。自由に拡張したり、データで活用したり、自由にカスタマイズしてください。