Slide 1

Slide 1 text

Nav2、Nav3 ... はたまた自作? 〜 作って理解する Nav3 の設計意図 〜 Nav2, Nav3 ... or Build Your Own? — Understanding Nav3's design intent by building it from scratch あんざいゆき / Yuki Anzai (@yanzm) DroidKaigi 2026 1

Slide 2

Slide 2 text

あんざいゆき / Yuki Anzai • Android Engineer • Google Developer Expert for Android • X (twitter) : @yanzm(やんざむ) • blog : y-anz-m.blogspot.com • 株式会社ウフィカ(uPhyca Inc.) 代表取締役 • 札幌・関東 2拠点生活

Slide 3

Slide 3 text

Nav3 使ってる? Are you using Nav3? 3

Slide 4

Slide 4 text

Nav3 難しくない? Isn't Nav3 hard? 4

Slide 5

Slide 5 text

Nav3 コンポーネント多くない? Aren't there a lot of components? NavDisplay / NavEntry / NavKey Scene / SceneStrategy NavEntryDecorator / entryProvider ... 5

Slide 6

Slide 6 text

Nav3 いる? Do we even need Nav3? 6

Slide 7

Slide 7 text

自分で作ったら Nav3 の意図がわかるかも… If we build it ourselves, maybe we'll understand Nav3's intent... 7

Slide 8

Slide 8 text

作るアプリ The app we'll build メモリスト MemoList メモ詳細 MemoDetail 8 メモ編集 MemoEdit

Slide 9

Slide 9 text

これから踏む7つの問題 The seven problems we'll hit 1. 前の画面に戻りたい Go back 2. 回転しても画面を維持したい Survive rotation 3. プロセス死から復元したい Survive process death 4. 離れた画面の状態を保持したい Keep in-screen state 5. ViewModel を画面にスコープしたい Scope ViewModels to screens 6. タブレットで2ペインにしたい Two panes on tablets 7. マルチモジュールに対応したい Multi-module support 9

Slide 10

Slide 10 text

v1 まずは素朴に作る Start naive 10

Slide 11

Slide 11 text

Route sealed interface Route { data object MemoList : Route data class MemoDetail(val id: Long) : Route data class MemoEdit(val id: Long?) : Route } 11

Slide 12

Slide 12 text

v1 var route by remember { mutableStateOf(Route.MemoList) } when (val r = route) { Route.MemoList -> MemoListScreen( onMemoClick = { id -> route = Route.MemoDetail(id) }, onAddClick = { route = Route.MemoEdit(null) }, ) is Route.MemoDetail -> MemoDetailScreen( id = r.id, onEditClick = { route = Route.MemoEdit(r.id) }, ) is Route.MemoEdit -> MemoEditScreen( id = r.id, onSaved = { route = Route.MemoList }, ) } 12

Slide 13

Slide 13 text

v1 var route by remember { mutableStateOf(Route.MemoList) } when (val r = route) { Route.MemoList -> MemoListScreen( onMemoClick = { id -> route = Route.MemoDetail(id) }, onAddClick = { route = Route.MemoEdit(null) }, back で前の画面に戻れない • ) • メモを保存したときに前の画面に戻れない is Route.MemoDetail -> MemoDetailScreen( id = r.id, → 画面遷移の履歴を管理していないから前の画面がわ onEditClick = { route = Route.MemoEdit(r.id) }, ) からない } is Route.MemoEdit -> MemoEditScreen( id = r.id, → backstack で「画面遷移の履歴」を保持すればいい onSaved = { route = Route.MemoList }, ) Back doesn't work — we don't track navigation history, so we don't know the previous screen → keep the history as a back stack 13

Slide 14

Slide 14 text

v2 前の画面に戻りたい We want to go back 14 問題 1/7

Slide 15

Slide 15 text

v2 val backStack = remember { mutableStateListOf(Route.MemoList) } BackHandler(enabled = backStack.size > 1) { backStack.removeLastOrNull() } when (val route = backStack.last()) { is Route.MemoList -> MemoListScreen( onMemoClick = { id -> backStack.add(Route.MemoDetail(id)) }, onAddClick = { backStack.add(Route.MemoEdit(null)) }, ) is Route.MemoDetail -> MemoDetailScreen( id = route.id, onEditClick = { backStack.add(Route.MemoEdit(route.id)) }, ) is Route.MemoEdit -> MemoEditScreen( id = route.id, onSaved = { backStack.removeLastOrNull() }, ) } 15 前のバージョンからの差分 changed since prev ver

Slide 16

Slide 16 text

v2 val backStack = remember { mutableStateListOf(Route.MemoList) } BackHandler(enabled = backStack.size > 1) { backStack.removeLastOrNull() Route の履歴を SnapshotStateList (変更された } ときに recompose が走る List )として保持 when (val route = backStack.last()) { is Route.MemoList -> MemoListScreen( Keep the Route history as a SnapshotStateList (a list that onMemoClick = { id -> backStack.add(Route.MemoDetail(id)) }, triggers recomposition on change) onAddClick = { backStack.add(Route.MemoEdit(null)) }, ) MemoList is Route.MemoDetail -> MemoDetailScreen( id = route.id, onEditClick = { backStack.add(Route.MemoEdit(route.id)) }, SnapshotStateList ) is Route.MemoEdit -> MemoEditScreen( id = route.id, onSaved = { backStack.removeLastOrNull() }, ) } 16

Slide 17

Slide 17 text

v2 val backStack = remember { mutableStateListOf(Route.MemoList) } BackHandler(enabled = backStack.size > 1) { backStack.removeLastOrNull() } when (val route = backStack.last()) { is Route.MemoList -> MemoListScreen( onMemoClick = { id -> backStack.add(Route.MemoDetail(id)) }, onAddClick = { backStack.add(Route.MemoEdit(null)) }, backStack (= Route のリスト)の一番最後の MemoList ) Route(= 現在の画面)を表示 is Route.MemoDetail -> MemoDetailScreen( last() id = route.id, Show the last Route in the backStack (= the current onEditClick = { backStack.add(Route.MemoEdit(route.id)) }, screen) ) is Route.MemoEdit -> MemoEditScreen( id =MemoList route.id, onSaved = { backStack.removeLastOrNull() }, ) } 17

Slide 18

Slide 18 text

v2 val backStack = remember { mutableStateListOf(Route.MemoList) } BackHandler(enabled = backStack.size > 1) { backStack.removeLastOrNull() } when (val route = backStack.last()) { is Route.MemoList -> MemoListScreen( onMemoClick = { id -> backStack.add(Route.MemoDetail(id)) }, onAddClick = { backStack.add(Route.MemoEdit(null)) }, ) backStack に Route を追加 is Route.MemoDetail -> MemoDetailScreen( → recompose last() id = route.id, → 追加した Route が last に onEditClick = { backStack.add(Route.MemoEdit(route.id)) }, なるので画面が切り替わる ) MemoDetail (1) add(MemoDetail(1)) MemoDetail (1) add() → recompose → the new is Route.MemoEdit -> MemoEditScreen( Route is now last, so the screen id = route.id, onSaved = { backStack.removeLastOrNull() }, switches MemoList MemoList ) } 18

