Search code examples
androidkotlinandroid-jetpack-composedagger-hiltandroid-jetpack-navigation

How to setup android navigation on jetpack compose using hilt with view models responsible for navigation?


I am getting this error when trying to navigate to another screen from the view model,

kotlin.UninitializedPropertyAccessException: lateinit property _navController has not been initialized

This is my activity code,

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    @Inject
    lateinit var navigator: Navigator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AssessmentAppTheme {
                // A surface container using the 'background' color from the theme
                Column(modifier = Modifier
                    .fillMaxWidth(1f)
                    .padding(vertical = 10.dp, horizontal = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
                    AssessmentApp(modifier = Modifier.padding(bottom = 40.dp))
                    NavigationGraph(navigator)
                }
            }
        }
    }
}

This is my navigation module,

@Module
@InstallIn(ActivityRetainedComponent::class)
class AppModule {

    @Provides
    fun providesNavigation() = Navigator()
}

This is my navigation class,

@ActivityRetainedScoped
class Navigator {
    private lateinit var _navController: NavHostController

    fun navigate(destination: NavigationDestination) {
        _navController.navigate(destination.route)
    }

    fun setController(controller: NavHostController) {
        _navController = controller
    }
}

this is the navigation graph where I am remembering the navController,

@Composable
fun NavigationGraph(
    navigator: Navigator
) {
    val navController = rememberNavController()
    navigator.setController(navController)
    NavHost(navController = navController, startDestination = Routes.CLIENTS_ROUTE ) {
        composable(Routes.CLIENTS_ROUTE) {
            val viewModel = hiltViewModel<ClientViewModel>()
            ClientScreen(viewModel = viewModel)
        }
        composable(Routes.ASSESSMENT_OPTIONS_ROUTE, arguments = listOf(navArgument(RouteArgs.CLIENT_ID) {type = NavType.StringType})) { backStackEntry ->
            val viewModel = hiltViewModel<ClientViewModel>()
            ClientAssessmentOptionScreen(viewModel = viewModel)
        }
}

finally, this is one of view models trying to navigate to different screen,

@HiltViewModel
class ClientViewModel @Inject constructor(
    private val repository: IClientRepository,
    private val navigator: Navigator,
    private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
    
    // Some code here //

    fun onEvent(event: ClientEvent) {
        viewModelScope.launch {
            when(event) {
                is ClientEvent.OnClientClicked -> {
                    event.client.clientName?.let {
                        navigator.navigate(
                            NavigationDestination(Routes.generateAssessmentOptionsRoute(clientId = it))
                        )
                    }
                }
            }
        }
    }
}

What am I doing wrong here? and is the approach to make view models handle navigation the right one for jetpack compose applications?


Solution

  • Just answering it here in case someone else also stumbles upon this. I have modified my navigator class and added a shared flow. Which would be used sort of as an event emitter. Whenever we would want to navigate to another screen we can use the navigate method which would emit the route destination.

    @Singleton
    class Navigator {
        private val _sharedFlow =
            MutableSharedFlow<NavigationDestination>(extraBufferCapacity = 1)
        val sharedFlow = _sharedFlow.asSharedFlow()
    
        fun navigate(destination: NavigationDestination) {
            _sharedFlow.tryEmit(destination)
        } 
    }
    

    Now in the NavigationGraph, I have remembered the NavController and have also added a launchedEffect coroutine, which would be listening to the navigate events from the flow. For each flow event, we will trigger the NavController to navigate to that emitted destination.

    @Composable
    fun NavigationGraph(
        navController: NavHostController = rememberNavController(),
        navigator: Navigator
    ) {
        LaunchedEffect("navigation") {
            navigator.sharedFlow.onEach {
                navController.navigate(it.route)
            }.launchIn(this)
        }
    
        NavHost(navController = navController, startDestination = Routes.CLIENTS_ROUTE ) {
        // some code here... //
        }
    }