Android の戻るボタンで、ダイアログではなくアプリが閉じた
Android の実機で、迎えたばかりの猫の呼び名をたずねるダイアログを開いた状態で戻るボタンを押すと、ダイアログが閉じるのではなくアプリごと終了した。ダイアログに限らず、どの画面で押しても同じだった。原因は @capacitor/app を入れていなかったことで、直し方も1行では終わらない。リスナーを登録した瞬間に、ネイティブ側の既定動作がまるごと消えるからだ。
どの画面で戻るを押しても、アプリが終了した
対象は Capacitor + React で書いた勤怠アプリで、iOS と Android の両ストアに出している。画面遷移はすべて React の state で持つ単一ページ構成なので、WebView の履歴は最初の1ページから増えない。
きっかけは「猫を選ぶ画面から設定画面へ戻れない」というタスクだった。調べると、戻れないのはその画面に限った話ではなく、Android では戻るボタンが常にアプリの終了として働いていた。タブを移動しても、編集モードを開いても、暗幕つきのダイアログを開いても、押した瞬間に落ちる。iOS には端末の戻るボタンが無いので、こちらでは何も起きていなかった。
Capacitor 本体は back を1行も処理していない
WebView を積んだフレームワークなら、戻るくらいは面倒を見てくれているはずだと思っていた。手元の node_modules を読んで、そうではないことを確かめた。Capacitor は 8.5.0 を使っている。
# node_modules/@capacitor/android/capacitor/src/main/java/com/getcapacitor/
$ grep -n "onBackPressed\|KEYCODE_BACK\|canGoBack\|goBack" \
BridgeActivity.java Bridge.java CapacitorWebView.java
$ echo $?
1
# パッケージ全体に広げても、当たるのは Cordova 互換のモックだけ
$ grep -rn "onBackPressed\|KEYCODE_BACK\|canGoBack" .
cordova/MockCordovaWebViewImpl.java:104: public boolean canGoBack() {BridgeActivity、Bridge、CapacitorWebView のどれも back に触れていない。誰も押さえていないので、戻るは OnBackPressedDispatcher をそのまま素通りして Activity の既定動作にたどり着く。すなわち終了だ。WebView の履歴は参照すらされない。
back を扱うコードは、コアではなくプラグイン側にある。@capacitor/app の AppPlugin.load() が、読み込まれたときに1つだけコールバックを登録する。
this.onBackPressedCallback = new OnBackPressedCallback(!disableBackButtonHandler) {
@Override
public void handleOnBackPressed() {
if (!hasListeners(EVENT_BACK_BUTTON)) {
if (bridge.getWebView().canGoBack()) {
bridge.getWebView().goBack();
}
} else {
JSObject data = new JSObject();
data.put("canGoBack", bridge.getWebView().canGoBack());
notifyListeners(EVENT_BACK_BUTTON, data, true);
bridge.triggerJSEvent("backbutton", "document");
}
}
};
getActivity().getOnBackPressedDispatcher().addCallback(getActivity(), this.onBackPressedCallback);プラグインを入れていない Capacitor アプリの戻るボタンは、「まだ実装していない機能」ではなく「Activity を終了させる実装済みのボタン」として動いている。何もしないのではなく、いちばん強いことをする。
リスナーを1つ登録した時点で、既定動作は消える
上のコードの hasListeners の分岐がそのまま落とし穴になる。JS 側でリスナーを1つでも登録すると、canGoBack を見る枝には二度と入らず、通知を投げて終わりになる。@capacitor/app のドキュメントコメントも backButton について「Listening for this event will disable the default back button behaviour」と明記している。つまり「ダイアログを閉じる処理を足す」だけでは済まず、どの層も受け取らなかったときの行き先まで自分で決めないと、戻るが何も起きないアプリになる。
決めた行き先は2段にした。ホーム以外のタブで受け手がいなければホームタブへ寄せる。ホームでさらに押されたら、そこが最上位なのでアプリを畳む。
ここで捨てた案が exitApp だ。ドキュメントも「This should only be used in conjunction with the backButton handler for Android」と、まさにこの用途を想定して用意している。選ばなかったのはアプリの使われ方のためで、勤怠アプリは休憩のたびに開いて閉じる。終了させると次に開くたびコールドスタートになり、猫が歩き出すまで待たされる。かわりに minimizeApp でバックグラウンドへ送った。ホーム画面に戻るという見え方は変わらないまま、次に開いたときは畳んだ状態から続く。
登録順のスタックだと、浅い層が先に閉じる
残るのは「開いている層のうち、いちばん深いものを1つだけ閉じる」という規則の実装だ。素直に考えると、各層がマウント時にハンドラを配列へ積み、アンマウント時に外す。押されたら配列の最後を呼ぶ。よくあるスタックだが、React ではこれが逆に動く。
useEffect は子から先に実行される。ダイアログを内側に持つ画面では、深いはずのダイアログが先に積まれ、外側の画面が後から積まれる。配列の末尾は常に浅いほうになり、戻るを押すと手前のダイアログを残したまま画面ごと閉じてしまう。順番は「どちらが深いか」を表していない。
なので深さは登録順ではなく、数で持たせた。各層は自分の深さを申告して登録し、押されたときは配列を走査して最大値を1つ選ぶ。
/**
* 戻るを受け取る層の深さ。同時に複数ひらいていても、いちばん深い層だけが閉じる。
* React の効果は子が先に走るため、登録の順番では「深さ」を決められない。数で決める。
*/
export const BACK_DEPTH = {
/** タブの下にぶら下がるサブ画面(ねこ屋さん) */
screen: 10,
/** 画面のなかの編集モード(ホームの「時間を直す」) */
panel: 20,
/** 暗幕つきのダイアログ */
dialog: 30,
/** いちばん前に出るオンボーディング */
onboarding: 40,
} as const
// (中略 — useAndroidBackButton が登録する backButton リスナーの中身)
const deepest = handlers.reduce<BackHandler | null>(
(top, handler) => (top === null || handler.depth > top.depth ? handler : top),
null,
)
if (deepest) deepest.close()
else latest.current()層の側は useBackHandler(active, depth, close) を1回呼ぶだけになる。同じ層の入れ子(編集モードの中で開くセレクタなど)は、深さを増やさずそのハンドラの中で内側から順に閉じる。ネイティブ依存が増えたので npx cap sync android が要る。ここまでで型検査は通っているが、実機での確認はこれからだ。症状のほうは実機で見て分かっているが、直ったことはまだ実機で見ていない。
iOS には端末の戻るボタンが無く、画面内の ◀ とタブバーで戻れるので、こちらは変更していない。
まとめ
ネイティブの物理ボタンは、Web の世界から見ると「まだ配線していない入力」に見える。実際には配線済みで、既定の行き先が終了になっているだけだ。ハイブリッドアプリでプラットフォーム固有の入力を扱うときは、自分が何も書いていない状態が「無反応」なのか「既定の動作」なのかを先に確かめたほうがいい。ここを取り違えると、node_modules を読めば数分で分かることを、自分のコードの中で探し続けることになる。
もう1つ。React で「いま開いている層」を管理するとき、効果の実行順は階層の深さと一致しない。順序に意味を持たせたいなら、順序ではなく値で持つ。