bitbank techblog

Web フロントエンドだけでネイティブなモバイルアプリのように見せる Tips 集

こんにちは!リテールチームの kei です。

今回のテーマは「いかにもネイティブらしいスマホアプリを Web のフロントエンドだけで実装してみたのでそのノウハウをまとめてみる」です!

たまたま個人で開発していたアプリケーションのユースケースがほぼ 100% モバイルでのものだったため、ずっとやってみたかったことを盛り込むというのも兼ねてモバイル体験に振り切ったフロントエンド実装というのをやってみました。

ウェブの実装をネイティブアプリに適用するアプローチは多数存在していますが、今回メインで扱うことになる PWA はその中でもまだあまり浸透していないほうに分類されます。考えられる理由はいくつかありますが、私は今回「十分実用に耐えうるレベルである」という感覚を持てたので、利用するユーザーが限られていたり PoC 検討のようなシーンにおいては積極的に勧めていけたらと思っています。

記事末尾では「それでもネイティブアプリには届かないところ」もまとめていますので、ぜひ参考にしていただければ幸いです。

PWA もろもろ

まずは PWA です。

いくらウェブだけで実現するといってもさすがに毎度ブラウザにアクセスさせるわけにはいかないので、PWA を使用します。

PWA 自体はもう認知されて久しいので詳細な説明は省きますが、どういうよさがあるのかということについては MDN のこのページがよくまとまっているのでご紹介しておきます。

これによってユーザーは「インストール」と近い体験を得ることができ、スマートフォンのホーム画面に「アプリ」としてアイコンが並ぶことにもなります。OS のタスクスイッチャーにおいてもひとつのアプリとして扱われるようになるし、ウェブブラウザのアドレスバーなどの UI からも解放されます。

PWAのタスク切り替えの様子

つまり PWA をまず利用することこそ「単なるウェブページ」からの脱却の一歩です。このうえで、PWA のコンテキストでより映える素晴らしい機能(プッシュ通知!)を加え、細かい UI のインタラクションを調整してネイティブアプリに近づけていきます。

PWA 導入の大まかな手順は以下のようになります:

  1. manifest.json を書く
  2. ユーザーにウェブブラウザでアプリケーションにアクセスしてもらう
  3. ブラウザの「ホーム画面に追加」を実行してもらう

実際にはスプラッシュスクリーン用のアイコンや背景画像などのアセット類を同梱する必要があることに注意してください(スプラッシュスクリーンはなくても問題ありませんが、ネイティブアプリの体験にぐっと近づくための重要な要素のひとつなので必ず実装しましょう!)。

導入手順を紹介できたところで、PWA が持つ大きな弱点もあわせて説明しておきたいです。主に以下のようなポイントに集約されます:

  • インストールの過程でユーザーにかなり能動的なアクションを要求すること
    • しかも大半のユーザーはやったことのない操作なので説明が必要になる
  • アプリケーションが更新されても自動的に変更が反映されない

特に 2 つめが明確なデメリットになります。サービスワーカーを適切に実装すれば最新版を常に取得して自身をリロードすることも可能ではありますが、かなり複雑性が増すこと、そしてやりすぎるとせっかく PWA にした意義がどんどん失われていってしまう(常に表示を更新するのはウェブの文化に近づくことになる)ことから慎重な検討が必要です。

ここは「単なるウェブページ」から間違いなく劣後してしまっている点であり、PWA を導入するかの観点においてよく考えるべきポイントとなります。

しかし今回はここをクリアできているとして、引き続きウェブでネイティブアプリを実現する Tips を見ていきましょう!

プッシュ通知

PWA ではスマートフォン OS によるネイティブな通知機能がサポートされています。一般的なウェブページはブラウザに表示されているときのみその効力があると考えると、ユーザーがスマートフォンを触っていないときでもこちら側から何かアクションを起こせるということの偉大さがよく理解できますよね。

しかも他のアプリと同じインターフェースで通知バナーがロック画面などに並ぶわけで、これは「アプリっぽさ」を格段に上げる重要な要素です。ぜひやりましょう!

PWAのネイティブ通知がロック画面に並ぶ様子

