Jetpack ComposeでNavigation3をベースにマルチペイン対応を簡単にする「AppNav」を作ってみた
1. はじめに
Adaptive Navigationを簡単にする「AppNav」を公開しました。
Googleが提案している次世代ナビゲーション 「Navigation 3 (Nav3)」 の思想をベースにしつつ、特にマルチデバイス(スマホ、タブレット、折りたたみ端末)におけるマルチペイン対応を劇的にシンプルにすることを目指しました。
2. 開発背景
近年、Android開発では折りたたみデバイスやタブレットといった「アダプティブUI」への対応が強く求められています。
正直なところ、スマホに比べて需要の少ない大画面対応は手間がかかるし、ついつい後回しにしがちでした。しかし、最近の状況は一変しています。
- Android デスクトップモードの強化
- Android XR の発表
- ChromeOS と Android の統合加速
「スマホアプリだから大画面は関係ない」という言い訳は、もはや通用しない時代が来たと感じています。
そこで重い腰を上げ、どうせ対応するなら最新の Navigation 3 (Nav3) をベースにしようと開発を始めました。しかし、実際に触ってみると多くの疑問点や、実戦投入する上での「壁」が見えてきたのです。
3. Navigation 3を触って感じた「壁」
Nav3は、良くも悪くも 「ただのList」 です。
1画面のスタックを管理する分にはシンプルで強力ですが、画面数が増え、さらにマルチペインが必要になった途端、この「フラットなList構造」は破綻し始めます。なぜなら、画面間の親子関係や役割といった「構造」を表現する手段がないからです。
公式レシピの限界:不純物としてのMetadata
公式のサンプルに従うと、以下のような実装になりがちです。
- 各Keyに対応した、NavEntryに、「これはSupport画面である」といった静的な Metadata を付与する。
- Adaptiveに対応した Strategy で、NavEntyに付与されたMetadataを頼りに表示ロジックを変換する。
- それをAdaptive対応の Scaffold に流し込む。
この実装では、Nav3の魅力であった「Listで書けるシンプルさ」が破壊されます。
「自分は誰か」を画面自身が知らなければならない矛盾
最大の問題は、「Support画面は、自分がSupportであることを知っていなければならない」 という点です。
本来、ある画面が「Main」になるか「Support」になるかは、「誰が、どのような文脈でその画面を呼んだか」 によって決まるべきです。「Mainが呼んだから、今はSupportとして振る舞う」というのが自然であり、画面自身が「私はSupportです」と名乗ってしまうと、別の文脈でMainとして再利用することが難しくなります。
4. AppNavの設計思想:「Screen」と「Layout」の完全な分離
この問題を解決するために、AppNavでは 「ScreenはLayoutを知らず、LayoutはScreenを知らず」 という疎結合な設計を徹底しました。
お互いを知らないことで得られる柔軟性
- Screen(中身): 自分が「どのデバイスで」「MainかSupportか」を一切気にしません。単に自分のコンテンツを表示することに専念します。
- Layout(器): 渡されたScreenが「何者か」を知る必要はありません。現在の画面に応じて、受け取ったパーツを「ここにMain、ここにSupport」と最適に配置する役割だけを担います。
「役割」はScreenではなく、Actionが決める
Nav3の公式レシピとの決定的な違いは、役割(Role)をどこで定義するかにあります。
公式レシピでは、画面(Key/Entry)そのものに「これはSupport画面です」という属性を固定で持たせていましたが、これでは再利用性が失われます。AppNavでは、画面遷移を 「Key = Arg + Context」 と再定義しました。
この「Context(文脈)」を生成するのが、遷移時に指定される Action です。
5. AppNavのアーキテクチャ:論理構造の統一
AppNavの内部では、開発者が指定した「Action」がどのように「物理的な画面配置」へと変換されるのか。そのプロセスを整理したのが以下の図です。
アーキテクチャの解説
-
入口(論理): 画面(Screen)は遷移時に
Arg + Actionを発行するだけです。自分がどこに表示されるべきかという具体的な関心(物理的な配置)は持ちません。 -
Keyの生成:
ConstraintResolver(論理構造の法典)に問い合わせが行われ、Actionから Context(文脈) が導き出されます。これによりArg + Contextを内包したAppNavKeyが生成されます。 -
データの蓄積: 生成されたKeyは
BackStackに積まれます。ここではじめて、フラットなNav3のリストの中に「構造的な意味」が保存されます。 -
出口(物理): 描画時、
SceneStrategyが再びConstraintResolverを参照します。「このContextを持つKeyは、現在のデバイス状況ならどこに配置すべきか?」を判定し、最終的なScene(物理的な配置)へと出力します。
6. この設計がもたらす「Screenの解放」
このアーキテクチャにより、ScreenとLayoutは完全に「お互いを知らない」まま共存できます。
-
Screen(入口)側:
// 画面側は「何で、どうしたいか」というActionを伝えるだけ navController.navigate(DetailArgs(id), AppNavAction.Expand()) -
Layout(出口)側:
Resolverが定義したルールに従い、Keyに含まれるContextを見て「Mainパネル」「Supportパネル」といったスロットに機械的に流し込むだけ。
この一貫したフローにより、**「ある場所からは隣に、またある場所からは独立した一画面として」**といった柔軟な画面の再利用が、コードの不純物を一切増やすことなく実現可能になりました。
7. Sample App あります
実装の詳細は appnav-sample に譲りますが、このサンプルは AppNav のアーキテクチャが「単なる理想論」ではないことを証明しています。
1. 王道の構成:List-Detail-Extra
スマホでは1画面ずつの遷移ですが、ウィンドウを広げた瞬間にバックスタックにいた画面たちがそれぞれの「座席(Role)」へ自動的に収まります。