Slide 19

Slide 19 text

v2 val backStack = remember { mutableStateListOf(Route.MemoList) } BackHandler(enabled = backStack.size > 1) { backStack.removeLastOrNull() } when (val route = backStack.last()) { is Route.MemoList -> MemoListScreen( backStack に前の画面がある MemoList onMemoClick = { id -> backStack.add(Route.MemoDetail(id)) }, ときは、backStack の最後の onAddClick = { backStack.add(Route.MemoEdit(null)) }, ) エントリーを削除することで前 last() の画面に戻れる is Route.MemoDetail -> MemoDetailScreen( id = route.id, removeLastOrNull() MemoDetail (1) Removing the last entry navigates onEditClick = { backStack.add(Route.MemoEdit(route.id)) }, back (when there is a previous ) screen) MemoList MemoList is Route.MemoEdit -> MemoEditScreen( id = route.id, onSaved = { backStack.removeLastOrNull() }, ) } 19

Slide 20

Slide 20 text

v2 val backStack = remember { mutableStateListOf(Route.MemoList) } BackHandler(enabled = backStack.size > 1) { backStack.removeLastOrNull() } when (val route = backStack.last()) { is Route.MemoList -> MemoListScreen( onMemoClick = { id -> backStack.add(Route.MemoDetail(id)) }, onAddClick = { backStack.add(Route.MemoEdit(null)) }, 画面回転したときに MemoList に戻ってしまう ) is Route.MemoDetail -> MemoDetailScreen( → rememberSaveable で保存すればよさそう id = route.id, onEditClick = { backStack.add(Route.MemoEdit(route.id)) }, Rotation resets us to MemoList → let's persist it with ) rememberSaveable is Route.MemoEdit -> MemoEditScreen( id = route.id, onSaved = { backStack.removeLastOrNull() }, ) } 20

Slide 21

Slide 21 text

v3 回転しても画面を維持したい Survive rotation 21 問題 2/7

Slide 22

Slide 22 text

v3 val backStack = rememberSaveable { mutableStateListOf(Route.MemoList) } BackHandler(enabled = backStack.size > 1) { backStack.removeLastOrNull() } when (val route = backStack.last()) { is Route.MemoList -> MemoListScreen( onMemoClick = { id -> backStack.add(Route.MemoDetail(id)) }, onAddClick = { backStack.add(Route.MemoEdit(null)) }, ) is Route.MemoDetail -> MemoDetailScreen( id = route.id, onEditClick = { backStack.add(Route.MemoEdit(route.id)) }, ) is Route.MemoEdit -> MemoEditScreen( id = route.id, onSaved = { backStack.removeLastOrNull() }, ) } 22

Slide 23

Slide 23 text

v3 val backStack = rememberSaveable { mutableStateListOf(Route.MemoList) } BackHandler(enabled = backStack.size > 1) { backStack.removeLastOrNull() } when (val route = backStack.last()) { is Route.MemoList -> MemoListScreen( ホームボタンを押すとクラッシュ onMemoClick = { id -> backStack.add(Route.MemoDetail(id)) }, onAddClick = { backStack.add(Route.MemoEdit(null)) }, java.lang.IllegalArgumentException: ) Parcel: unknown type for value MemoList is Route.MemoDetail -> MemoDetailScreen( → 保存できるのは Bundle に入るものだけ id = route.id, onEditClick = { backStack.add(Route.MemoEdit(route.id)) }, → )Json 文字列化して保存 Pressing Home crashes — -> onlyMemoEditScreen( Bundle-compatible values can is Route.MemoEdit be savedid →= serialize Routes to JSON strings route.id, onSaved = { backStack.removeLastOrNull() }, ) } 23

Slide 24

Slide 24 text

v4 プロセス死から復元したい Survive process death 24 問題 3/7

Slide 25

Slide 25 text

Route + Kotlin Serialization import kotlinx.serialization.Serializable @Serializable sealed interface Route { @Serializable data object MemoList : Route Kotlin Serialization を使って @Serializable data class MemoDetail(val id: Long) : Route @Serializable data class MemoEdit(val id: Long?) : Route } 25 Route を Json 文字列化 Encode Routes as JSON strings with Kotlin Serialization v4

Slide 26

Slide 26 text

v4 val RouteListSaver = listSaver, String>( save = { backStack -> backStack.map { Json.encodeToString(it) } }, restore = { saved -> saved.map { Json.decodeFromString(it) }.toMutableStateList() }, ) val backStack = rememberSaveable(saver = RouteListSaver) { mutableStateListOf(Route.MemoList) } Json 文字列化して保存する Saver を用意し、 rememberSaveable でそれを使うようにする Write a Saver that stores the list as JSON strings and pass it to rememberSaveable 26

Slide 27

Slide 27 text

v4 val RouteListSaver = listSaver, String>( save = { backStack -> backStack.map { Json.encodeToString(it) } }, restore = { saved -> saved.map { Json.decodeFromString(it) }.toMutableStateList() }, ) val backStack = rememberSaveable(saver = RouteListSaver) { mutableStateListOf(Route.MemoList) } Json 文字列化して保存する Saver を用意し、 rememberSaveable でそれを使うようにする Write a Saver that stores the list as JSON strings and pass it to rememberSaveable

Slide 28

Slide 28 text

v4 val RouteListSaver = listSaver, String>( save = { backStack -> backStack.map { Json.encodeToString(it) } }, restore = { saved -> saved.map { Json.decodeFromString(it) }.toMutableStateList() }, ) val backStack = rememberSaveable(saver = RouteListSaver) { mutableStateListOf(Route.MemoList) } 自作の Saver の処理は KSerializer にそのまま 委譲できる The hand-written Saver logic can be delegated to a KSerializer 28

Slide 29

Slide 29 text

v5 手書き Saver を部品に置き換える Swap the hand-written Saver for the built-in part 29 問題 3/7 つづき

Slide 30

Slide 30 text

v5 val backStack = rememberSerializable( serializer = SnapshotStateListSerializer(serializer()), ) { mutableStateListOf(Route.MemoList) } @Composable public fun rememberSerializable( serializer: KSerializer, … ): T { val saver = serializableSaver(serializer, configuration) @Suppress("DEPRECATION") return rememberSaveable(*inputs, saver = saver, key = null, init = init) } rememberSerializable = rememberSaveable の KSerializer 版 the KSerializer avor of rememberSaveable fl 30

Slide 31

Slide 31 text

Nav3 との比較 Comparison with Nav3 31

Slide 32

Slide 32 text

自作 @Serializable sealed interface Route { @Serializable data object MemoList : Route … } package androidx.navigation3.runtime public interface NavKey Nav3 @Serializable data object MemoList : NavKey 32

Slide 33

Slide 33 text

自作 val backStack: SnapshotStateList = rememberSerializable( serializer = SnapshotStateListSerializer(serializer()), ) { mutableStateListOf(Route.MemoList) } Nav3 val backStack: NavBackStack = rememberNavBackStack(MemoList) 33

Slide 34

Slide 34 text

自作 val backStack: SnapshotStateList = rememberSerializable( serializer = SnapshotStateListSerializer(serializer()), ) { mutableStateListOf(Route.MemoList) } Nav3 val backStack: NavBackStack = rememberNavBackStack(MemoList) 34

Slide 35

Slide 35 text

自作 val backStack: SnapshotStateList = rememberSerializable( serializer = SnapshotStateListSerializer(serializer()), ) { mutableStateListOf(Route.MemoList) } Nav3 val backStack: NavBackStack = rememberNavBackStack(MemoList) @Serializable(with = NavBackStackSerializer::class) public class NavBackStack public constructor(internal val base: SnapshotStateList) : MutableList by base, StateObject by base, RandomAccess by base { public constructor() : this(base = mutableStateListOf()) public constructor(vararg elements: T) : this(base = mutableStateListOf(*elements)) } 35

Slide 36

Slide 36 text

自作 val backStack: SnapshotStateList = rememberSerializable( serializer = SnapshotStateListSerializer(serializer()), ) { mutableStateListOf(Route.MemoList) } Nav3 val backStack: NavBackStack = rememberNavBackStack(MemoList) @Composable public fun rememberNavBackStack(vararg elements: NavKey): NavBackStack { return rememberSerializable( serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()) ) { NavBackStack(*elements) } A wrapper around SnapshotStateListSerializer } 36

Slide 37

Slide 37 text

自作 val backStack: SnapshotStateList = rememberSerializable( serializer = SnapshotStateListSerializer(serializer()), ) { mutableStateListOf(Route.MemoList) } Nav3 val backStack: NavBackStack = rememberNavBackStack(MemoList) @Composable public fun rememberNavBackStack(vararg elements: NavKey): NavBackStack { return rememberSerializable( serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()) ) { NavBackStack(*elements) } } 37

