UI Testing Viewpager with NavGraphViewModel using Espresso - android

I currently have an app which uses a One Activity-Many Fragment approach, and within this app is a fragment which shares significant data with its children, and so I have used navGraphViewModels scoped to a nested nav graph as so:
private val viewModel by navGraphViewModels<MySharedViewModel>(R.id.nested_nav_graph)
The parent fragment contains a viewPager, and the fragments passed to that viewPager all share the same viewModel as the parent.
My issue with using this approach is that when it comes to UI testing involving navGraphViewModels using Espresso, I was getting the error "View XXX does not have a NavController set." I managed to fix this for the parent fragment with the below:
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
// This allows fragments to use by navGraphViewModels()
navController.setViewModelStore(ViewModelStore())
UiThreadStatement.runOnUiThread {
navController.setGraph(R.navigation.nested_nav_graph)
}
val scenario =
launchFragmentInContainer(themeResId = R.style.AppTheme) {
MyFragment().also { fragment ->
fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
if (viewLifecycleOwner != null) {
Navigation.setViewNavController(fragment.requireView(), navController)
}
}
}
}
return navController
}
However, as the parent fragment then loads its child fragments into the viewPager and these also require a NavController, my tests don't proceed past the #Before block. Any help regarding how to set the navController to the child fragment would be appreciated.

Related

one ViewModel per each composable page inside HorizontalPager

I'm converting my project from view system to compose. In one of app page's in old view system I have a fragment with one viewPager which just make some pages of same fragment with different data to show. While each fragment has it's own lifecycle I can have multiple isolated viewModel per each fragment. In Compose as far as I know viewModel life cycle is attached to navigation graph, therefor each time I try to access viewModel it just return same viewModel object that's created in first call. how can I achieve same view system behavior in compose?
this is simplified version of what my app is doing now
#Composable
fun MainScreen(mainViewModel: MainViewModel = hiltViewModel()) {
val pages = mainViewModel.pages.collectAsState()
val pagerState = rememberPagerState(pageCount = pages.size)
HorizontalPager(state = pagerState) {
ChildScreen()
}
}
#Composable
fun ChildScreen(childViewModel:ChildViewModel = hiltViewModel()){
}
here childViewModel is always one object for all pages

How to Add a fragment into an Activity using Jetpack Compose

How to add a Fragment to an Activity using jetpack compose, i couldn't able to find a proper documentation , Here is my activity code looks like
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposeDemoTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
RenderTextUserFields()
}
}
}
}
}
And i have a fragment name LoginFragment i want to render the LoginFragment when the Application initially loaded then i want to navigate to Another Fragment I have DetailsFragment
You can use a standard AppCompatActivity:
For example in a Scaffold you can use something like:
findNavController().navigate(R.id.nav_profile, bundle)
scope.launch {
scaffoldState.drawerState.close()
}
with:
private fun findNavController(): NavController {
val navHostFragment =
supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
return navHostFragment.navController
}
First I would recommend if you are using Fragments with JetpackCompose, to avoid using Activities.
So if if i understood correctly what you could do is define your NavigationGraph, which you can do by adding a navigation folder to your res folder, and there create nav_graph.xml(see: Android Navigation).
Set your initial fragment(in this case LoginFragment) as a starting fragment(this can be done in the design of nav_graph.xml after you add your fragments to it).
After that you can declare your nav controller in a fragment with for example val navController = findNavController() which you can pass into your composable, and upon a click or whatever you have you navigate to the desired fragment(which has to be defined in nav_graph.xml).
There are two ways to do this:
You can just call navController.navigate(R.id.yourFragmentId)
Or you can define a action between LoginFragment and DetailsFragment in nav_graph.xml(you can just connect them in the design view), and then later navigate with navController.navigate(R.id.loginToDetailActionId)

Shared ViewModel in scope of parent fragment using Navigation Component

