この記事はヤプリ #1 Advent Calendar 2022 の19日目の投稿になります。
フロントエンドエンジニアのこん(@k0n_karin)です!今回は、Vueを使ってWebストーリーっぽいコンポーネントを実装してみましたので、その簡単な解説記事です。
Webストーリーとは
ウェブストーリーは、動画、音声、画像、アニメーション、テキストを融合して 動的な消費体験を創造する、一般的な「ストーリー」形式のウェブ バージョンです。
もしかしたらInstagramのストーリーズのほうがご存じの方が多いかもしれませんね。
作った経緯
ヤプリではアプリの一部でWebViewを使うシーンがあります。WebViewのメリットは、なんと言ってもネイティブアプリ内でWebの技術が使えることです。iOS/Androidそれぞれの開発が不要になるので、機能を小さく開発しスピーディに仮説検証できます。
そのためWebViewを使って、フロントエンドの力でアプリ体験の向上に一助できないか検討していました。
ちょうど先日メディアファインダーの動画対応がリリースされたこともあり、動画を使った新しい取り組みができないか、ということで白羽の矢が立ったのがWebストーリーでした。
WebストーリーはGoogleのAMP frameworkで利用できます。Nuxt.js 2.xだとcommunityがサポートしているのですが、Vue単体でのAMPの扱いがわからず、OSSもいい感じのものが見当たらなかったので、なら勉強も兼ねてイチから作ってみよう!ということで作ってみました。
(ブログ執筆後、公開する前に改めてAMP frameworkを調べていたところ、そういえばAMP frameworkの実態はWeb Componentsだよなと思い、Vueの公式ドキュメントを見たらなんとご丁寧にWeb Componentsの使い方が書いてありました。これで無事にVueでもAMP frameworkを動かすことができてしまいました。嬉しいのやら悲しいのやら・・・。)
できたもの
作る過程
準備
今回は以下の要件を満たせる最低限のコードを書いていきます。
- WebストーリーのUIの一部再現
- モバイル向けの表示
では早速Vueのプロジェクトをセットアップします。気づけばVue3も最初からViteになっていました。
npm init vue@latest web-stories cd web-stories npm i npm run dev
コンポーネント設計
まずコンポーネント設計ですが、ストーリーを表示するページと1つのストーリーでコンポーネントを分ける方針で行きます。
- StoryView.vue
- StoryPlayer.vue
StoryView.vueで表示したい動画や画像のURLなどの情報を持たせ、それをStoryPlayer.vueに渡すような形でpropsを定義してあげます。
StoryView.vue
<script setup lang="ts"> import StoryPlayer from "@/components/StoryPlayer.vue"; const stories = [ { id: 1, src: "cat.mp4", transitionUrl: "https://www.google.com/", }, { id: 2, src: "cat.jpg", transitionUrl: "", }, ]; </script> <template> <main class="StoryView"> <div v-for="(story, index) in stories" :key="story.id"> <StoryPlayer :src="story.src" :transition-url="story.transitionUrl" :index="index" /> </div> </main> </template> <style scoped> .StoryView { width: 100vw; height: 100vh; } </style>
StoryPlayer.vue
<script setup lang="ts"> defineProps<{ src: string; destination: string; index: number; }>(); </script> <template> <div></div> </template>
ストーリーの表示
さて、ストーリーのUIに必要な要素はざっと以下の3つです。
- 動画or画像
- 画面上部のインジケーター
- 画面下部のボタン(遷移先のリンクがある場合)
まずは動画と画像を出し分けて表示するところを実装します。これは拡張子から判別します。
また、<video>
は自動再生したいのでmuted
とplaysinline
を付与します。これをしないと自動再生ができなかったり、フルスクリーン再生されてしまいます。
そして、ストーリーを端末いっぱいに表示したいので、画像も動画も、縦長も横長もobject-fit: contain
にします。
StoryPlayer.vue
<script setup lang="ts"> const props = defineProps<{ src: string; transitionUrl: string; index: number; }>(); const isImg = computed(() => { const extention = props.src.split('.').at(-1) const imgExtensions = ['jpg', 'jpeg', 'png', 'webp'] return imgExtensions.includes(`${extention}`) }) const isVideo = computed(() => { // videoも同様に判別 }) </script> <template> <img v-if="isImg" :src="src" class="StoryPlayer" /> <video v-if="isVideo" :src="src" class="StoryPlayer" muted playsinline ></video> </template> <style scoped> .StoryPlayer { position: absolute; height: 100%; width: 100%; object-fit: contain; } </style>
次にヘッダーのインジケーターです。これはストーリーの数に応じて設置します。
再生中or再生済みのストーリーのインジケーターを白塗りにするので、 --played
のクラスをmodifierとして付与します。再生済みの判断はv-for
のindex
とcurrentIndex
を比較すればOKです。
StoryView.vue
<script setup lang="ts"> import { ref } from "vue"; import StoryPlayer from "@/components/StoryPlayer.vue"; const stories = [/* 略 */] const currentIndex = ref(0); </script> <template> <main class="StoryView"> <div class="StoryView__header"> <div v-for="(_, index) in stories" class="StoryView__headerLine" :class="{ 'StoryView__headerLine--played': index <= currentIndex, }" ></div> </div> <!-- 略 --> </main> </template> <style scoped> .StoryView__header { display: flex; gap: 5px; padding: 10px 5px; position: absolute; width: 100%; z-index: 10; } .StoryView__headerLine { flex-grow: 1; border-bottom: 2px solid #666; } .StoryView__headerLine--played { border-bottom-color: #fff; } </style>
最後にストーリーの上に浮かせるボタンですね。タップしたらtransitionUrl
に遷移させます。また、transitionUrl
がある場合のみ表示するのでこのようになります。
StoryPlayer.vue
<script setup lang="ts"> /* 略 */ const transition = (transitionUrl: string) => { location.href = transitionUrl; }; </script> <template> <!-- 略 --> <button v-if="transitionUrl !== ''" class="StoryPlayerLink" @click="transition(transitionUrl)" > 詳しくはこちら </button> </template> <style scoped> .StoryPlayerLink { position: absolute; bottom: 30px; left: 0; right: 0; width: 150px; height: 40px; background-color: #eee; color: #666; font-size: 16px; font-weight: bold; border-radius: 30px; margin: auto; padding: 0 12px; } </style>
ストーリーのコントロール
表示の準備ができたので、次は動画と画像をコントロールします。主にやることは3つです。
- ストーリーの自動再生
- タップでストーリーの送り戻し
- ロングタップ時のコントロール
では自動再生から順番にやっていきます。
画像の場合、自動再生の概念はないので現時点では対応不要です。次項の送り戻しで再生の制御を実装します。
動画の場合、自動再生は<video>
のautoplay
で可能ですが、全ての動画が自動再生されると困るので、再生するストーリーを決めます。
先程StoryViewで定義したcurrentIndex
とv-for
のindex
を比較して、v-show
で再生したいストーリーだけを表示します。
また、StoryPlayer側にも表示状態をisVisible
としてprops
で渡します。
StoryPlayer.vue
<div v-for="(story, index) in stories" v-show="index === currentIndex" :key="story.id" class="StoryView__container" > <StoryPlayer :src="story.src" :transition-url="story.transitionUrl" :index="index" :is-visible="index === currentIndex" /> </div>
isVisible
の状態を元に、StoryPlayer側で再生の制御をします。
今回は使ったことがなかったので、Vueのカスタムディレクティブを試してみます。<script setup>
のカスタムディレクティブは、接頭辞にv
をつける必要があります。
props.isVisble
をcomputed
にしてバインドし、true
のときは動画を再生、false
のときは動画を停止し0秒に戻します。
StoryPlayer.vue
<video v-video-control="isVisible">
const isVisible = computed(() => { return props.isVisible }) const vVideoControl: DirectiveHook<HTMLVideoElement, null, boolean> = ( el, binding ) => { if (binding.value) { el.play(); } else { el.pause() el.currentTime = 0 } };
では次にストーリーの送り戻しを実装していきます。
ストーリーは画面の左半分と右半分をタップすると、前後のストーリーを再生できます。左:右の割合がInstagramだと1:9、AMPのWebストーリーだと2:8くらいでした。今回は後者の2:8くらいで行こうと思います。
<button>
を使ってストーリーをタップできるようにします。
StoryPlayer.vue
<button class="StoryPlayerPrev" ></button> <button class="StoryPlayerNext" ></button>
.StoryPlayerPrev { position: absolute; left: 0; height: 100%; width: 20%; } .StoryPlayerNext { position: absolute; right: 0; height: 100%; width: 80%; }
続いて、それぞれのボタンをタップ時に前後のストーリーを再生するようにしましょう。
ここでは、StoryPlayer内でtouchend
イベントを購読し、emit
を用いてStoryView側で制御します。タッチデバイス以外でも動作するようにmouseup
も購読しておきましょう。
touchend
は.prevent
をしないとmouseup
イベントも発火させてしまうので、お忘れなきよう。
https://www.w3.org/TR/touch-events/#list-of-touchevent-types
StoryPlayer.vue
const emit = defineEmits<{ (e: "prev" | "next"): void; }>(); const handlePressEnd = (key: "prev" | "next") => { emit(key) }
<button class="StoryPlayerPrev" @touchend.prevent="handlePressEnd('prev')" @mouseup="handlePressEnd('prev')" ></button> <button class="StoryPlayerNext" @touchend.prevent="handlePressEnd('next')" @mouseup="handlePressEnd('next')" ></button>
StoryView.vue
const nextStory = () => { if (currentIndex.value < stories.length - 1) { currentIndex.value++; } else { currentIndex.value = 0; } }; const prevStory = () => { if (currentIndex.value !== 0) { currentIndex.value--; } };
<StoryPlayer :src="story.src" :transition-url="story.transitionUrl" :index="index" :is-visible="index === currentIndex" @prev="prevStory()" @next="nextStory()" />
これで前後のストーリーを再生できるようになりました。
ところが、動画の再生が終わった後に次のストーリーが再生されなかったり、そもそも画像は再生時間がなかったりするので、それぞれ実装していきます。
動画は再生が終わったらemit("next")
します。<video>
は再生が終了するとended
イベントが発火されるので、それを購読すればOKですね。
StoryPlayer.vue
// script const handleVideoEnded = () => { emit("next"); }; // template <video @ended="handleVideoEnded"></video>
画像の場合は、再生時間を決めた上で、再生時間のタイマーが必要になります。
画像は5秒間表示するようにしてみます。画像の表示フラグのisVisible
をwatch()
して、setInterval()
で100msごとにインクリメントしていきましょう。
また、カウンターもwatch()
し、5秒を超えたらemit("next")
して、カウンターとsetInterval()
をリセットします。
StoryPlayer.vue
const INTERVAL_MS = 100; const IMAGE_DISPLAY_MS = 5000; const imageDisplayTimerId = ref<NodeJS.Timeout>(); const imageDisplayCounter = ref(0); watch( isVisible, (newVal) => { if (isImg.value && newVal) { imageDisplayTimerId.value = setInterval(() => { imageDisplayCounter.value += INTERVAL_MS; }, INTERVAL_MS); } }, // 初期描画時もwatchします { immediate: true, } ); watch(imageDisplayCounter, (newVal) => { if (newVal >= IMAGE_DISPLAY_MS) { emit("next"); imageDisplayCounter.value = 0; if (imageDisplayTimerId.value !== undefined) { clearInterval(imageDisplayTimerId.value); imageDisplayTimerId.value = undefined; } } });
最後にロングタップの制御をします。ストーリーをロングタップすると、ストーリーが一時停止されます。これも動画と画像でそれぞれ処理が異なってきます。個人的に、この実装が一番大変でした。
タップ開始でストーリーを一時停止し、そこから2パターンに分岐します。
- タップ時間がしきい値以下なら、ストーリーを送り戻し
- タップ時間がしきい値以上なら、ストーリーを再開
では実装してみましょう。まずロングタップのタイマーから実装します。タイマーには3つの要素が必要になります。
- タップ時間のカウンター
- タップ中のフラグ
- ロングタップを判定するしきい値
タップ開始のイベントはtouchstart
です。タップ中のフラグをtrue
にし、カウンターをsetInterval()
でインクリメントします。
ここで一時停止するために、動画の場合はtemplate参照を使って.pause()
します。
画像の場合はimageDisplayTimerId
のタイマー内で、タップ中のフラグがtrue
のときはカウンターをインクリメントしないようにします。
StoryPlayer.vue
<video ref="videoRef"></video>
const videoRef = ref<HTMLVideoElement | null>(null); const pressingTimerId = ref<NodeJS.Timeout>(); const pressingCounter = ref(0); const isPressing = ref(false); const PRESSING_THRESHOLD = 200; const handlePressStart = () => { isPressing.value = true; if (isVideo.value) { videoRef.value?.pause(); } pressingTimerId.value = setInterval(() => { pressingCounter.value += INTERVAL_MS; }, INTERVAL_MS); }; watch( isVisible, (newVal) => { if (isImg.value && newVal) { imageDisplayTimerId.value = setInterval(() => { // タップ時はカウントしない if (isPressing.value) { return; } imageDisplayCounter.value += INTERVAL_MS; }, INTERVAL_MS); } }, { immediate: true, } );
タップ終了時の処理は、送り戻しを実装した際のhandlePressEnd()
を拡張していきます。
まずタップ中のフラグをfalse
にし、タイマーとカウンターをリセットします。
そして動画の場合、タップ時間がしきい値以下ならemit
し、しきい値以上ならストーリーを再開します。
画像の場合、タップ時間がしきい値以下なら画像のタイマーをリセットしてemit
し、しきい値以上なら何もしません。
StoryPlayer.vue
const handlePressEnd = (key: "prev" | "next") => { if (isVideo.value) { if (pressingCounter.value < PRESSING_THRESHOLD) { emit(key); } else { videoRef.value?.play(); } } if (isImg.value) { if (pressingCounter.value < PRESSING_THRESHOLD) { // 送り戻しの場合は画像のタイマーをリセット imageDisplayCounter.value = 0; if (imageDisplayTimerId.value !== undefined) { clearInterval(imageDisplayTimerId.value); imageDisplayTimerId.value = undefined; } emit(key); } } isPressing.value = false; pressingCounter.value = 0; clearInterval(pressingTimerId.value); pressingTimerId.value = undefined; };
<button class="StoryPlayerPrev" @touchstart.prevent="handlePressStart()" @touchend.prevent="handlePressEnd('prev')" @mousedown="handlePressStart()" @mouseup="handlePressEnd('prev')" ></button> <button class="StoryPlayerNext" @touchstart.prevent="handlePressStart()" @touchend.prevent="handlePressEnd('next')" @mousedown="handlePressStart()" @mouseup="handlePressEnd('next')" ></button>
ここまでで、動画と画像をストーリーとして再生することができるようになりました🥳
これ以降もやることは山積みで、動画や画像の読み込み最適化、ストーリー上のテキスト表示、インジケーターのプログレスバー化などなど、盛りだくさんです。
おわりに
結果的に自作したコンポーネントは使わずとも、冒頭の通りVueでAMP frameworkが使えることがわかりましたが、動画の再生周りやTouchEventについての知識が深まったのでかなり勉強になりました。(次はVueとAMPの記事を書こうかな・・・。
解説記事を書くにあたって、作ったものをわかりやすくリファクタリングしたり、改めて調べ直して知識の再整理する機会になったので、非常に有意義なアウトプットになったと実感しています。
ヤプリではフロントエンドに限らずこのような取り組みやアウトプットを積極的に奨励しています! もしご興味があれば、ぜひ一度カジュアルにお話ししてみませんか?