準備しないといけないことはざっくり分けると以下のようになります:

  • サーバーからネイティブ通知を送信できるインターフェースを提供してくれる Firebase Cloud Messaging に登録する
  • サーバー側で Firebase Cloud Messaging の SDK を組み込んで実装する
  • フロントエンド側で Firebase Cloud Messaging を待ち受けるサービスワーカーを登録する

今回使用したサービスは Firebase のものでしたが、他にもいくつか選択肢はあるみたいです。詳しく調査しませんでしたが、もちろん Firebase Cloud Messaging も本番利用 OK で特に問題はなさそうかなという感想です。ウェブからテスト送信できる機能があるので開発時はこれがとても便利でした。

それと、ページを開いた瞬間に上記で必要な各手順をどういう順序で実行するのかは一考の余地があります。というのも、実際には下記のような細かい作業が全て必要になるのでどこを並列に実行してよいか、どれは二度目は不要か、などを設計する必要があるからです。

  • ユーザーに通知を許可するか尋ねる
  • サービスワーカーを登録する
  • 通知先デバイスを特定するためのトークンを取得、POST して保管する
  • メッセージ受信時のコールバックを定義する

上記をはじめとして特にフロントエンド側での細かいワナがたくさんあったので全部紹介したいなと思いつつ、量が多いのと本記事の趣旨からも脱線してしまうためここは割愛とさせていただきます!

自分の iPhone で、該当アプリを意図的にタスクキルしたり端末を再起動したりしてもちゃんと通知が届くのを確認できたときはとても感動しました。これぞウェブとモバイルの統合!というところでしょうか。

ページトランジション

モバイルアプリにあってウェブページにないものを考えたときにすぐ思いついたのですが、モバイルではページ移動するとき、左右方向にプッシュ/ポップされるようなページトランジション(アニメーション)が非常によく使われています。これは後述する「左右スワイプによる戻る/進むアクション」の話とも関連します。

0:00
/0:06

というわけで、まずページの階層構造を整理し下記の基本ルールを決めつつ Figma のプレビュー機能を使って実際の動作イメージを起こしていきました。

  • 基本ルール
    • 階層を「もぐる」方向に移動するときは右から左へシフトするアニメーションにする
    • 階層を「上がる」方向に移動するときは左から右へシフトするアニメーションにする
    • 階層に関連がないページへ移動するときはアニメーションはなしにする
      • 例えばグローバルボトムナビで違うアイテムへ移動するときなど

あとは使用しているフロントエンドフレームワークのページトランジション定義へうまく統合していければ OK です。

しかし、ここでかなりやっかいな問題に気がつきます。

スマートフォンのウェブブラウザや WebView が標準で持っている「画面両端から反対方向へスワイプすることで戻る/進むが可能な機能」と見事にバッティングしてしまうのです。これはもちろん PWA も該当します。

この機能はいわば操作自体がアニメーションを持っているのと同義ですから、スワイプによって画面が遷移したあと同じアニメーションがもう一度繰り返されるような挙動になってしまうのですよね。

これは絶対に防ぎたいので、つまり「a タグや JS でのページ遷移とウェブブラウザの機能によるページ遷移を区別する必要がある」ことになります。

このコードは少し愚直で美しくないものになってしまったのですが、概ね以下のようになります(サンプルは Angular です):