Slide 38

Slide 38 text

ここまでのまとめ / Recap so far 自作 / DIY Nav3 sealed interface Route interface NavKey mutableStateListOf() NavBackStack (wraps SnapshotStateList, uses mutableStateListOf() internally) rememberSerializable() rememberNavBackStack() (uses rememberSerializable() internally) 38

Slide 39

Slide 39 text

v5 @Composable fun MemoListScreen( onMemoClick: (Long) -> Unit, onAddClick: () -> Unit, modifier: Modifier = Modifier, ) { var query by rememberSaveable { mutableStateOf("") } … 39

Slide 40

Slide 40 text

v5 @Composable fun MemoListScreen( onMemoClick: (Long) -> Unit, onAddClick: () -> Unit, modifier: Modifier = Modifier, ) { var query by rememberSaveable { mutableStateOf("") } … MemoList の検索ボックス文字列が → MemoDetail → 戻る で消える The search text in MemoList is lost after going to MemoDetail and back 40

Slide 41

Slide 41 text

v5 when (val route = backStack.last()) { is Route.MemoList -> MemoListScreen( … ❌ ) is Route.MemoDetail -> MemoDetailScreen( … ) is Route.MemoEdit -> MemoEditScreen( backStack.last() が MemoDetail になる … ) } → MemoListScreen() を呼び出すパスは通らなくなる = MemoListScreen が composition から完全に消える → rememberSaveable の値も一緒に消える backStack.last() becomes MemoDetail → the code path that calls MemoListScreen() no longer runs = MemoListScreen leaves the composition entirely → its rememberSaveable values are discarded with it 41

Slide 42

Slide 42 text

v6 離れた画面の状態を保持したい Keep in-screen state across navigation 42 問題 4/7

Slide 43

Slide 43 text

v6 private val Route.stateKey: String get() = toString() 各画面の状態を保存するための key(Bundle に 保存される)を定義 De ne a key for saving each screen's state (the key itself is stored in the Bundle) fi 43

Slide 44

Slide 44 text

v6 val stateHolder: SaveableStateHolder = rememberSaveableStateHolder() fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { stateHolder.removeState(popped.stateKey) } } BackHandler(enabled = backStack.size > 1) { pop() } val route = backStack.last() stateHolder.SaveableStateProvider(key = route.stateKey) { when (route) { is Route.MemoList -> MemoListScreen(…) is Route.MemoDetail -> MemoDetailScreen(…) is Route.MemoEdit -> MemoEditScreen( id = route.id, onSaved = { pop() }, ) 44 }

Slide 45

Slide 45 text

v6 val stateHolder: SaveableStateHolder = rememberSaveableStateHolder() fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { stateHolder.removeState(popped.stateKey) } } BackHandler(enabled = backStack.size > 1) { pop() } val route = backStack.last() stateHolder.SaveableStateProvider(key = route.stateKey) { when (route) { is Route.MemoList -> MemoListScreen(…) is Route.MemoDetail -> MemoDetailScreen(…) is Route.MemoEdit -> MemoEditScreen( id = route.id, onSaved = { pop() }, ) 45 }

Slide 46

Slide 46 text

v6 val stateHolder: SaveableStateHolder = rememberSaveableStateHolder() fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { stateHolder.removeState(popped.stateKey) } } BackHandler(enabled = backStack.size > 1) { pop() } val route = backStack.last() stateHolder.SaveableStateProvider(key = route.stateKey) { when (route) { is Route.MemoList -> MemoListScreen(…) is Route.MemoDetail -> MemoDetailScreen(…) is Route.MemoEdit -> MemoEditScreen( id = route.id, onSaved = { pop() }, ) 46 }

Slide 47

Slide 47 text

v6 val stateHolder: SaveableStateHolder = rememberSaveableStateHolder() fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { stateHolder.removeState(popped.stateKey) } } BackHandler(enabled = backStack.size > 1) { pop() } val route = backStack.last() stateHolder.SaveableStateProvider(key = route.stateKey) { when (route) { is Route.MemoList -> MemoListScreen(…) is Route.MemoDetail -> MemoDetailScreen(…) is Route.MemoEdit -> MemoEditScreen( id = route.id, onSaved = { pop() }, ) 47 }

Slide 48

Slide 48 text

Nav3 との比較 Comparison with Nav3 48

Slide 49

Slide 49 text

自作 val stateHolder: SaveableStateHolder = rememberSaveableStateHolder() Nav3 @Composable public fun rememberSaveableStateHolderNavEntryDecorator( saveableStateHolder: SaveableStateHolder = rememberSaveableStateHolder() ): SaveableStateHolderNavEntryDecorator = remember(saveableStateHolder) { SaveableStateHolderNavEntryDecorator(saveableStateHolder) } 49

Slide 50

Slide 50 text

自作 val stateHolder: SaveableStateHolder = rememberSaveableStateHolder() @Immutable public open class NavEntryDecorator( internal val onPop: (key: Any) -> Unit = {}, internal val decorate: @Composable (entry: NavEntry) -> Unit, ) Nav3 public class SaveableStateHolderNavEntryDecorator( saveableStateHolder: SaveableStateHolder ) : NavEntryDecorator( onPop = { contentKey -> saveableStateHolder.removeState(contentKey) }, decorate = { entry -> saveableStateHolder.SaveableStateProvider(entry.contentKey) { entry.Content() } }, ) 50

Slide 51

Slide 51 text

自作 val stateHolder: SaveableStateHolder = rememberSaveableStateHolder() fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { stateHolder.removeState(popped.stateKey) } } Nav3 public class SaveableStateHolderNavEntryDecorator( saveableStateHolder: SaveableStateHolder ) : NavEntryDecorator( onPop = { contentKey -> saveableStateHolder.removeState(contentKey) }, decorate = { entry -> saveableStateHolder.SaveableStateProvider(entry.contentKey) { entry.Content() } }, ) 51

Slide 52

Slide 52 text

自作 val route = backStack.last() stateHolder.SaveableStateProvider(key = route.stateKey) { when (route) { … } } Nav3 public class SaveableStateHolderNavEntryDecorator( saveableStateHolder: SaveableStateHolder ) : NavEntryDecorator( onPop = { contentKey -> saveableStateHolder.removeState(contentKey) }, decorate = { entry -> saveableStateHolder.SaveableStateProvider(entry.contentKey) { entry.Content() } }, ) 52

Slide 53

Slide 53 text

Nav3 @Composable public fun NavDisplay( backStack: List, …, entryDecorators: List> = listOf(rememberSaveableStateHolderNavEntryDecorator()), … ) { … } 53

Slide 54

Slide 54 text

ViewModel を使いたい We want to use ViewModels 54 問題 5/7

Slide 55

Slide 55 text

class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyTheme { MyApp() } } } } @Composable fun MyApp() { … stateHolder.SaveableStateProvider(key = route.stateKey) { when (route) { is Route.MemoList -> MemoListScreen(…) is Route.MemoDetail -> MemoDetailScreen(…) is Route.MemoEdit -> MemoEditScreen(…) } } } 55

