この Codelab では、Instagram のストーリー(ウェブ版)のようなエクスペリエンスを構築する方法について説明します。HTML、CSS、JavaScript の順にコンポーネントを作成していきます。
このコンポーネントの構築中に行われた段階的な機能強化については、ブログ投稿のストーリー コンポーネントの作成をご覧ください。
セットアップ
- [Remix to Edit] をクリックして、プロジェクトを編集可能にします。
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
幅の新しい列を前の列の右側に配置し続けます。
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
)に挿入し、その 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-size
を cover
に設定すると、画像がいっぱいになるため、ビューポートに空きスペースがなくなります。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-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 マークアップでは、最初の友だちとその最新のストーリーを取得することでアクセスできます。ハイライト表示されたコードを 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] を押します。
まとめ
コンポーネントに関する要件は以上です。自由に構築し、データで推進し、自由にカスタマイズしてください。