I am trying to use the same instance of ViewModel in Parent Fragment and its children, using Navigation Component. The hierarchy is as follows: Single Activity that has navigationHost. This host has 3 child fragments, A, B and C. The last fragment has also navigationHost with 2 fragments: X and Y. The below graph illustrates the hierarchy.
Expected:
I would like to share the same instance of fragment C ViewModel with fragment X and Y.
Current:
The ViewModel of fragment C is initialized twice: Once when fragment C is initialized and second time when fragment X is initialized. The Fragment X is set as a default destination in the fragment C nav graph. When I am changing the default destination to Y, the ViewModel is initialized in C and Y.
What I tried already:
In child viewModels I use this:
val viewModel: ParentViewModel =
ViewModelProvider(findNavController().getViewModelStoreOwner(R.id.parent_graph)).get(
ParentViewModel::class.java
)
In parent viewModel I use this:
val viewModel by viewModels<ParentViewModel>()
I've also tried to inject the viewModel using Koin sharedViewModel with scope of fragment:
val viewModel by sharedViewModel<ParentViewModel>(from = { parentFragment!! })
Also no luck.
Is it possible or maybe it is a bug in navigation library?
A NavHostFragment is a fragment itself, so your structure is actually
Fragment C -> NavHostFragment -> Fragment X
-> Fragment Y
I.e., the parentFragment you get from Fragment X is not Fragment C - it is the NavHostFragment you added in between the two.
Therefore if you want to get a ViewModel from Fragment C, you'd need to use requireParentFragment().requireParentFragment() - the parent of your NavHostFragment is Fragment C.
Cannot find a parameter with this name: from
------------update----------------
for those facing the same issue, check here koin issue discuss about, and maybe here might be helpful.
I'm using
//child fragment
private val viewModel: TripParentViewModel by viewModel(owner = { ViewModelOwner.Companion.from(requireParentFragment().requireParentFragment().viewModelStore)})
//parent fragment
private val parentViewModel by viewModel<TripParentViewModel>()
as solution,
class TripParentViewModel:ViewModel() {
var count = 0
fun test(){
when(count){
0 -> Timber.d("first click")
1 -> Timber.d("second click")
2 -> Timber.d("third click")
}
Timber.d(count.toString())
count++
}
}
currently, I run this when change fragment, I didn't see any problem so far, if anything goes wrong, I will update here
koin_version = "2.2.1"
navigation_version = "2.3.5"

Fragments not added to backstack using Navigation Components

Information:
I'm programmatically inserting a NavHostFragment for each feature of the app. Each NavHostFragment has it's own Navigation Graph. Dagger is providing them by using a FragmentFactory specific to each feature. It's a Single Activity structure with MVVM architecture.
Repo: https://github.com/mitchtabian/DaggerMultiFeature/tree/nav-component-backstack-bug
checkout the branch "nav-component-backstack-bug".
The Problem
When navigating into the graph the fragments are not being added to the backstack. The only fragment that's added is whichever one has most recently been visited. So the stack size always stays at one.
Originally I thought it was because I wasn't setting the FragmentFactory to the ChildFragmentManager but that doesn't change anything. See the code snippets below for the relevant code. Or checkout the project and run it. I have logs printing out the fragments currently in the backstack from the ChildFragmentManager and also the SupportFragmentManager. Both have a constant size of 1.
Feature1NavHostFragment.kt
This is one of the custom NavHostFragment's. The create() function in the companion object is how I create them.
class Feature1NavHostFragment
#Inject
constructor(
private val feature1FragmentFactory: Feature1FragmentFactory
): NavHostFragment(){
override fun onAttach(context: Context) {
((activity?.application) as BaseApplication)
.getAppComponent()
.feature1Component()
.create()
.inject(this)
childFragmentManager.fragmentFactory = feature1FragmentFactory
super.onAttach(context)
}
companion object{
const val KEY_GRAPH_ID = "android-support-nav:fragment:graphId"
#JvmStatic
fun create(
feature1FragmentFactory: Feature1FragmentFactory,
#NavigationRes graphId: Int = 0
): Feature1NavHostFragment{
var bundle: Bundle? = null
if(graphId != 0){
bundle = Bundle()
bundle.putInt(KEY_GRAPH_ID, graphId)
}
val result = Feature1NavHostFragment(feature1FragmentFactory)
if(bundle != null){
result.arguments = bundle
}
return result
}
}
}
MainActivity.kt
This is an example of how I create the NavHostFragment's in MainActivity.
val newNavHostFragment = Feature1NavHostFragment.create(
getFeature1FragmentFactory(),
graphId
)
supportFragmentManager.beginTransaction()
.replace(
R.id.main_nav_host_container,
newNavHostFragment,
getString(R.string.NavHostFragmentTag)
)
.setPrimaryNavigationFragment(newNavHostFragment)
.commit()
Feature1MainFragment.kt
And here is an example of how I'm navigating to other fragments in the graph.
btn_go_next.setOnClickListener {
findNavController().navigate(R.id.action_feature1MainFragment_to_feature1NextFragment)
}
Summary
Like I said, in every fragment I'm printing the backstack for both the ChildFragmentManager and the SupportFragmentManager. Both have a constant size of one. It's as if the fragments are being replaced as I navigate into the graph instead of added to the stack.
Anyways, thanks for reading this and I would appreciate any insights.
Looks like a misunderstanding on my part (and a bug, also on my part).
If you loop through the fragments in the childFragmentManager it only ever shows the top-most fragment for some reason.
Example
val navHostFragment = supportFragmentManager
.findFragmentByTag(getString(R.string.NavHostFragmentTag)) as NavHostFragment
val fragments = navHostFragment.childFragmentManager.fragments
for(fragment in fragments){
// Only prints a single fragment, no matter the backstack size
}
However, if you print the backstack size like this, you will get the correct answer.
val navHostFragment = supportFragmentManager
.findFragmentByTag(getString(R.string.NavHostFragmentTag)) as NavHostFragment
val backstackCount = navHostFragment.childFragmentManager.backStackEntryCount
println("backstack count: $backstackCount")
At the end of the day this misunderstanding caused me to believe the fragments were not being added to the backstack. All is good.

