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)に挿入し、その URL を 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 つの背景画像を定義すると、Load 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;
  }
}

.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 であるため、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] 全画面表示 を押します。

まとめ

コンポーネントに関する要件は以上です。自由に構築し、データで推進し、自由にカスタマイズしてください。