はじめに
Jetpack Navigation 3(以下 Nav3)は柔軟性が高い一方で、実装方針が 1 つに定まっているわけではありません。そのため、実運用ではアプリの要件に応じて設計する場面が多く、実装が複雑になりやすいです。
本記事では、Nav3 において状態の保持と描画を分けて考えると、なぜ設計しやすくなるのかを説明します。
なお、本記事で紹介するアプローチは android/nav3-recipes の #242 にて、公式のレシピとしては採用されませんでした。これはアプローチ自体に問題があるのではなく、レシピとして適切ではなかったためだと考えています。しかし、Nav3 の設計の自由度や仕組みを理解する上では有用な視点であるため、本記事で取り上げます。
Nav3 とは
Nav3 は Compose のための新しい画面遷移の仕組みです。バックスタックの状態変化が UI に反映される宣言的な設計になっています。
Nav3 を扱うときに利用することが多い NavBackStack、rememberDecoratedNavEntries、NavDisplay について簡単に説明します。
NavBackStack
NavBackStack はバックスタックを管理するための型です。実体としては、Serializable な SnapshotStateList<NavKey> です。
つまり NavBackStack は、Nav3 の遷移を扱うための高度な仕組みというより、Nav3 の標準的なバックスタック表現の 1 つと考えるのがよいでしょう。
独自の画面遷移を設計する場合は、NavBackStack を使わずに独自実装しても構いません1。具体例は後述の遷移の設計は自由で説明します。
@Serializable(with = NavBackStackSerializer::class)
public class NavBackStack<T : NavKey> : MutableList<T>, StateObject, RandomAccess
rememberDecoratedNavEntries
rememberDecoratedNavEntries は backStack: List<T> をもとに entryDecorators と entryProvider を適用し、List<NavEntry<T>> を生成する関数です。
代表的な entryDecorators としては、SaveableStateHolderNavEntryDecorator や ViewModelStoreNavEntryDecorator があります。これらは内部で各画面に SaveableStateHolder や ViewModelStore を関連付けます。
ここで重要なのは、画面の状態(たとえば ViewModel や SavedState)の寿命は、この関数に渡されるリストに含まれている期間と一致するという点です。リストからキーが削除されると、対応する状態も破棄されます。
@Composable
public fun <T : Any> rememberDecoratedNavEntries(
backStack: List<T>,
entryDecorators: List<@JvmSuppressWildcards NavEntryDecorator<T>> = listOf(),
entryProvider: (T) -> NavEntry<T>
): List<NavEntry<T>>
NavDisplay
NavDisplay は、entries: List<NavEntry<T>> を受け取って描画するための関数です2。NavDisplay は遷移状態を管理するのではなく、entries を描画する役割を担います3。
@Composable
public fun <T : Any> NavDisplay(
entries: List<NavEntry<T>>,
modifier: Modifier = Modifier,
// ...
onBack: () -> Unit,
)
保持と描画を分けて考える
実際のアプリ開発の場面では、1 方向の単純な遷移だけでなく、タブやナビゲーションバーによる遷移も扱うことが多いです。このような UI では「画面としては表示されていないが、タブの状態は保持しておきたい」という要件が発生します。
例えば、以下のように遷移状態を NavigationState という独自のモデルで管理することを考えましょう。このように保持対象のバックスタックと描画対象のバックスタックを分けて扱うことで、非アクティブなタブの状態は保持しつつ、現在表示中のタブだけを描画できます。タブ切り替えやナビゲーションバーを伴う構成でも、この分離を意識すると設計しやすくなります。
このようにすると、NavDisplay に渡す entries は activeBackStack の内容のみになりますが、rememberDecoratedNavEntries には inactiveBackStack も渡されているため、裏にあるタブの状態(ViewModel など)は破棄されずに保持されます。
interface NavigationState {
/** 非アクティブなバックスタック */
val inactiveBackStack: NavBackStack<NavKey>
/** アクティブなバックスタック */
val activeBackStack: NavBackStack<NavKey>
}
@Composable
fun NavigationState.toDecoratedEntries(
entryProvider: (NavKey) -> NavEntry<NavKey>,
): List<NavEntry<NavKey>> {
// 全てのバックスタックを decorated した entries を生成する
val allEntries = rememberDecoratedNavEntries(
// 非アクティブ・アクティブに限らず全てのバックスタックを渡す
backStack = inactiveBackStack + activeBackStack,
entryProvider = entryProvider,
)
// アクティブなバックスタックに対応する entries のみを返す
return allEntries.takeLast(activeBackStack.size)
}
遷移の設計は自由
前述の実装例から分かるとおり、遷移の設計には高い自由度があります。少なくとも以下の 2 点を満たせば、遷移状態の持ち方と描画の分け方をある程度自由に設計できます。
- 状態を保持したいバックスタックを
rememberDecoratedNavEntriesに渡す - 画面を描画したい
NavEntry群だけをNavDisplayに渡す
たとえば、以下のような NavNode を定義し、木構造を探索して「保持対象の一覧」と「描画対象の entries」を導出できれば、より汎用的な遷移管理も可能になります。
sealed interface NavNode {
class Leaf(val key: NavKey) : NavNode
class Stack(val children: List<NavNode>) : NavNode
class Select(var selected: NavNode, val children: Set<NavNode>) : NavNode
}
まとめ
- Nav3 では、状態の保持と描画を分けて考えると設計しやすい
-
rememberDecoratedNavEntriesには、状態保持する対象のバックスタックを全て渡す -
NavDisplayには、生成されたentriesの中から描画するものだけを渡す
参考文献
- Navigation 3 | App architecture | Android Developers
- android/nav3-recipes: Implement common use cases with Jetpack Navigation 3
-
NavDisplayは描画だけでなくonBackにて戻る操作の消費も行います。 ↩