JetpackComposeでViewModelから画面遷移を行う

# 初めに
こちらの動画の説明になります。また、元の動画ではDIライブラリに`Koin`を使用していますが、Androidアプリ開発でよく使われている`Hilt`に変更して説明します。

<iframe width="100%" height="400" src="https://www.youtube.com/embed/BFhVvAzC52w" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

# 仕組み
`NavHostController`に対する操作を`NavigationAction`として定義します。`Flow<NavigationAction>`を返すインターフェース`Navigator`を定義し`Hilt`でDIできるようモジュールとして登録します。`ViewModel`ではDIされた`Navigator`を経由して画面遷移の処理を送信します。`Composable`では`Flow<NavigationAction>`を監視し、変更があった場合、`NavHostController`を使って実際の画面遷移の処理を行います。

# 実装
## Destination
今回のサインプルでは型安全のナビゲーションを使用しているので、`sealed interface`でデスティネーションを定義していきます。また、ネストしたナビゲーショングラフを使用していますが、`NavHost`内に直接`composable`を並べる方法でも動作します。
```kotlin:Destination.kt
sealed interface Destination {
    @Serializable
    data object HomeGraph : Destination

    @Serializable
    data object AuthGraph : Destination

    @Serializable
    data object LoginScreen : Destination

    @Serializable
    data object HomeScreen : Destination

    @Serializable
    data class DetailScreen(val id: String) : Destination
}
```
## NavigationAction
特定のデスティネーションへ遷移させる`Navigate`、元画面へ戻る`NavigateUp`を`sealed interface`で定義します。必要に応じて`PopBackStack`なども定義してください。

```kotlin:NavigationAction.kt
sealed interface NavigationAction {

    data class Navigate(
        val destination: Destination,
        val navOptions: NavOptionsBuilder.() -> Unit = {}
    ) : NavigationAction

    data object NavigateUp : NavigationAction
}
```
## Navigator
画面遷移の処理、及び結果を`Flow<NavigationAction>`で取得するインターフェースの定義と、そのデフォルト実装クラスを作成します。`Flow<NavigationAction>`のコレクターは1つしか存在しないため、内部では`Channel<NavigationAction>`で実装します。

```kotlin:Navigator.kt
interface Navigator {
    val startDestination: Destination
    val navigationActions: Flow<NavigationAction>

    suspend fun navigate(
        destination: Destination,
        navOptions: NavOptionsBuilder.() -> Unit = {}
    )

    suspend fun navigateUp()
}

class DefaultNavigator(
    override val startDestination: Destination
) : Navigator {
    private val _navigationActions = Channel<NavigationAction>()
    override val navigationActions = _navigationActions.receiveAsFlow()

    override suspend fun navigate(
        destination: Destination,
        navOptions: NavOptionsBuilder.() -> Unit
    ) {
        _navigationActions.send(
            NavigationAction.Navigate(
                destination = destination,
                navOptions = navOptions
            )
        )
    }

    override suspend fun navigateUp() {
        _navigationActions.send(NavigationAction.NavigateUp)
    }
}
```
## ObserveAsEvents
`LifecycleOwner`がアクティブの間`Flow<T>`を監視し、変更があった場合に`onEvent(T)`を発行する`Composable`関数です。

```kotlin:ObserveAsEvents.kt
@Composable
fun <T> ObserveAsEvents(
    flow: Flow<T>,
    key1: Any? = null,
    key2: Any? = null,
    onEvent: (T) -> Unit
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    LaunchedEffect(key1 = lifecycleOwner.lifecycle, key1, key2) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            withContext(Dispatchers.Main.immediate) {
                flow.collect(onEvent)
            }
        }
    }
}
```

## AppModule
`Navigator`を`Hilt`のモジュールとして登録します。ナビゲーションの初期画面はここで設定します。また、`Composable`内で`Navigator`を取得するための`EntryPoint`を作成し、`EntryPoint`を取得する`Composable`関数を作成します。(Hiltを使用するための、ApplicationやAcitivityへのアノテーション設定は省略します。)

```kotlin:AppModule.kt
@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides
    @Singleton
    fun providesNavigator(): Navigator {
        return DefaultNavigator(
            startDestination = Destination.AuthGraph
        )
    }
}

private lateinit var navigatorEntryPoint: NavigatorEntryPoint

@EntryPoint
@InstallIn(SingletonComponent::class)
interface NavigatorEntryPoint {
    val navigator: Navigator
}

@Composable
fun requireNavigatorEntryPoint(): NavigatorEntryPoint {
    if (!::navigatorEntryPoint.isInitialized) {
        navigatorEntryPoint = EntryPoints.get(
            LocalContext.current.applicationContext,
            NavigatorEntryPoint::class.java
        )
    }
    return navigatorEntryPoint
}
```

