この Codelab では、ウェブで Instagram ストーリーなどのエクスペリエンスを構築する方法について説明します。まずは HTML、CSS、JavaScript の順でコンポーネントを構築していきます。
このコンポーネントの作成中に行われる段階的な機能強化については、ストーリー コンポーネントの構築に関するブログ投稿をご覧ください。
設定
- [Remix to Edit] をクリックしてプロジェクトを編集可能にします。
app/index.html
を開きます。
HTML
私は常にセマンティック HTML を使用することを目指しています。各友達はストーリーをいくつでも持てるので、<section>
要素を各友達に、<article>
要素をストーリーごとに使用することに意味があると思っています。では最初から始めましょう。まず ストーリーコンポーネントの
コンテナが必要です
<div>
要素を <body>
に追加します。
<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>
- Google では、画像サービス(
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
セクションに、これらの子ストーリー要素を所定の位置にラングリングするレイアウトを作成しましょう。この問題を解決するために、便利な積み重ねテクニックを使います。基本的には、行と列に同じグリッド エイリアス [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 つの背景画像を定義すると、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;
}
}
Exit が必要なストーリーに .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 の 1 行目は、主要な 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
}
}
}
試してみる
- サイトをプレビューするには、[アプリを表示] を押してから、全画面表示 を押します。
まとめ
以上で、このコンポーネントに必要だった機能の説明は終わりです。それを基に自由に構築し、データで動かして、通常は自分用にカスタマイズしてください。