初めに
こちらの動画の説明になります。また、元の動画ではDIライブラリにKoin
を使用していますが、Androidアプリ開発でよく使われているHilt
に変更して説明します。
仕組み
NavHostController
に対する操作をNavigationAction
として定義します。Flow<NavigationAction>
を返すインターフェースNavigator
を定義しHilt
でDIできるようモジュールとして登録します。ViewModel
ではDIされたNavigator
を経由して画面遷移の処理を送信します。Composable
ではFlow<NavigationAction>
を監視し、変更があった場合、NavHostController
を使って実際の画面遷移の処理を行います。
実装
Destination
今回のサインプルでは型安全のナビゲーションを使用しているので、sealed interface
でデスティネーションを定義していきます。また、ネストしたナビゲーショングラフを使用していますが、NavHost
内に直接composable
を並べる方法でも動作します。
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
なども定義してください。
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>
で実装します。
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
関数です。
@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へのアノテーション設定は省略します。)
@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
をコンストラクタインジェクションで取得し、必要に応じて画面遷移の処理を呼び出します。
@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
}
}
)
}
}
}
@HiltViewModel
class HomeViewModel @Inject constructor(
private val navigator: Navigator
) : ViewModel() {
fun navigateToDetail(id: String) {
viewModelScope.launch {
navigator.navigate(
destination = Destination.DetailScreen(id),
)
}
}
}
@HiltViewModel
class DetailViewModel @Inject constructor(
private val navigator: Navigator
) : ViewModel() {
fun goBack() {
viewModelScope.launch {
navigator.navigateUp()
}
}
}
MainActivity
EntryPoint
からNavigator
を取得、Flow<NavigationActions>
を監視し、変更があった場合、NavigationActions
に応じて画面遷移の処理を行います。各画面ではボタン押下時にViewModel
の処理を呼び出し、ViewModel
内で画面遷移の呼び出しを行います。
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