@Component({
  selector: "app-root",
  standalone: true,
  imports: [RouterOutlet, GlobalBottomNaviComponent],
  template: `
    <div [@routeAnimations]="prepareRoute(outlet)">
      <router-outlet #outlet="outlet" />
    </div>
    <c-global-bottom-navi />
  `,
  animations,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit {
  private location = inject(PlatformLocation)
  private navigationBackManager = inject(NavigationBackManager)

  // 本サンプルでは戻る方向のみの対応としています
  isPoped = false

  ngOnInit() {
    this.location.onPopState(() => {
      this.isPoped = true
    })
  }

  prepareRoute(outlet: RouterOutlet): string {
    if (this.isPoped && this.navigationBackManager.isManuallyBacked) {
      this.isPoped = false
      this.navigationBackManager.isManuallyBacked = false
      return outlet.activatedRouteData.animation
    } else if (this.isPoped) {
      this.isPoped = false
      return "none"
    } else {
      return outlet.activatedRouteData.animation
    }
  }
}
// 状態管理のためのサービス、ストアを作るほどではないのでサービスとしてみました

import { Injectable } from "@angular/core"

@Injectable({
  providedIn: "root",
})
export class NavigationBackManager {
  private _isManuallyBacked = false

  get isManuallyBacked(): boolean {
    return this._isManuallyBacked
  }

  set isManuallyBacked(v: boolean) {
    this._isManuallyBacked = v
  }
}
// AppComponent の animations に渡すアニメーション定義

export const animations = [
  trigger("routeAnimations", [
    transition("none => *", []),
    transition("backward => *", backward),
    transition("login => home", forward),
    transition("account => login", backward),
    // ...以下つづく
  ]),
]

これにより、例えばよく画面左上に配置される ボタンで戻った場合でもアニメーションは適用されるし、左スワイプで戻った場合にもスムーズな画面遷移が実現されます。ページがそこまで大量に存在しなければ十分にスケールする実装だとも思いますし、個人レベルでは十分そうです。

また、最近では View Transitions API という標準仕様のみでページトランジションを定義できるようにもなりました。これらがモバイルでどの程度「ネイティブっぽく」動くかはわかりませんが、次回はぜひ検討したいです。

View Transitions API については ICS さんの記事が非常に詳しいのでぜひご覧ください!

アニメーションをしっかり見せる

これはかなり細かいノウハウというかコツのようなものなのですが、普段 PC ウィンドウでしか開発をしていないと気づきにくいことだったので取り上げてみたいと思います。

やることは「アニメーションの transition-duration を少し余分に設けてみよう!」です。

PC では綺麗に動作するアニメーションがモバイルでも同様に動作するかというとそうではありません。言語化が難しいですが、例えばアニメーションのトリガーに少し時間を要した場合、動いた頃にはアニメーションの実行時間は既に経過していて結果的に何もアニメーションされず表示されてしまう、のようなことはしばしばあります。

長過ぎるアニメーションはユーザビリティ悪化の恐れがあるのでそもそも注意するべきですが、モバイルにおいてはその縛りを少し緩くしてもよいのでは、というのが本ポイントの趣旨となります。

これは通常のウェブサービスにおいてもすぐ実施できる施策なので、改めてご自分のプロジェクトを一通りスマートフォンで操作してみるといいかもしれません!

仮にデベロッパーツールを使ってモバイル用の表示を確認しながら開発していたとしても、それはその PC のマシンスペックで再現されている挙動に過ぎないということに注意しましょう。開発サーバーのネットワークモードを併用するなどして、普段から PC・モバイルを同時に確認できる環境を用意する必要がある…のかもしれませんね。

バウンススクロールをとめる

これも比較的小ネタです。

ウェブに限らずスマートフォンで何かのページを勢いよくスクロールすると、ページの上下端にぶつかったとき画面がバウンスする挙動がありますよね。

0:00
/0:04

これは当然ウェブページでも機能するものですが、ネイティブアプリでは基本的に発生しません(画面全体での話であって、例えばメインビュー内のリスト表示などでは起こり得ます)。

せっかく PWA として起動したのに、ふとした瞬間にこのような挙動が見えてしまうと急に安っぽさが露呈してしまうのですよね。ぜひ改善したいところです。

しかし対応方法はかなり泥臭いものになってしまい、場合によっては読みやすさやメンテナンス性を優先したいというケースも出てきそうです。やることは「スクロールに反応させたくない要素の touchmove イベントを塞ぐ」なのですが、当然スクロールさせたいエリアが内包されている場合は部分的に解除しないといけないのです。

document.addEventListener("touchmove", (event: Event) => {
  if (event.target as HTMLElement).classList.contains("want-to-scroll") {
    return
  }
  event.preventDefault()
}, { passive: false })

やはり細かいところで不安が残る形にはなり、このあたりがウェブにおける限界なのでは…と思うところでもありました。

ここ数年 CSS は続々と革新的な機能がリリースされているので、JS 方面からではなく CSS 方面からの解決も期待したいところです!

PWA の sessionStorage でちょうどよいローカル状態管理が実現できる

フロントエンド開発における状態管理はたいていウェブブラウザの利用が前提として据えられていて、これをそのまま PWA に持ち込むと少し UX の維持が難しいと感じることがありました。

具体的には、状態が保存される「きっかけ」と「レベル」の対応が少しずれているような感じです。

普段のウェブでの私たちの理解は概ね以下のようになると思います:

保存方法 生存期間
オンメモリ そのページ/タブが再読込されるまで
sessionStorage そのタブの生存期間と同一
localStorage 能動的に削除されるまで

では PWA ではどうなるかというと、実験した限りではこうなりました:

保存方法 生存期間
オンメモリ OS による自動スリープなどで消える
sessionStorage OS による自動スリープなどでは消えないが、タスクキルされると消える
localStorage 能動的に削除されるまで

最近の iOS や Android は、アプリを使用していないとそのリソースをバックグラウンドで自動的に抑制します。これによりオンメモリなデータは基本的にクリアされることになりますが、具体的な時間やルールなどはおそらく簡単に説明できるものではなく、非常に高度な最適化が行われているものと思われます。なのでここは特定の仕様を期待して実装してはいけない、つまり「いつ消えてもよいもののみをオンメモリの状態管理にする」のがよさそうです。

そして注目すべき sessionStorage ですが、これが必要十分でとても便利です。上記の自動的なリソース抑制では消されないが、ユーザーが明示的にリセットしたいアクション(=タスクキル)では消えるようになっており、基本的な状態管理はこれに合わせると色々マッチしそうでした。

状態管理ライブラリにはたいてい Storage API と統合するための機能が備わっているので、これを基本的に sessionStorage にしておくことで直感的な挙動になると思われます!

明示的な仕様ではないので長く安定する利用法ではないかもしれませんが、調べた限りこのような実験の情報はほとんど見つけられなかったため、なかなか重要な知見を得られたのではと思えています。

タップ時のエフェクトを消したい

タップ操作に対するユーザーへのフィードバックとして、モバイルではタップされた要素がハイライトされるようになっています。特に Android では青いエフェクトを見かけたことがある方も多いかもしれません。

これも非常にチープ感を演出してしまうので、デフォルトで無効化しておくのがおすすめです。-webkit-tap-highlight-color を透明にすることで実現できます。

* {
  -webkit-tap-highlight-color: transparent;
  user-select: none;
}

(ついでに画面を長押しされたときに範囲選択されてしまうのを防ぐために user-select: none; もグローバルにセットしておきましょう)

場合によってはタップのフィードバックが必要なケースもあると思いますが、そのときはこの機能を使うのではなく個別に :hover:active などを利用しつつ CSS を書くのがいいと思います(前者は PC での動作とは異なりタップ時に効果があることに留意してください)。

ちなみにこの -webkit-tap-highlight-color は CSS 標準外の仕様だそうですが、昔からありますしいきなり動作しなくなったりすることは基本的にないと考えてよさそうです。

あえて何もスタイリングしないデフォルトのフォーム要素を使う

一般的なウェブ開発において HTML 標準状態のままでフォーム要素を使うというのはほぼありえないことですが、ネイティブを目指すモバイルのみがターゲットの場合ではこれをあえて活用することができます。

例えば下記のような単純なマークアップ(と最低限のリセット CSS)によるセレクトボックスが iOS でどのようにレンダリングされるかを見てみましょう。

<select>
  <option value="Apple">🍎</option>
  <option value="Orange">🍊</option>
  <option value="Banana">🍌</option>
</select>
ネイティブフォーム要素のレンダリング結果

かなりいい感じです!

しかもこれを利用するにあたり、追加の実装やライブラリの導入コストなどもゼロというおまけつきです。もはや NativeSelectBox というコンポーネントにしてしまって再利用可能な形にすることさえ検討できるでしょう。ほかにも <input type="time" /> などがネイティブ感高めでおすすめです。

ただし、このアプローチはユーザーが使用しうる実機のパターンをよく確認したうえで採用するべきと思われます。特に Android はメーカーや機種によってレンダリング結果が変わる可能性が高いので要注意です(デフォルトブラウザが変更されていても WebView 部分はメーカー独自のもののままだったりすることはよくあります)。

ダークモード対応

ウェブページでもダークモードは珍しくなくなったので、ネイティブアプリを目指すならぜひとも対応しておきたいところ。

基本的にやることは変わりませんが、PWA のスプラッシュスクリーンにも手をいれるのを忘れないようにしましょう。起動した瞬間に全面に白い背景が表示されると非常に眩しいので…。

<!-- iOS の場合 -->
<link rel="apple-touch-startup-image" href="/assets/splash-screen-dark.png" media="(prefers-color-scheme: dark)">

それでもネイティブアプリに届かないところ

いろいろな工夫を紹介してきましたが、まだ解決できていないポイントもまとめておきます。

スワイプアクション

ウェブにはもともとタッチというインターフェースは存在しなかったため、スワイプやスライドなどのように複雑なタッチ操作が要求されるものにめっぽう弱いです。

ネイティブアプリでは表示されたものをサッと片付けたりするためにスワイプすることがよくありますが、これらの操作をウェブで再現するのはかなり難しいと思います。

スワイプ系の操作ができないと、例えば

  • 領域内の X ボタンをタップする
  • 領域外をタップする

などの操作が必要になってしまうことが多く、これらタップ箇所は手元から遠くて操作しづらいこともしばしばあります。UX を重視するならぜひともこれらスワイプ系の操作には対応したいところですが、これはまだまだだなという感覚です。

ハプティクス UI

ハードウェアが絡む OS の機能を直接使うようなものはもともと難しいというのは前提としてありますが、その中でも質感として重要なのは UI からのフィードバックだと思っています。

ハプティクス UI というのはその中でも「主に物理的に作用するフィードバックを伴うインターフェース」というような位置づけになっていて、要はタップするとスマートフォンがちょっとバイブレーションする、などのやつのことですね。

特に iOS は昔からこの領域が得意で、最近だと例えば ChatGPT のネイティブアプリではレスポンスの文章が表示されるのと同期しつつ抑揚を持った細かいバイブレーションが再生されます。デバイスとの一体感が見事に演出されていて、私も好きな機能です。

これらをウェブから細かく調整するのはまだまだ難しく、仕様の標準化が進むといいなと思います(一応ウェブにも Vibration API というものは既に存在してはいますが、高度なことはできません)。

左右スワイプによる「戻る/進む」アニメーションが画面全体に適用されてしまう

「ページトランジション」のところで左右スワイプによる戻る/進む機能について触れましたが、実はここには解決できてない問題がありました。

OS から見たら一枚のページでしかないため、ページ遷移のアニメーションは常に画面全体に適用されてしまいます。ネイティブアプリでは例えばボトムナビは基本的にこのアニメーションの対象にはなっておらず、この差がかなりの違和感を生んでしまいます。

そもそも左右スワイプによるページ遷移は OS 側の機能を「間借り」するような形で使っていたものである以上、実装側から制御したりすることはできないわけで、ここは諦めるしかなさそうという判断になりました。

頻繁に利用する機能なので何か方法を模索したいところです。

バウンススクロールを完全に止めるのは難しい

前述したバウンススクロールを止める件ですが、この問題を完全に解決するのはやはり現状では難しそうでした。何か対策があったらぜひ教えてください。

おわりに

以上、細かな Tips 集+改善点のご紹介でした!

最後にちょっとおまけ話を。
実は今回の PWA アプリケーションをつくっている途中でこんなニュースを目にしました。

Appleが「iOS 17.4でのPWA廃止」を撤回すると発表
https://gigazine.net/news/20240302-apple-pwa-support-not-remove/

iOS が PWA を廃止しようとしていたことなど全く知らずに開発を進めていたので(それまで PWA に全く興味がなかったのでおそらくスルーしていた)、完成する頃にはもう使えなくなっていたかもしれないと思うととんだ笑い話だなと思いました。使えてよかったです…!

それでは、ここまでお読みいただきありがとうございました!

Author image
About kei
expand_less