Is there a way to keep fragment alive when using BottomNavigationView with new NavController?

I'm trying to use the new navigation component. I use a BottomNavigationView with the navController : NavigationUI.setupWithNavController(bottomNavigation, navController)
But when I'm switching fragments, they are each time destroy/create even if they were previously used.
Is there a way to keep alive our main fragments link to our BottomNavigationView?
Try this.
Navigator
Create custom navigator.
#Navigator.Name("custom_fragment") // Use as custom tag at navigation.xml
class CustomNavigator(
private val context: Context,
private val manager: FragmentManager,
private val containerId: Int
) : FragmentNavigator(context, manager, containerId) {
override fun navigate(destination: Destination, args: Bundle?, navOptions: NavOptions?) {
val tag = destination.id.toString()
val transaction = manager.beginTransaction()
val currentFragment = manager.primaryNavigationFragment
if (currentFragment != null) {
transaction.detach(currentFragment)
}
var fragment = manager.findFragmentByTag(tag)
if (fragment == null) {
fragment = destination.createFragment(args)
transaction.add(containerId, fragment, tag)
} else {
transaction.attach(fragment)
}
transaction.setPrimaryNavigationFragment(fragment)
transaction.setReorderingAllowed(true)
transaction.commit()
dispatchOnNavigatorNavigated(destination.id, BACK_STACK_DESTINATION_ADDED)
}
}
NavHostFragment
Create custom NavHostFragment.
class CustomNavHostFragment: NavHostFragment() {
override fun onCreateNavController(navController: NavController) {
super.onCreateNavController(navController)
navController.navigatorProvider += PersistentNavigator(context!!, childFragmentManager, id)
}
}
navigation.xml
Use custom tag instead of fragment tag.
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" android:id="#+id/navigation"
app:startDestination="#id/navigation_first">
<custom_fragment
android:id="#+id/navigation_first"
android:name="com.example.sample.FirstFragment"
android:label="FirstFragment" />
<custom_fragment
android:id="#+id/navigation_second"
android:name="com.example.sample.SecondFragment"
android:label="SecondFragment" />
</navigation>
activity layout
Use CustomNavHostFragment instead of NavHostFragment.
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="#+id/container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<fragment
android:id="#+id/nav_host_fragment"
android:name="com.example.sample.CustomNavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="#+id/bottom_navigation"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="#navigation/navigation" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="#+id/bottom_navigation"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:menu="#menu/navigation" />
</androidx.constraintlayout.widget.ConstraintLayout>
Update
I created sample project. link
I don't create custom NavHostFragment. I use navController.navigatorProvider += navigator.
Update 19.05.2021 Multiple backstack
Since Jetpack Navigation 2.4.0-alpha01 we have it out of the box.
Check Google Navigation Adavanced Sample
Old answer:
Google samples link
Just copy NavigationExtensions to your application and configure by example. Works great.
After many hours of research I found solution. It was all the time right in front of us :) There is a function: popBackStack(destination, inclusive) which navigate to given destination if found in backStack. It returns Boolean, so we can navigate there manually if the controller won't find the fragment.
if(findNavController().popBackStack(R.id.settingsFragment, false)) {
Log.d(TAG, "SettingsFragment found in backStack")
} else {
Log.d(TAG, "SettingsFragment not found in backStack, navigate manually")
findNavController().navigate(R.id.settingsFragment)
}
If you have trouble passing arguments add:
fragment.arguments = args
in class KeepStateNavigator
If you are here just to maintain the exact RecyclerView scroll state while navigating between fragments using BottomNavigationView and NavController, then there is a simple approach that is to store the layoutManager state in onDestroyView and restore it on onCreateView
I used ActivityViewModel to store the state. If you are using a different approach make sure you store the state in the parent activity or anything which survives longer than the fragment itself.
Fragment
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
recyclerview.adapter = MyAdapter()
activityViewModel.listStateParcel?.let { parcelable ->
recyclerview.layoutManager?.onRestoreInstanceState(parcelable)
activityViewModel.listStateParcel = null
}
}
override fun onDestroyView() {
val listState = planet_list?.layoutManager?.onSaveInstanceState()
listState?.let { activityViewModel.saveListState(it) }
super.onDestroyView()
}
ViewModel
var plantListStateParcel: Parcelable? = null
fun savePlanetListState(parcel: Parcelable) {
plantListStateParcel = parcel
}
I've used the link provided by #STAR_ZERO and it works fine. For those who having problem with the back button, you can handle it in the activity / nav host like this.
override fun onBackPressed() {
if(navController.currentDestination!!.id!=R.id.homeFragment){
navController.navigate(R.id.homeFragment)
}else{
super.onBackPressed()
}
}
Just check whether current destination is your root / home fragment (normally the first one in bottom navigation view), if not, just navigate back to the fragment, if yes, only exit the app or do whatever you want.
Btw, this solution need to work together with the solution link above provided by STAR_ZERO, using keep_state_fragment.
Super easy solution for custom general fragment navigation:
Step 1
Create a subclass of FragmentNavigator, overwrite instantiateFragment or navigate as you need. If we want fragment only create once, we can cache it here and return cached one at instantiateFragment method.
Step 2
Create a subclass of NavHostFragment, overwrite createFragmentNavigator or onCreateNavController, so that can inject our customed navigator(in step1).
Step 3
Replace layout xml FragmentContainerView tag attribute from android:name="com.example.learn1.navigation.TabNavHostFragment" to your customed navHostFragment(in step2).
In the latest Navigation component release - bottom navigation view will keep track of the latest fragment in stack.
Here is a sample:
https://github.com/android/architecture-components-samples/tree/main/NavigationAdvancedSample
Example code
In project build.gradle
dependencies {
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.4.0-alpha01"
}
In app build.gradle
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'androidx.navigation.safeargs'
}
dependencies {
implementation "androidx.navigation:navigation-fragment-ktx:2.4.0-alpha01"
implementation "androidx.navigation:navigation-ui-ktx:2.4.0-alpha01"
}
Inside your activity - you can setup navigation with toolbar & bottom navigation view
val navHostFragment = supportFragmentManager.findFragmentById(R.id.newsNavHostFragment) as NavHostFragment
val navController = navHostFragment.navController
//setup with bottom navigation view
binding.bottomNavigationView.setupWithNavController(navController)
//if you want to disable back icon in first page of the bottom navigation view
val appBarConfiguration = AppBarConfiguration(
setOf(
R.id.feedFragment,
R.id.favoriteFragment
)
).
//setup with toolbar back navigation
binding.toolbar.setupWithNavController(navController, appBarConfiguration)
Now in your fragment, you can navigate to your second frgment & when you deselect/select the bottom navigation item - NavController will remember your last fragment from the stack.
Example: In your Custom adapter
adapter.setOnItemClickListener { item ->
findNavController().navigate(
R.id.action_Fragment1_to_Fragment2
)
}
Now, when you press back inside fragment 2, NavController will pop fragment 1 automatically.
https://developer.android.com/guide/navigation/navigation-navigate
Not available as of now.
As a workaround you could store all your fetched data into ViewModel and have that data readily available when you recreate the fragment. Make sure you get the ViewModel object using activity context.
You can use LiveData to make your data lifecycle-aware observable data holder.
The solution provided by #piotr-prus helped me, but I had to add some current destination check:
if (navController.currentDestination?.id == resId) {
return //do not navigate
}
without this check current destination is going to recreate if you mistakenly navigate to it, because it wouldn't be found in back stack.

Categories

Resources