Slide 56

Slide 56 text

@Composable fun MemoDetailScreen( id: Long, onEditClick: () -> Unit, modifier: Modifier = Modifier, ) { val viewModel = viewModel { MemoDetailViewModel(id) } この ViewModel どこにスコープされる? What is this ViewModel scoped to? 56

Slide 57

Slide 57 text

@Composable fun MemoDetailScreen( id: Long, onEditClick: () -> Unit, modifier: Modifier = Modifier, ) { val viewModel = viewModel { MemoDetailViewModel(id) } この ViewModel どこにスコープされる? → MainActivity にスコープされてしまう What is this ViewModel scoped to? → It ends up scoped to MainActivity 57

Slide 58

Slide 58 text

@Composable fun MemoDetailScreen( id: Long, onEditClick: () -> Unit, modifier: Modifier = Modifier,この ViewModel どこにスコープされる? ) { → MainActivity にスコープされてしまう val viewModel = viewModel { MemoDetailViewModel(id) } メモ1を開いた後にメモ2を開くとメモ1の内容が 表示される 画面を pop しても onCleared() が呼ばれない What is this ViewModel scoped to? → It ends up scoped to MainActivity Open memo 1 then memo 2 → memo 1's content shows up. Popping never calls onCleared() 58

Slide 59

Slide 59 text

@Composable public fun viewModel( modelClass: KClass, viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { … }, … ): VM { … val provider = ViewModelProvider.create(viewModelStoreOwner, …) return if (key != null) { provider[key, modelClass] } else { provider[modelClass] } } 59

Slide 60

Slide 60 text

@Composable public fun viewModel( modelClass: KClass, viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { … }, … ): VM { … val provider = ViewModelProvider.create(viewModelStoreOwner, …) LocalViewModelStoreOwner を上書きしていない return if (key != null) { provider[key, modelClass]場合、親の Activity が ViewModelStoreOwner } else { として返る provider[modelClass] } } Without an override, LocalViewModelStoreOwner falls back to the parent Activity 60

Slide 61

Slide 61 text

@Composable public fun viewModel( modelClass: KClass, viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { … }, … ): VM { … val provider = ViewModelProvider.create(viewModelStoreOwner, …) LocalViewModelStoreOwner を上書きしていない return if (key != null) { provider[key, modelClass]場合、親の Activity が ViewModelStoreOwner } else { として返る provider[modelClass] } } → back stack のライフサイクルで LocalViewModelStoreOwner を上書きすればよい → Override LocalViewModelStoreOwner with a back-stackscoped owner 61

Slide 62

Slide 62 text

v7 Route ごとに ViewModelStore を持つ One ViewModelStore per Route 62

Slide 63

Slide 63 text

v7 private class EntryViewModelStores : ViewModel() { private val stores = mutableMapOf() fun storeFor(route: Route): ViewModelStore { return stores.getOrPut(route) { ViewModelStore() } } fun clear(route: Route) { stores.remove(route)?.clear() } back stack の Route ごとに ViewModelStore を持つ override fun onCleared() { stores.values.forEach { it.clear() } stores.clear() } } 63 ViewModel A ViewModel holding one ViewModelStore per Route in the back stack

Slide 64

Slide 64 text

val stores: EntryViewModelStores = viewModel { EntryViewModelStores() } … fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { stores.clear(popped) stateHolder.removeState(popped.stateKey) } } v7 Route ごとの ViewModelStore 置き場は Activity スコープで保持 The per-Route store holder itself is Activity-scoped … val route = backStack.last() stateHolder.SaveableStateProvider(key = route.stateKey) { val owner = remember(route) { EntryViewModelStoreOwner(stores.storeFor(route)) } CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { when (route) { … } } } 64

Slide 65

Slide 65 text

val stores: EntryViewModelStores = viewModel { EntryViewModelStores() } … v7 fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { stores.clear(popped) stateHolder.removeState(popped.stateKey) } back stack から消えたら対応す } る ViewModelStore をクリア … val route = backStack.last() Clear the ViewModelStore when its Route leaves the back stack stateHolder.SaveableStateProvider(key = route.stateKey) { val owner = remember(route) { EntryViewModelStoreOwner(stores.storeFor(route)) } CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { when (route) { … } } } 65

Slide 66

Slide 66 text

val stores: EntryViewModelStores = viewModel { EntryViewModelStores() } … v7 fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { stores.clear(popped) stateHolder.removeState(popped.stateKey) private class EntryViewModelStoreOwner( } override val viewModelStore: ViewModelStore, } ) : ViewModelStoreOwner … val route = backStack.last() stateHolder.SaveableStateProvider(key = route.stateKey) { val owner = remember(route) { EntryViewModelStoreOwner(stores.storeFor(route)) } CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { when (route) { … Route に対応する ViewModelStoreOwner セット } } Provide the ViewModelStoreOwner for the current Route } 66

