0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

初めに

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

仕組み

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

実装

Destination

今回のサインプルでは型安全のナビゲーションを使用しているので、sealed interfaceでデスティネーションを定義していきます。また、ネストしたナビゲーショングラフを使用していますが、NavHost内に直接composableを並べる方法でも動作します。

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、元画面へ戻るNavigateUpsealed interfaceで定義します。必要に応じてPopBackStackなども定義してください。

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>で実装します。

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関数です。

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

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

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をコンストラクタインジェクションで取得し、必要に応じて画面遷移の処理を呼び出します。

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
                    }
                }
            )
        }
    }
}
HomeViewModel.kt
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val navigator: Navigator
) : ViewModel() {

    fun navigateToDetail(id: String) {
        viewModelScope.launch {
            navigator.navigate(
                destination = Destination.DetailScreen(id),
            )
        }
    }
}
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内で画面遷移の呼び出しを行います。

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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?