According to the latest changes in Android Jetpack Navigation component NavHostFragment creates instance of NavController in his onCreate() method before any child fragment is recreated. Consequently it allows to call findNavController() even in our own fragment's onCreate() method.
Here is the official example of testing navigation call(https://developer.android.com/guide/navigation/navigation-testing):
#RunWith(AndroidJUnit4::class)
class TitleScreenTest {
#Test
fun testNavigationToInGameScreen() {
// Create a TestNavHostController
val navController = TestNavHostController(
ApplicationProvider.getApplicationContext())
// Create a graphical FragmentScenario for the TitleScreen
val titleScenario = launchFragmentInContainer<TitleScreen>()
titleScenario.onFragment { fragment ->
// Set the graph on the TestNavHostController
navController.setGraph(R.navigation.trivia)
// Make the NavController available via the findNavController() APIs
Navigation.setViewNavController(fragment.requireView(), navController)
}
// Verify that performing a click changes the NavController’s state
onView(ViewMatchers.withId(R.id.play_btn)).perform(ViewActions.click())
assertThat(navController.currentDestination?.id).isEqualTo(R.id.in_game)
}
}
In this example NavController object is set only when the fragment's view is already available.
Is there any pure standard way to test the navigation call if we have it during fragment's onCreate() method?
e.g.
class TitleScreen : Fragment(R.layout.fragment_title_screen) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
findNavController().navigate(R.id.action_title_screen_to_in_game)
}
}
Related
I implemented ViewModel driven navigation as shown in my code below. Basic idea is a Singleton class NavigationManager which is available to both, composables and the ViewModel, via dependency injection. The NavigationManager has a SharedFlow property named direction which can be changed from e.g. the ViewModel and is observed by the composables.
Now my question on this:
Is it safe to use a SharedFlow in this situation? As a SharedFlow is a hot flow and therefore can emit events while not being observed, is it possible that navigation events are lost? E.g. is it possible that a navigation event is emitted while the user rotates his phone and the NavigationManger.direction SharedFlow isn't observed for a short time (as the activity is recreated on rotation)?
// MainActivity.kt
// navigationManager.direction is observed here
// NavigationManager is a Singleton injected via dependency injection
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
#Inject
lateinit var navigationManager: NavigationManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyJetpackComposeTheme {
val navController = rememberNavController()
MyNavHost(navController)
LaunchedEffect(navigationManager.direction) {
navigationManager.direction.collect { direction ->
direction?.let {
Log.i("NavTest", "change route to: $direction")
navController.navigate(direction)
}
}
}
}
}
}
}
// The navigation manager. Instantiating it is done by the
// depdendency injection framework, not shown here for brevitiy
class NavigationManager(private val externalScope: CoroutineScope) {
private val _direction = MutableSharedFlow<String?>()
val direction : SharedFlow<String?> = _direction
fun navigate(direction: String) {
Log.d("NavTest", "navigating to $direction")
externalScope.launch {
_direction.emit(direction)
}
}
}
// triggering navigation from inside a ViewModel would be like this
// (navigationManger would be injected via dependency injection)
navigationManager.navigate("some_direction")
How would the #Composable ContentFeed() function access the viewModel which was created in the Activity? Dependency injection? Or is that a wrong way of doing things here? The viewModel should always have only have one instance.
// MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel by viewModels<MainViewModel>()
setContent {
PracticeTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
PulseApp(viewModel)
}
}
}
}
// TabItem.kt
typealias ComposableFun = #Composable () -> Unit
sealed class TabItem(var icon: Int, var title: String, var content: ComposableFun) {
object Feed : TabItem(R.drawable.ic_baseline_view_list_24, "Feed", { ContentFeed() })
}
// Content.kt
#Composable
fun ContentFeed() {
// I need viewModel created in MainActivity.kt here
}
In any composable you can use viewModel<YourClassHere>():
Returns an existing ViewModel or creates a new one in the given owner (usually, a fragment or an activity), defaulting to the owner provided by LocalViewModelStoreOwner.
The only exception in Compose for now, when it's not bind to activity/fragment, is when you're using Compose Navigation. In this case the store owner is bind to each route, see this and this answers on how you can share store owners between routes.
Check out more about view models Compose in documentation.
I have created Navigation Drawer Activity:
class MainActivity : AppCompatActivity() {
private lateinit var appBarConfiguration: AppBarConfiguration
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val toolbar: Toolbar = findViewById(R.id.toolbar)
setSupportActionBar(toolbar)
val drawerLayout: DrawerLayout = findViewById(R.id.drawer_layout)
val navView: NavigationView = findViewById(R.id.nav_view)
val navController = findNavController(R.id.nav_host_fragment)
appBarConfiguration = AppBarConfiguration(navController.graph, drawerLayout)
setupActionBarWithNavController(navController, appBarConfiguration)
navView.setupWithNavController(navController)
}
}
And I have mobile_navigation.xml:
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="#+id/mobile_navigation"
app:startDestination="#id/databaseFragment">
<fragment
android:id="#+id/databaseFragment"
android:name="com.acmpo6ou.myaccounts.ui.DatabaseFragment"
android:label="fragment_database_list"
tools:layout="#layout/fragment_database_list" >
<action
android:id="#+id/actionCreateDatabase"
app:destination="#id/createDatabaseFragment" />
</fragment>
<fragment
android:id="#+id/createDatabaseFragment"
android:name="com.acmpo6ou.myaccounts.ui.CreateDatabaseFragment"
android:label="create_edit_database_fragment"
tools:layout="#layout/create_edit_database_fragment" />
</navigation>
The start destination is DatabaseFragment. However there is a problem, here is my DatabaseFragment:
class DatabaseFragment(
override val adapter: DatabasesAdapterInter,
val presenter: DatabasesPresenterInter
) : Fragment(), DatabaseFragmentInter {
...
companion object {
#JvmStatic
fun newInstance(
adapter: DatabasesAdapterInter,
presenter: DatabasesPresenterInter
) = DatabaseFragment(adapter, presenter)
}
}
As you can see my DatabaseFragment should receive two arguments to its constructor: adapter and presenter. This is because of dependency injection, in my tests I can instantiate DatabaseFragment passing through mocked adapter and presenter. Like this:
...
val adapter = mock<DatabasesAdapterInter>()
val presenter = mock<DatabasesPresenterInter>()
val fragment = DatabaseFragment(adapter, presenter)
...
It works with tests, but it doesn't work with android navigation. It seems that Android Navigation Components create DatabaseFragment instead of me, but they don't pass any arguments to fragment's constructor and it fails with error that is too long to post it here.
Is there a way to tell Navigation Components so that they pass appropriate arguments to my fragments when instantiating them?
Thanks!
Short answer is no, you can not pass arguments to Fragment.
All subclasses of Fragment must include a public no-argument constructor. The framework will often re-instantiate a fragment class when needed, in particular during state restore, and needs to be able to find this constructor to instantiate it. If the no-argument constructor is not available, a runtime exception will occur in some cases during state restore.
I just want to add to i30mb1 answer:
Is there really a necessity for you to pass those two arguments in the constructor?
As far as I know and as far as I have experimented with MVP, each view should have a presenter. So for example when I create a new fragment, I create a new presenter for it. Then the parent activity should have another presenter. If you need that presenter so that the fragment can make changes in the Activities view, you could implement interfaces, but that's another topic.
If you ever need to pass simple arguments using navigation like POJOS or even simplier objects like Strings etc.. you can use SafeArgs https://developer.android.com/guide/navigation/navigation-pass-data
I fixed everything pretty easily using default arguments, like this:
class DatabaseFragment(
override val adapter: DatabasesAdapterInter = DatabasesAdapter(),
val presenter: DatabasesPresenterInter = DatabasesPresenter()
) : Fragment(), DatabaseFragmentInter {
...
companion object {
#JvmStatic
fun newInstance() = DatabaseFragment()
}
}
I'm implementing Espresso tests. I'm using a Fragment with a NavGraph scoped ViewModel. The problem is when I try to test the Fragment I got an IllegalStateException because the Fragment does not have a NavController set. How can I fix this problem?
class MyFragment : Fragment(), Injectable {
private val viewModel by navGraphViewModels<MyViewModel>(R.id.scoped_graph){
viewModelFactory
}
#Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
//Other stuff
}
Test class:
class FragmentTest {
class TestMyFragment: MyFragment(){
val navMock = mock<NavController>()
override fun getNavController(): NavController {
return navMock
}
}
#Mock
private lateinit var viewModel: MyViewModel
private lateinit var scenario: FragmentScenario<TestMyFragment>
#Before
fun prepareTest(){
MockitoAnnotations.initMocks(this)
scenario = launchFragmentInContainer<TestMyFragment>(themeResId = R.style.Theme_AppCompat){
TestMyFragment().apply {
viewModelFactory = ViewModelUtil.createFor(viewModel)
}
}
// My test
}
Exception I got:
java.lang.IllegalStateException: View android.widget.ScrollView does not have a NavController setjava.lang.IllegalStateException
As can be seen in docs, here's the suggested approach:
// Create a mock NavController
val mockNavController = mock(NavController::class.java)
scenario = launchFragmentInContainer<TestMyFragment>(themeResId = R.style.Theme_AppCompat) {
TestMyFragment().also { fragment ->
// In addition to returning a new instance of our Fragment,
// get a callback whenever the fragment’s view is created
// or destroyed so that we can set the mock NavController
fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
if (viewLifecycleOwner != null) {
// The fragment’s view has just been created
Navigation.setViewNavController(fragment.requireView(), mockNavController)
}
}
}
}
Thereafter you can perform verification on mocked mockNavController as such:
verify(mockNavController).navigate(SearchFragmentDirections.showRepo("foo", "bar"))
See architecture components sample for reference.
There exists another approach which is mentioned in docs as well:
// Create a graphical FragmentScenario for the TitleScreen
val titleScenario = launchFragmentInContainer<TitleScreen>()
// Set the NavController property on the fragment
titleScenario.onFragment { fragment ->
Navigation.setViewNavController(fragment.requireView(), mockNavController)
}
This approach won't work in case there happens an interaction with NavController up until onViewCreated() (included). Using this approach onFragment() would set mock NavController too late in the lifecycle, causing the findNavController() call to fail. As a unified approach which will work for all cases I'd suggest using first approach.
You are missing setting the NavController:
testFragmentScenario.onFragment {
Navigation.setViewNavController(it.requireView(), mockNavController)
}
I'm trying to learn MVVM pattern and I'm doing an example project with it. But I can't figure it out that fragments should be created in ViewModel or Activity.
I have created them in activity but whenever rotation changes it's all being recreated. This is my code:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val navView: BottomNavigationView = findViewById(R.id.nav_view)
popularFragment = FragmentPopular()
discoverFragment = FragmentDiscover()
favoritesFragment = FragmentFavorites()
setFragment(popularFragment)
navView.setOnNavigationItemSelectedListener(onNavigationItemSelectedListener)
}
fun setFragment(fragment: Fragment){
supportFragmentManager.beginTransaction().replace(R.id.frame_main, fragment).commit()
}
So how can I create them in ViewModel and whenever rotation changes fragments should stay the same.
I have created them in activity but whenever rotation changes it's all being recreated
That is perfectly normal.
So how can i create them in viewModel
You don't. You give the fragments their own ViewModel, and the ViewModel will be retained across the configuration change.