Slide 67

Slide 67 text

val stores: EntryViewModelStores = viewModel { EntryViewModelStores() } … v7 fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { stores.clear(popped) stateHolder.removeState(popped.stateKey) } } … val route = backStack.last() stateHolder.SaveableStateProvider(key = route.stateKey) { val owner = remember(route) { EntryViewModelStoreOwner(stores.storeFor(route)) } CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { when (route) { … } } } 67

Slide 68

Slide 68 text

val viewModelStoreProvider = rememberViewModelStoreProvider() … fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { viewModelStoreProvider.clearKey(popped) stateHolder.removeState(popped.stateKey) } 自作した EntryViewModelStores・owner・clear を } lifecycle-viewmodel 2.11 の公式 API に置き換え … val route = backStack.last() Our hand-rolled EntryViewModelStores, owner, and clear — replaced with o cial lifecycle-viewmodel 2.11 APIs ffi stateHolder.SaveableStateProvider(key = route.stateKey) { val owner = rememberViewModelStoreOwner(route, viewModelStoreProvider) CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { when (route) { … } } lifecycle 2.11.0 } 68 v7

Slide 69

Slide 69 text

Nav3 との比較 Comparison with Nav3 69

Slide 70

Slide 70 text

自作 val viewModelStoreProvider = rememberViewModelStoreProvider() fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { viewModelStoreProvider.clearKey(popped) stateHolder.removeState(popped.stateKey) } } Nav3 public class ViewModelStoreNavEntryDecorator public constructor(viewModelStoreProvider: ViewModelStoreProvider) : NavEntryDecorator( onPop = { key -> viewModelStoreProvider.clearKey(key) }, decorate = { entry -> … }, ) { } 70

Slide 71

Slide 71 text

自作 val viewModelStoreProvider = rememberViewModelStoreProvider() fun pop() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { viewModelStoreProvider.clearKey(popped) stateHolder.removeState(popped.stateKey) } } Nav3 public class ViewModelStoreNavEntryDecorator public constructor(viewModelStoreProvider: ViewModelStoreProvider) : NavEntryDecorator( onPop = { key -> viewModelStoreProvider.clearKey(key) }, decorate = { entry -> … }, ) { } 71

Slide 72

Slide 72 text

自作 stateHolder.SaveableStateProvider(key = route.stateKey) { val owner = rememberViewModelStoreOwner(route, viewModelStoreProvider) CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { when (route) { … } } } Nav3 public class ViewModelStoreNavEntryDecorator public constructor(viewModelStoreProvider: ViewModelStoreProvider) : NavEntryDecorator( onPop = { … }, decorate = { entry -> val owner = rememberViewModelStoreOwner( entry.contentKey, viewModelStoreProvider, savedStateRegistryOwner = LocalSavedStateRegistryOwner.current, ) CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { entry.Content() } }, 72 ) {

Slide 73

Slide 73 text

Nav3 @Composable public fun rememberViewModelStoreNavEntryDecorator( viewModelStoreOwner: ViewModelStoreOwner = … ): ViewModelStoreNavEntryDecorator { val viewModelStoreProvider = rememberViewModelStoreProvider(parent = viewModelStoreOwner) return rememberViewModelStoreNavEntryDecorator(viewModelStoreProvider) } Nav3 で NavDisplay( … entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), ), … ) 73 ViewModel を使う 場合は必ず追加 Must be added whenever you use ViewModels with Nav3

Slide 74

Slide 74 text

タブレットで2ペインにしたい We want two panes on tablets 74 問題 6/7

Slide 75

Slide 75 text

back stack のどの要素を表示するか back stack Which back stack elements to show w < 600dp w >= 600dp [List] [List] [List] [List, Detail] [Detail] [List, Detail] [List, Detail, Edit] [Edit] [Edit] 75

Slide 76

Slide 76 text

back stack のどの要素を表示するか back stack Which back stack elements to show Default: show only last() w >= 600dp [List] [List] null [List, Detail] [Detail] [List, Detail] [List, Detail, Edit] [Edit] null

Slide 77

Slide 77 text

back stack のどの要素を表示するか back stack Which back stack elements to show Default: show only last() w >= 600dp [List] [List] null [List, Detail] [Detail] [List, Detail] [List, Detail, Edit] [Edit] null Scene : 要素をどう表示するか SceneStrategy : back stack から Scene を計算 how to display the elements computes a Scene from the back stack 77

Slide 78

Slide 78 text

v8 見せ方を Scene として計算する Compute the presentation as a Scene 78

Slide 79

Slide 79 text

v8 class RouteEntry( val route: Route, val content: @Composable () -> Unit, ) Route と Composable の入れ物を用意 A container pairing a Route with its composable 79

Slide 80

Slide 80 text

v8 class RouteEntry( val route: Route, Route に応じた画面の出し分けを lambda 化 val content: @Composable () -> Unit, Turn the route-to-screen when into a lambda ) val entryProvider: (Route) -> RouteEntry = { route -> when (route) { Route.MemoList -> RouteEntry(route) { MemoListScreen(…) } is Route.MemoDetail -> RouteEntry(route) { MemoDetailVmScreen(…) } is Route.MemoEdit -> RouteEntry(route) { MemoEditScreen(…) } } } 80

Slide 81

Slide 81 text

v8 @Composable private fun EntryContent( route: Route, viewModelStoreProvider: ViewModelStoreProvider, stateHolder: SaveableStateHolder, content: @Composable () -> Unit, ) { stateHolder.SaveableStateProvider(key = route.stateKey) { val owner = rememberViewModelStoreOwner(route, viewModelStoreProvider) CompositionLocalProvider(LocalViewModelStoreOwner provides owner) { content() } } } 状態保存と ViewModel の対応部分を wrapper とし て Composable 化 Extract the state-saving + ViewModel wiring into a wrapper composable 81

Slide 82

Slide 82 text

v8 val entryProvider: (Route) -> RouteEntry = { route -> backStack のままでは key が変 when (route) { 化しない … } toList() は内容が変わったとき } だけ別インスタンス val entries = remember(backStack.toList()) { backStack never changes instance — backStack.map { route -> toList() does, exactly when the content RouteEntry(route) { changes EntryContent(route, viewModelStoreProvider, stateHolder) { entryProvider(route).content() } } } 状態保存、ViewModel 対応の Composable } で wrap Wrap with the state-saving / ViewModel composable 82

Slide 83

Slide 83 text

v8 interface Scene { val entries: List @Composable fun Content() } class SinglePaneScene( private val entry: RouteEntry ) : Scene { override val entries: List get() = listOf(entry) @Composable override fun Content() { entry.content() } } 83

Slide 84

Slide 84 text

v8 class TwoPaneScene( private val list: RouteEntry, private val detail: RouteEntry, ) : Scene { override val entries: List get() = listOf(list, detail) @Composable override fun Content() { Row(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.weight(1f)) { list.content() } Box(modifier = Modifier.weight(1f)) { detail.content() } } } } 左右に半分ずつ表示 Side by side, half width each 84