## ViewModel
各画面に対応した`ViewModel`を作成します。`Navigator`をコンストラクタインジェクションで取得し、必要に応じて画面遷移の処理を呼び出します。

```kotlin:LoginViewModel.kt
@HiltViewModel
class LoginViewModel @Inject constructor(
    private val navigator: Navigator
) : ViewModel() {

    fun login() {
        viewModelScope.launch {
            navigator.navigate(
                destination = Destination.HomeGraph,
                navOptions = {
                    popUpTo(Destination.AuthGraph) {
                        inclusive = true
                    }
                }
            )
        }
    }
}
```
```kotlin:HomeViewModel.kt
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val navigator: Navigator
) : ViewModel() {

    fun navigateToDetail(id: String) {
        viewModelScope.launch {
            navigator.navigate(
                destination = Destination.DetailScreen(id),
            )
        }
    }
}
```

```kotlin:DetailViewModel.kt
@HiltViewModel
class DetailViewModel @Inject constructor(
    private val navigator: Navigator
) : ViewModel() {

    fun goBack() {
        viewModelScope.launch {
            navigator.navigateUp()
        }
    }
}
```
## MainActivity
`EntryPoint`から`Navigator`を取得、`Flow<NavigationActions>`を監視し、変更があった場合、`NavigationActions`に応じて画面遷移の処理を行います。各画面ではボタン押下時に`ViewModel`の処理を呼び出し、`ViewModel`内で画面遷移の呼び出しを行います。

```kotlin:MainActivity.kt
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
    // NavHostContollerを取得
    val navController = rememberNavController()
    // EntryPointからNavigatorを取得
    val navigator = requireNavigatorEntryPoint().navigator
    // NavigatorのFlowを監視
    ObserveAsEvents(flow = navigator.navigationActions) { action ->
        // NavigationActionsに応じて画面遷移の処理を実行
        when (action) {
            is NavigationAction.Navigate -> navController.navigate(
                action.destination
            ) {
                action.navOptions(this)
            }
            NavigationAction.NavigateUp -> navController.navigateUp()
        }
    }

    NavHost(
        navController = navController,
        // 初期画面
        startDestination = navigator.startDestination,
        modifier = Modifier.padding(innerPadding)
    ) {
        navigation<Destination.AuthGraph>(
            startDestination = Destination.LoginScreen
        ) {
            composable<Destination.LoginScreen> {
                val viewModel = hiltViewModel<LoginViewModel>()
                Box(
                    modifier = Modifier
                        .fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Button(onClick = viewModel::login) {
                        Text(text = "Login")
                    }
                }
            }
        }
        navigation<Destination.HomeGraph>(
            startDestination = Destination.HomeScreen
        ) {
            composable<Destination.HomeScreen> {
                val viewModel = hiltViewModel<HomeViewModel>()
                Box(
                    modifier = Modifier
                        .fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    Button(onClick = {
                        viewModel.navigateToDetail(UUID.randomUUID().toString())
                    }) {
                        Text(text = "Go to detail")
                    }
                }
            }
            composable<Destination.DetailScreen> {
                val viewModel = hiltViewModel<DetailViewModel>()
                val args = it.toRoute<Destination.DetailScreen>()
                Column(
                    modifier = Modifier
                        .fillMaxSize(),
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text(text = "ID: ${args.id}")
                    Button(onClick = viewModel::goBack) {
                        Text(text = "Go back")
                    }
                }
            }
        }
    }
}
```
# 終わりに
DI可能な`Navigator`経由で画面遷移の処理の呼び出しが行えることによって、画面遷移が必要な箇所へ`NavHostController`を引き渡す必要がなくなり、`NavHostController`を引き渡すことのできない`ViewModel`内からでも画面遷移を行えるようになります。また、`Navigator`の実装クラスを切り替えることで、アプリ起動時に表示される画面を切り替えることができるため、特定の画面のテストも容易になります。

・プロジェクト全体のソースコードは下記に置いてあります。
[kikw/NavigationFromViewModel](https://github.com/kikw/NavigationFromViewModel)