前回は、PWAの基礎知識について説明しました。今回は実際に制作してみたPWAに触れてもらいつつ、その内側・技術的側面について解説していきたいと思います。
2013年にiOSネイティブ版をお披露目したLluminoは、「計算をもっと楽しく」がコンセプトの電卓アプリ。当時は世界中で話題になりました。(もう5年前…!) 当時の制作過程については連載としてまとめていますので、興味がありましたらどうぞ。(👉 連載:Lluminoができるまで)
今回のチャレンジは、そんなLluminoを最新のWeb技術で再現・PWA化してみようというものです。
制作したWebアプリは https://pwa.llumino.app/ に配置しました。まずは実物を触って体感してみてください! (昨日解禁になったばかりの「.app」ドメインだぞ!!)
Lluminoのコンセプトを継承して、触って楽しくなるエフェクトを再現しました。テーマ機能も搭載し、好みの配色に切り替えられます。
iOS/Androidをお持ちの方は、ホーム画面に追加してみてください。また違った体験が得られるはず。
触り心地はいかがでしょうか?ユーザー視点の考察はいったん置いておいて、今回は技術的な側面に着目してみます。
今回のテーマは、「PWAは本当にネイティブアプリを置き換えるものではないのか?」を確かめること。先日の記事でも触れたPWAの位置づけから考えると、結論は「置き換えるものではない!」ですが、やってみないと何も言えませんよね。
そんな攻めのテーマですので、Webページというよりもアプリに近い操作感を目指してみました。ホーム画面に追加することを前提に、コンテンツは基本的に一画面に収め、スクロールするのは画面の一部のみ。
**ソースコードはすべて公開しちゃいます!!**ドン!!MITライセンスです。
全体構成は Flow + React + Redux で、最近では割と一般的なものではないでしょうか。
触り心地を大切にしたいLluminoですが、体験のコアとなるディスプレイ・ボタンのエフェクトはCSS animation/transitionのみで実現されています。古めの端末ではやや動作がもたつくかもしれませんが、少なくとも最近のデバイスにおいては、この程度のエフェクトであれば十分動いてくれるようです。パワフルな時代になったもんだ…。
本気でパフォーマンスを追求するなら、WebGLをフル活用するなどしてより低レイヤーの描画処理を組むことになるでしょう。
「Service Worker」は、Webページとは別にバックグラウンドで実行することのできるスクリプト。 navigator.serviceWorker.register
で外部のJavaScriptファイルを登録できます。
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js').then(function(reg) {
}).catch(function(err) {
console.error('Registration failed:', error);
});
}
</script>
https://github.com/cocopon/llumino-pwa/blob/master/public/index.html
Lluminoでは、Service Workerの登録処理はHTMLに直書きしています。続いて、こちらがService Workerとして登録するスクリプトの一部。
self.addEventListener('install', (ev) => {
// キャッシュを追加
ev.waitUntil(updateCache());
});
self.addEventListener('fetch', (ev) => {
// キャッシュがあればそれを返し、なければ新たに取得
ev.respondWith(
caches.open(CACHE_NAME).then((cache) => {
return cache.match(ev.request).then((res) => {
return res || fetch(ev.request);
});
})
);
});
https://github.com/cocopon/llumino-pwa/blob/master/src/js/service-worker.js
Service Workerは、状況に応じて様々なイベントが発生します。 install
は初回登録のとき、 fetch
は何らかのリクエストが発生したとき。
初回登録時にHTML/CSS/JSなどすべてのリソースをキャッシュし、以後のリクエストではそのキャッシュを返すようにすれば、オフライン環境でも動作するようになる…といった仕組みです。
データの保存には「Local Storage」を利用します。今回はデータフローの設計にReduxを採用しているので、「Redux Persist」を利用して、アプリ全体の状態を司るStateを適宜Local Storageに保存・起動時に復元してあげればOKです。
https://github.com/cocopon/llumino-pwa/blob/master/src/js/store.js
ネイティブアプリの操作感に近づけるためには、画面に対していくつか設定が必要になります。画面幅をデバイス幅に合わせたり、拡大縮小を禁止したり。これらはHTMLのmetaタグで設定できます。
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover">
https://github.com/cocopon/llumino-pwa/blob/master/public/index.html
拡大縮小の禁止は minimum-scale
および maximun-scale
を等倍の 1
に、また user-scalable
を no
にすることで実現できます。が、これらを設定するには相応の覚悟が必要です。
というのも、通常のWebページでは多少文字や画像が小さくとも、ユーザーのピンチ操作で拡大表示することができます。「ユーザーが拡大操作する自由」をわざわざ奪うということは、そのぶん等倍での閲覧性・操作性をしっかり確保しなければならないということ。そうでなければ、単に使いづらいだけのアプリになってしまいます。
iOSのSafariでホームアイコンに追加する場合、アイコンとタイトルはHTMLのmetaタグ・linkタグで設定します。
<meta name="apple-mobile-web-app-title" content="Llumino PWA">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="assets/img/icon/ios/180x180.png">
<link rel="apple-touch-startup-image" href="assets/img/launch.png">
https://github.com/cocopon/llumino-pwa/blob/master/public/index.html
アプリのアイコンが並んでいるホーム画面は、ユーザーのファッション的な側面を持ち合わせています。アイコンのデザインを気にするユーザーも多く、何も設定していなかったり、設定されていてもダサいアイコンだったりするだけで、ユーザーの愛着はぐっと下がってしまうでしょう。気合を入れて準備しておきましょう。
ちなみに、iOSは勝手にアイコンを角丸に切り抜いてくれるので、切り抜き前の四角い画像を用意します。
他にも、起動時の画像(いわゆるlaunch image)を設定する apple-touch-startup-image
や、ブラウザーのナビゲーションやツールバーを隠す apple-mobile-app-capable
の設定も忘れずに。
このあたりは、Appleの公式資料「Configuring Web Applications」に書かれています。…が、最終更新が2016年と久しく更新されておらず、Appleのやる気のなさが伝わってくるようです。
一方Androidは、「Web App Manifest」というファイルによって、ホーム画面に追加するアプリの情報を定義します。HTMLのlinkタグでマニフェストへのパスを指定します。
<link rel="manifest" href="manifest.json">
https://github.com/cocopon/llumino-pwa/blob/master/public/index.html
マニフェストはJSON形式で、背景色やテーマカラー、アプリのアイコンなどの情報を指定します。
{
"background_color": "#000000",
"display": "standalone",
"icons": [{
"sizes": "180x180",
"src": "assets/img/icon/android/180x180.png",
"type": "image/png"
}, {
"sizes": "192x192",
"src": "assets/img/icon/android/192x192.png",
"type": "image/png"
}, {
"sizes": "512x512",
"src": "assets/img/icon/android/512x512.png",
"type": "image/png"
}],
"name": "Llumino PWA",
"short_name": "Llumino",
"start_url": "./",
"theme_color": "#000000"
}
https://github.com/cocopon/llumino-pwa/blob/master/public/manifest.json
起動時の画像は、上記の色やアイコン、タイトルから自動的に生成され、現時点では変更することができません。(ちょっとダサくて悲しい…)
ちなみに、AndroidのアイコンはiOSと異なり、OS側で切り抜きしてくれないどころか、必須であるはずのドロップシャドウすら追加してくれないので注意してください。すべて自前で加工して用意する必要があるということですね。
サイズやスタイルについては、Googleがガイドを用意してくれているので、これを参照しながらOSに馴染んだアイコンを準備しましょう。
Webアプリでやってしまいがちな悲しいこととして、タップ長押しでブラウザー標準の文字選択UIが出てしまうというのがあります。
これはCSSで抑制できますので、忘れずに指定しておきましょう。テキスト入力系まで効かせてしまうと、文字が入力できなくなってしまうので :not
で除外します。
:not(input, textarea) {
-webkit-user-select: none;
}
https://github.com/cocopon/llumino-pwa/blob/master/src/sass/common/_base.scss
せっかくネイティブアプリの代替を目指して仕上げたので、製作者としてはアプリのようにホーム画面に追加してフルパワーを発揮してもらいたいもの。
AndroidのChromeでは、一定の条件を満たすと自動的にバナーを表示してくれます。
詳細な条件はGoogleの公式資料「ウェブアプリのインストール バナー」を参照しましよう。
一方iOSについては特に用意されておらず、類似の仕組みを自前で準備する必要があります。今回Lluminoには載せていませんが、やるならばUser agentをみてiOSであることを確認後、 navigator.standalone
のフラグを見てまだホーム画面に登録されていないことを確認、適宜メッセージを出す…といった感じでしょうか。
iOSでは、デフォルトでタップ時のハイライト(黒くて薄い角丸のマスク)が付加されます。
ユーザー操作に対するインタラクションがあること自体はよいことですが、このままだとWebっぽいというか、きちんとUIに調和するよう制御してあげたいところです。まずは -webkit-tap-highlight-color
で透明色を指定して無効化してあげます。
button {
-webkit-tap-highlight-color: transparent;
}
https://github.com/cocopon/llumino-pwa/blob/master/src/sass/common/_reset.scss
このままではユーザー操作に対する反応が皆無になり、押したかどうかわかりづらく気持ち悪さを感じてしまうので、 :active
で調和した外観を定義しましょう。
.common-display:active {
background-color: rgba(white, 0.05);
}
この際気をつけなければならないのは、iOSでは :active
のつく要素に ontouchstart
ハンドラーを追加しないとアクティブ時のスタイルの指定が効かないということ。この挙動に対するAppleの公式資料は見あたらず本来の意図を知る術はありませんが、そういうものとして対処していくしかなさそうです。タップ時に実行したい処理が特にない場合は、単に空の関数 () => {}
を設定するのでも大丈夫です。
通常、Webページのコンテンツは縦横に伸び、画面には収まりきらないのでスクロールして閲覧します。この挙動はアプリらしくみせるには邪魔なので、 position: fixed;
で固定しつつサイズも指定して抑制します。
html, body {
height: 100%;
left: 0;
overflow: hidden;
position: fixed;
top: 0;
width: 100%;
}
https://github.com/cocopon/llumino-pwa/blob/master/src/sass/common/_base.scss
とはいえ、Webアプリ内ではリストコンポーネントなどスクロールさせたいコンテンツも出てくるので、その場合は overflow-scrolling: touch;
を設定しましょう。ネイティブアプリのような挙動でスクロールしてくれるようになります。
.common-tab_pageLayout {
-webkit-overflow-scrolling: touch;
overflow-scrolling: touch;
}
Androidにおいて、タップのハンドリングに click
を使っていると、わずかに :active
の反映速度が遅れ(100〜200ms程度?)気持ち悪く感じます。 touchstart
/ touchend
を使うと遅れなく反応してくれるので、電卓のキーパッドなど反応速度が求められるシビアな場面では後者を使っていきましょう。
さらに、moveを伴うタッチイベントが無視されてしまうという現象にも遭遇しました。こちらも touchstart
/ touchend
でハンドリングすることで改善できます。
https://github.com/cocopon/llumino-pwa/blob/master/src/js/view/calc/button-grid.jsx
Lluminoには、ここに書ききれない細かなチューニングがまだまだたくさん詰まっています。詳細は実際のコードや、GitHubのissueあたりを見ていただくのがよいでしょう。とにかく、Webアプリをネイティブの挙動に近づけるには多くの労力と工夫が必要になり、完全に補えるものではない、ということを頭の片隅に置いておくとよいと思います。
…といった感じで、「PWAは本当にネイティブアプリを置き換えるものではないのか?」の検証をテーマに、拙作の光る電卓Lluminoを再現してみました。ある程度作り込んだものに触れてみることで、どんな手触りになるかを実感いただけたかと思います。「意外といけるじゃん?」という意見も「やっぱりネイティブには及ばないな?」という意見もあるでしょう。
次回はこの制作を通して得られた知見と、PWAの現状や可能性について触れていきたいと思います。
まだ盛り上がりはじめたばかり技術なので、みんな手探りです。比較的信頼性の高い公式資料を中心に参照していくのがよいと思います。