Slide 85

Slide 85 text

v8 interface SceneStrategy { fun calculateScene(entries: List): Scene? } class SinglePaneSceneStrategy : SceneStrategy { override fun calculateScene(entries: List): Scene { return SinglePaneScene(entries.last()) } } back stack の一番上の Route を SinglePaneScene で表示 Show the top Route in a SinglePaneScene 85

Slide 86

Slide 86 text

v8 class TwoPaneSceneStrategy( private val isWide: Boolean ) : SceneStrategy { override fun calculateScene(entries: List): Scene? { if (!isWide) return null val detail = entries.last() .takeIf { it.route is Route.MemoDetail } ?: return null val list = entries .lastOrNull { it.route is Route.MemoList } ?: return null return TwoPaneScene( list = list, detail = detail, ) } } 86

Slide 87

Slide 87 text

v8 class TwoPaneSceneStrategy( private val isWide: Boolean ) : SceneStrategy { override fun calculateScene(entries: List): Scene? { if (!isWide) return null val detail = entries.last() .takeIf { it.route is Route.MemoDetail } ?: return null val list = entries .lastOrNull { it.route is Route.MemoList } ?: return null return TwoPaneScene( list = list, detail = detail, ) } } 一番上が MemoDetail で back stack に MemoList があるときだけ TwoPaneScene で表示 TwoPaneScene only when the top is MemoDetail and MemoList is in the stack 87

Slide 88

Slide 88 text

v8 class TwoPaneSceneStrategy( private val isWide: Boolean ) : SceneStrategy { override fun calculateScene(entries: List): Scene? { if (!isWide) return null val detail = entries.last() .takeIf { it.route is Route.MemoDetail } ?: return null val list = entries .lastOrNull { it.route is Route.MemoList } ?: return null return TwoPaneScene( list = list, detail = detail, ) } } Nav3 では NavEntry の metadata を使って 汎用化 Nav3 generalizes this with NavEntry metadata 88

Slide 89

Slide 89 text

v8 val entries = remember(backStack.toList()) { backStack.map { route -> RouteEntry(route) { 横幅が 600dp 以上なら wide というロジックで EntryContent(route, viewModelStoreProvider, stateHolder) { TwoPaneSceneStrategy を生成 entryProvider(route).content() } wide = width >= 600dp → create TwoPaneSceneStrategy } } } val containerWidthPx = LocalWindowInfo.current.containerSize.width val isWide = with(LocalDensity.current) { containerWidthPx.toDp() } >= 600.dp val twoPaneSceneStrategy = remember(isWide) { TwoPaneSceneStrategy(isWide) } val defaultStrategy = remember { SinglePaneSceneStrategy() } val scene = twoPaneSceneStrategy.calculateScene(entries) ?: defaultStrategy.calculateScene(entries) scene.Content() 89

Slide 90

Slide 90 text

v8 val entries = remember(backStack.toList()) { backStack.map { route -> RouteEntry(route) { TwoPaneSceneStrategy が null を返すなら EntryContent(route, viewModelStoreProvider, stateHolder) { SinglePaneSceneStrategy にフォールバック entryProvider(route).content() } If it returns null, fall back to SinglePaneSceneStrategy } } } val containerWidthPx = LocalWindowInfo.current.containerSize.width val isWide = with(LocalDensity.current) { containerWidthPx.toDp() } >= 600.dp val twoPaneSceneStrategy = remember(isWide) { TwoPaneSceneStrategy(isWide) } val defaultStrategy = remember { SinglePaneSceneStrategy() } val scene = twoPaneSceneStrategy.calculateScene(entries) ?: defaultStrategy.calculateScene(entries) scene.Content() 90

Slide 91

Slide 91 text

91

Slide 92

Slide 92 text

Nav3 との比較 Comparison with Nav3 92

Slide 93

Slide 93 text

自作 interface Scene { val entries: List @Composable fun Content() } Nav3 @Immutable public interface Scene { public val key: Any public val entries: List> public val previousEntries: List> public val content: @Composable () -> Unit public val metadata: Map get() = entries.lastOrNull()?.metadata ?: emptyMap() } 93

Slide 94

Slide 94 text

自作 interface SceneStrategy { fun calculateScene(entries: List): Scene? } Nav3 @Immutable public fun interface SceneStrategy { public fun SceneStrategyScope.calculateScene( entries: List> ): Scene? } 94

Slide 95

Slide 95 text

自作 class SinglePaneSceneStrategy : SceneStrategy { override fun calculateScene(entries: List): Scene { return SinglePaneScene(entries.last()) } } Nav3 public class SinglePaneSceneStrategy : SceneStrategy { override fun SceneStrategyScope.calculateScene( entries: List> ): Scene { return SinglePaneScene( key = entries.last().contentKey, entry = entries.last(), previousEntries = entries.dropLast(1), ) } } 95

Slide 96

Slide 96 text

Nav3 @Composable public fun NavDisplay( backStack: List, … sceneStrategies: List> = …, … ) { … } internal fun calculateSceneWithSinglePaneFallback( sceneStrategies: List>, scope: SceneStrategyScope, entries: List>, ): Scene { var scene: Scene? = null for (index in sceneStrategies.indices) { scene = with(sceneStrategies[index]) { scope.calculateScene(entries) } if (scene != null) break } return scene ?: with(SinglePaneSceneStrategy()) { scope.calculateScene(entries) } } 96

Slide 97

Slide 97 text

マルチモジュールに対応したい We want multi-module support 97 問題 7/7

Slide 98

Slide 98 text

マルチモジュール構成 :app Multi-module structure • :app • :core:navigation :feature:memo :feature:setting List / Detail / Edit Setting • Route, … • :feature:memo :core:navigation Route / RouteEntry / Scene/ Navigator • List, Detail, Edit • :feature:setting • Setting 各画面に対応する Route はそれぞれのモジュールに定義したい Each screen's Route should live in its own module •… 98

Slide 99

Slide 99 text

Route を sealed にできない Route can't be sealed anymore ❌ :core:navigation sealed interface Route :feature:setting @Serializable data object SettingRoute : Route 99 interface Route

Slide 100

Slide 100 text