-
スタックの再配置: 開発者が
if文でレイアウトを切り替えるのではなく、ConstraintResolverが「今は3つ並べられる」と判断し、論理構造を物理配置(Scene)へ変換しています。
2. 変則型:Selector-Editor-Viewer
画面間での値の受け渡し(Selector → Editor)や、状態の購読(Editor ← Viewer)を伴う複雑なパターンです。

- 役割の分離: Viewer は Editor の値を購読していますが、自分がどのパネルに置かれているかは知りません。
- 柔軟なUI: 画面が狭いときは単体の Viewer、広いときは Editor と同期する Viewer と、コンテキストに応じた再利用が可能です。
3. Multiple BackStack
セッション(タブなど)ごとに独立したスタック管理が可能です。

- 状態の完全維持: タブを切り替えても、さらにはその途中でレイアウトを激しく変更(1ペイン ↔ 3ペイン)しても、各画面の入力値やスライダーの位置、スクロール状態は一切失われません。
4. Per-pane Predictive Back (予測戻る)
Android 14以降の最新機能である「予測戻る」にも、ペイン(パネル)単位で対応しています。地味に、一番手間がかかった部分です。

- 高度なハンドリング: 3ペイン表示時に「右端のパネルだけを戻す」といった、高度なジェスチャーハンドリングをネイティブにサポートしています。
- Nav3との親和性: BackStack の状態を宣言的に管理しているため、システムの BackEvent とのシームレスな同期が実現しました。## 8. おわりに
8. おわりに
この「AppNav」は、延々と Gemini と対話を重ねることで生まれました。
この記事の構成や推敲も、私の雑多な箇条書きを AI に投げ、整理してもらったものです。
AI 時代は、むしろ一人で考えられる範疇を無限大にしてくれるような気がします。
検索時間の短縮はもちろん、エラーの特定や原因の考察、そして何より**「ここまで設計を抽象化できたこと」**は、明らかに AI との壁打ちがあったからこそ到達できた領域です。
「スマホアプリだから大画面は関係ない」という言い訳ができなくなるこれからの時代に、このAppNavが皆さんの手間を減らす一助になれば幸いです。
ぜひ触ってみて、フィードバックやStarをいただけると嬉しいです!
Discussion