Route の復元対応が必要 We need to restore Routes ourselves • sealed(closed polymorphism)なら serializer() が自動生成される sealed (closed polymorphism) auto-generates serializer() • open polymorphism では「Route のサブタイプ一覧」をコンパイル時に知る方法がない With open polymorphism, no compile-time list of Route subtypes exists → KSerializer を自作する( = Nav3 の NavKeySerializer) → Hand-write a KSerializer (= Nav3's NavKeySerializer) or → SerializersModule に全サブタイプを登録(:app に登録リストを集める配線 = KMP 版 Nav3 の方法) → Register every subtype in a SerializersModule (collected in :app — how KMP Nav3 works) 100

Slide 101

Slide 101 text

TwoPaneSceneStrategy の汎用化 Generalizing TwoPaneSceneStrategy • :core:navigation に定義したい We want it in :core:navigation • :core:navigation から :feature:memo モジュールの Route は見えない :core:navigation can't see :feature:memo's Routes • RouteEntry に Two pane の役割(List/Detail)を申告させる仕組みが必要 RouteEntry needs a way to declare its two-pane role (List/Detail) 101

Slide 102

Slide 102 text

v9 モジュールごとに分けて組み立て直す Rebuild module by module 102

Slide 103

Slide 103 text

:core:navigation v9 interface Route class RouteEntry( val route: Route, val metadata: Map = emptyMap(), val content: @Composable () -> Unit, ) 103

Slide 104

Slide 104 text

:core:navigation v9 interface Scene … class SinglePaneScene … class TwoPaneScene … interface SceneStrategy … class SinglePaneSceneStrategy … 104

Slide 105

Slide 105 text

:core:navigation v9 class TwoPaneSceneStrategy( private val isWide: Boolean ) : SceneStrategy { private enum class Role { List, Detail } companion object { private const val ROLE_KEY = "TwoPaneSceneStrategy.role" fun listPane(): Map = mapOf(ROLE_KEY to Role.List) fun detailPane(): Map = mapOf(ROLE_KEY to Role.Detail) } override fun calculateScene(entries: List): Scene? { if (!isWide) return null val detail = entries.last() .takeIf { it.metadata[ROLE_KEY] == Role.Detail } ?: return null val list = entries .lastOrNull { it.metadata[ROLE_KEY] == Role.List } ?: return null return TwoPaneScene(list, detail) } } 105

Slide 106

Slide 106 text

:core:navigation v9 class TwoPaneSceneStrategy( private val isWide: Boolean ) : SceneStrategy { private enum class Role { List, Detail } companion object { private const val ROLE_KEY = "TwoPaneSceneStrategy.role" fun listPane(): Map = mapOf(ROLE_KEY to Role.List) fun detailPane(): Map = mapOf(ROLE_KEY to Role.Detail) } override fun calculateScene(entries: List): Scene? { if (!isWide) return null val detail = entries.last() .takeIf { it.metadata[ROLE_KEY] == Role.Detail } ?: return null val list = entries .lastOrNull { it.metadata[ROLE_KEY] == Role.List } ?: return null return TwoPaneScene(list, detail) } } 106

Slide 107

Slide 107 text

:core:navigation v9 class TwoPaneSceneStrategy( private val isWide: Boolean ) : SceneStrategy { private enum class Role { List, Detail } companion object { private const val ROLE_KEY = "TwoPaneSceneStrategy.role" fun listPane(): Map = mapOf(ROLE_KEY to Role.List) fun detailPane(): Map = mapOf(ROLE_KEY to Role.Detail) } override fun calculateScene(entries: List): Scene? { if (!isWide) return null val detail = entries.last() .takeIf { it.metadata[ROLE_KEY] == Role.Detail } ?: return null val list = entries .lastOrNull { it.metadata[ROLE_KEY] == Role.List } ?: return null return TwoPaneScene(list, detail) } } 107

Slide 108

Slide 108 text

:core:navigation v9 class TwoPaneSceneStrategy( private val isWide: Boolean ) : SceneStrategy { private enum class Role { List, Detail } companion object { private const val ROLE_KEY = "TwoPaneSceneStrategy.role" fun listPane(): Map = mapOf(ROLE_KEY to Role.List) fun detailPane(): Map = mapOf(ROLE_KEY to Role.Detail) } override fun calculateScene(entries: List): Scene? { if (!isWide) return null val detail = entries.last() .takeIf { it.metadata[ROLE_KEY] == Role.Detail } ?: return null val list = entries .lastOrNull { it.metadata[ROLE_KEY] == Role.List } ?: return null return TwoPaneScene(list, detail) } } 108

Slide 109

Slide 109 text

:core:navigation v9 class Navigator( private val backStack: SnapshotStateList, private val onRemoved: (Route) -> Unit, ) { fun goTo(route: Route) { backStack.add(route) } fun back() { val popped = backStack.removeLastOrNull() if (popped != null && popped !in backStack) { onRemoved(popped) } } } pop 時の掃除ができるように So cleanup can run on pop 109

Slide 110

Slide 110 text

:app v9 val viewModelStoreProvider = rememberViewModelStoreProvider() val stateHolder = rememberSaveableStateHolder() val navigator = remember(backStack) { Navigator(backStack) { removed -> viewModelStoreProvider.clearKey(removed) stateHolder.removeState(removed.stateKey) } } pop 時に ViewModelStore のクリアと 保存されている状態の削除を行う On pop, clear the ViewModelStore and remove the saved state 110

Slide 111

Slide 111 text

:feature:memo v9 sealed interface MemoRoute : Route @Serializable data object MemoListRoute : MemoRoute @Serializable data class MemoDetailRoute(val id: Long) : MemoRoute @Serializable data class MemoEditRoute(val id: Long?) : MemoRoute 111

Slide 112

Slide 112 text

:feature:memo v9 fun memoEntryProvider( navigator: Navigator, onSettingClick: () -> Unit, ): (Route) -> RouteEntry? = { route -> if (route is MemoRoute) { when (route) { is MemoListRoute -> RouteEntry(route, …) { MemoListScreen( onMemoClick = { id -> navigator.goTo(MemoDetailRoute(id)) }, onAddClick = { navigator.goTo(MemoEditRoute(null)) }, onSettingClick = onSettingClick, ) } is MemoDetailRoute -> … is MemoEditRoute -> … } } else { null } } 112

Slide 113

Slide 113 text

:feature:memo v9 fun memoEntryProvider( navigator: Navigator, onSettingClick: () -> Unit, ): (Route) -> RouteEntry? = { route -> if (route is MemoRoute) { when (route) { is MemoListRoute -> RouteEntry(route, …) { MemoListScreen( onMemoClick = { id -> navigator.goTo(MemoDetailRoute(id)) }, onAddClick = { navigator.goTo(MemoEditRoute(null)) }, onSettingClick = onSettingClick, ) } is MemoDetailRoute -> … is MemoEditRoute -> … } } else { null } } 113

Slide 114

Slide 114 text

:feature:memo v9 fun memoEntryProvider( navigator: Navigator, onSettingClick: () -> Unit, ): (Route) -> RouteEntry? = { route -> if (route is MemoRoute) { when (route) { is MemoListRoute -> RouteEntry( route, metadata = TwoPaneSceneStrategy.listPane() ) { … } is MemoDetailRoute -> RouteEntry( route, metadata = TwoPaneSceneStrategy.detailPane() ) { … } is MemoEditRoute -> … } } else { null } } 114

Slide 115

Slide 115 text

:feature:memo v9 Navigator によってメモ画面間の fun memoEntryProvider( 遷移はモジュール内で完結できる navigator: Navigator, onSettingClick: () -> Unit, With the Navigator, memo-internal ): (Route) -> RouteEntry? = { route -> navigation stays inside the module if (route is MemoRoute) { when (route) { is MemoListRoute -> RouteEntry(route, …) { MemoListScreen( onMemoClick = { id -> navigator.goTo(MemoDetailRoute(id)) }, onAddClick = { navigator.goTo(MemoEditRoute(null)) }, onSettingClick = onSettingClick, ) } is MemoDetailRoute -> … is MemoEditRoute -> … } } else { null } } 115

Slide 116

Slide 116 text

:feature:setting v9 @Serializable data object SettingRoute : Route fun settingEntryProvider( navigator: Navigator, ): (Route) -> RouteEntry? = { route -> if (route is SettingRoute) { RouteEntry(route) { SettingScreen( onBack = { navigator.back() } ) } } else { null } 前の画面に戻る遷移も Navigator で } Going back also goes through the Navigator 116

Slide 117

Slide 117 text

:app v9 val backStack = rememberSerializable( serializer = SnapshotStateListSerializer(RouteSerializer), ) { mutableStateListOf(MemoListRoute) } … val navigator = remember(backStack) { Navigator(backStack) { … } } 自作 KSerializer NavKeySerializer の Route 版 Our own KSerializer — the Route version of NavKeySerializer BackHandler(enabled = backStack.size > 1) { navigator.back() } val providers: List<(Route) -> RouteEntry?> = listOf( memoEntryProvider( navigator = navigator, onSettingClick = { navigator.goTo(SettingRoute) }, 117 ),

Slide 118

Slide 118 text

:app v9 val backStack = … … val navigator = remember(backStack) { Navigator(backStack) { … } } BackHandler(enabled = backStack.size > 1) { navigator.back() } val providers: List<(Route) -> RouteEntry?> = listOf( memoEntryProvider( navigator = navigator, onSettingClick = { navigator.goTo(SettingRoute) }, ), settingEntryProvider( navigator = navigator, ), ) 118 val entries = remember(backStack.toList()) {

Slide 119

Slide 119 text

:app v9 val backStack = … … val navigator = remember(backStack) { Navigator(backStack) { … } } 各 feature module の lambda を集約 BackHandler(enabled = backStack.size > 1) { navigator.back() } Aggregate each feature module's lambda val providers: List<(Route) -> RouteEntry?> = listOf( memoEntryProvider( navigator = navigator, onSettingClick = { navigator.goTo(SettingRoute) }, ), settingEntryProvider( navigator = navigator, ), ) 119 val entries = remember(backStack.toList()) {

Slide 120

Slide 120 text

:app v9 val providers: List<(Route) -> RouteEntry?> = listOf( … ) val entries = remember(backStack.toList()) { backStack.map { route -> val entry = providers.firstNotNullOfOrNull { it(route) } ?: error("Unknown route: $route") RouteEntry(route, metadata = entry.metadata) { EntryContent(route, viewModelStoreProvider, stateHolder) { entry.content() } } } Route に対応する RouteEntry を取得 } Resolve the RouteEntry for each Route … scene.Content() 120

Slide 121

Slide 121 text

Nav3 との比較 Comparison with Nav3 121

Slide 122

Slide 122 text

自作 class RouteEntry( val route: Route, // v8 val metadata: Map = emptyMap(), // v9 val content: @Composable () -> Unit, // v8 ) private val Route.stateKey: String get() = toString() // v6 Nav3 public class NavEntry( private val key: T, public val contentKey: Any = defaultContentKey(key), public val metadata: Map = emptyMap(), private val content: @Composable (T) -> Unit, ) { … } // defaultContentKey(key) // <= 1.1.5: = key.toString() // 1.1.6+: = Pair("$key", "${key::class}") 4つのフィールドすべてに自作の 対応物がある All four elds map to something we built ourselves fi 122

Slide 123

Slide 123 text

自作 Nav3 // :app — feature の lambda を並べて合成 // :app — feature の登録関数を並べるだけ val providers = listOf( memoEntryProvider(…), settingEntryProvider(…), ) NavDisplay( backStack = backStack, entryProvider = entryProvider { memoEntries(…) settingEntries(…) }, ) val entry = providers .firstNotNullOfOrNull { it(route) } ?: error("Unknown route: $route") // :feature:memo — 自分の画面群を登録 fun EntryProviderScope.memoEntries(…) { entry { MemoListScreen(…) } … } feature ごとに定義して :app で合成するのは自作と同じ形 Each feature registers its own screens and :app composes them — same shape as ours 123

Slide 124

Slide 124 text

作らなかったもの What we didn't build 124

Slide 125

Slide 125 text

Nav3 が黙って面倒を見ていること What Nav3 quietly takes care of • ここまでの自作は、2つの単純化の上に成り立っていた Our DIY relied on two simpli cations: • 「遷移は一瞬で終わる」「back stack の変更は必ず pop() を通る」 "transitions are instant" and "every change goes through pop()" • 実アプリで踏む3つの問題 Three problems real apps hit: • 遷移アニメーション ̶ 前後2画面が同時に composition に存在する瞬間 Transition animations — both screens in the composition at once • Predictive back ̶ ジェスチャで「途中まで戻る」 Predictive back — going "partway back" with a gesture • pop() を通らない back stack 変更 ̶ removeAt、一括置換 … Back stack changes that bypass pop() — removeAt, bulk replacement ... → 全部 Nav3 が部品で吸収している fi → Nav3's components absorb all of these 125

Slide 126

Slide 126 text

まとめ Wrap-up 126

Slide 127

Slide 127 text

Nav3 全体マップ The Nav3 map 回転・プロセス死 NavKey / rememberNavBackStack マルチモジュール NavKeySerializer / NavEntry / entryProvider DSL 画面内の状態 SaveableStateHolderNavEntryDecorator ViewModel の scope ViewModelStoreNavEntryDecorator アニメ中の lifecycle 自動適用 Decorator rotation & process death multi-module in-screen state ViewModel scoping lifecycle during animations (SceneSetup / BackStackAwareLifecycle) multi-pane Scene / SceneStrategy 127

Slide 128

Slide 128 text

で、Nav2、Nav3、はたまた自作? So — Nav2, Nav3, or build your own? • すごいシンプルな遷移だけなら自作も可能(AI もあるし) Very simple navigation? DIY is viable (and AI can help) • アニメーションや Predictive Back に対応するのは大変 Animations and predictive back are a lot of work • Nav2 で困っていないなら、急いで移行しなくていい If Nav2 isn't hurting you, there's no rush to migrate • back stack を自分で持てる Nav3 の強みが欲しいなら移行すべき If you want Nav3's "You own the back stack" strength, migrate • Nav3 を使うなら、Decorator と Scene の役割だけは理解してから If you adopt Nav3, rst understand Decorators and Scenes fi 128

Slide 129

Slide 129 text

おわり Thank you! あんざいゆき Yuki Anzai (@yanzm) 129