Proper usage of navigation component with view pager2 and nested navigation

Viewed 6969

I am trying to build the following view structure/ navigation using ViewPager2 + FragmentStateAdapter + navigation component.

Preconditions: Single activity architecture, with one navigation graph

1. Fragment A holds a view pager. View pager uses FragmentStateAdapter.

2. Fragment B is instantiated via FragmentStateAdapter ("lives" in view pager).

3. Fragment C - should be navigated to from Fragment B. --> This is where the problem is.


Approach 1 : ViewPager2 + FragmentStateAdapter + navigation declared from Fragment B

    <fragment
        android:id="@+id/fragmentA"
        android:name="com.abc.FragmentA"
        android:label="FragmentA" />

    <fragment
        android:id="@+id/fragmentB"
        android:name="com.abc.FragmentB"
        android:label="FragmentB">
        <action
            android:id="@+id/to_fragmentC"
            app:destination="@id/fragmentC" />
    </fragment>

    <fragment
        android:id="@+id/fragmentC"
        android:name="com.abc.FragmentC"
        android:label="FragmentC" />

FragmentB executes:

 FragmentBDirections
            .toFragmentC()
            .let { findNavController().navigate(it) }

Result :

App crash
java.lang.IllegalArgumentException: navigation destination com.abc:id/to_fragmentC is unknown to this NavController

Approach 2 : ViewPager2 + FragmentStateAdapter + navigation declared from Fragment A

    <fragment
        android:id="@+id/fragmentA"
        android:name="com.abc.FragmentA"
        android:label="FragmentA" >
        <action
            android:id="@+id/to_fragmentC"
            app:destination="@id/fragmentC" />
    </fragment>

    <fragment
        android:id="@+id/fragmentB"
        android:name="com.abc.FragmentB"
        android:label="FragmentB">
    </fragment>

    <fragment
        android:id="@+id/fragmentC"
        android:name="com.abc.FragmentC"
        android:label="FragmentC" />

FragmentB executes:

 FragmentADirections
            .toFragmentC()
            .let { findNavController().navigate(it) }

Result:

App navigates to FragmentC, but when i hit the back button , it crashes with :
java.lang.IllegalArgumentException
        at androidx.core.util.Preconditions.checkArgument(Preconditions.java:36)
        at androidx.viewpager2.adapter.FragmentStateAdapter.onAttachedToRecyclerView(FragmentStateAdapter.java:132)
        at androidx.recyclerview.widget.RecyclerView.setAdapterInternal(RecyclerView.java:1209)
        at androidx.recyclerview.widget.RecyclerView.setAdapter(RecyclerView.java:1161)
        at androidx.viewpager2.widget.ViewPager2.setAdapter(ViewPager2.java:461)
        at com.abc.FragmentA.viewCreated(FragmentA.kt:69)


Approach 3 : ViewPager + FragmentStatePagerAdapter (deprecated) + navigation declared from Fragment B

The same result as approach 1.


Approach 4 : ViewPager + FragmentStatePagerAdapter (deprecated) + navigation declared from Fragment A

This one actually works. Also, the navigation back works fine.

The problem here is that:

  • Navigation has to be defined for every parent fragment of FragmentB -> not so scaleable
  • Usage of the adapter that is deprecated

If anybody knows some elegant solution to this problem, I would be very glad for any hints.

Thank you

3 Answers

You don't need to declare Fragment B as a destination in your graph, because you never use NavController to navigate to it. Instead of using an action, you can just use a destination id to implement navigation in Fragment B, like findNavController().navigate(R.id.fragmentC). The findNavController method will find the parent fragment's navController to execute the navigation.

I don't know if you found a solution to your problem but this is how I did it in my app (this is similar to what you describe for solution 4)

I have an application that has a main fragment (HomeFragment) which contains a bottom tab bar which shows four different fragments.

At any point while using the application the user can log in/log out (this is a flow that contains 4 fragments in it) answer a questionnaire (2 fragments) show settings (one fragment) or take a picture (1 fragment)

I have declared my home view model like so:

class HomeViewModel(app:Application):AndroidViewModel(app), HomeParent {
}


intefrace HomeParent { //this contains all the actions that can be executed, like showLogin, showQuestions etc etc
    fun ShowLogin()
 
}

Inside the HomeFragment I initialize the pager like so:

class HomeFragment: Fragment(), KoinComponent {
    private val vm by sharedViewModel<HomeViewModel>()

    private val pager by lazy { binding.pager }  //I just use view bindings you can do it with findviewbyid
    private val adapter by lazy { HomePageAdapter(this) }

    
    override fun initView(state:Bundle?) { //this is run before oncreate finishes
         pager.setPageTransformer(this::pageAnimation) //this is just to animate transitions
    }

    override fun setUpListeners() {  //this is run after oncreate finishes
        bottomNav.setOnNavigationItemSelectedListener(this::navigationItemSelected) //this does stuff and sends it to vm
        pager.registerOnPageChangeCallback(pagerChangeCallback)
    }

    override fun onResume() {
        super.onResume()
        pager.adapter = adapter
        (vm.state.value as? HomeState.Default)?.let { //I'm using mvi, this basically holds the current position in the pager
            pager.setCurrentItem(it.position.position, false)
        }
    }

    override fun onPause() {
        super.onPause()
        pager.adapter = null //I don't remember why I did this, I gues
    }

    override fun onDestroy() {
        super.onDestroy()
        pager.unregisterOnPageChangeCallback(pagerChangeCallback)
    }

}

the actual adapter looks like this:

class HomePageAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) {
    override fun getItemCount() = 4

    override fun createFragment(position: Int): Fragment {
        return when (position) {
            0 -> Fragment1()
            1 -> Fragment2()
            2 -> Fragment3()
            3 -> Fragment4()
            else -> throw Throwable("Invalid position $position")
        }
    }
}

each of those fragments is created like so:

class Fragment1 :Fragment(),
    private val home : HomeParent by sharedViewModel<HomeViewModel>()
    override val vm: Fragment1ViewModel by viewModel { parametersOf(home) }

as you can see I pass the home view model as a parameter to each of the tabs inside home

then each fragment can do something like

home.ShowLogin()

this will send the message to HomeViewModel which will in turn send the message to HomeFragment and it will call something like

findNavController().navigate(HomeFragmentDirections.logIn())

so as you can see Fragment1/2/3/4 are outside of the whole navigation process, and HomeFragment contains all the actions that will be performed

I hope I explained it correctly, if anyone has any questions or need clarifications let me know

General idea

You can define a Channel in your ViewModel to handle the UI events.

Let's see how we can achieve this:

  1. define your viewModel:
@HiltViewModel
class OrderViewModel @Inject constructor(
    private val repository: Repository
) : ViewModel() {
    
    private val orderEventChannel = Channel<OrderEvent>()
    val orderEvent = orderEventChannel.receiveAsFlow()

    ...

    fun onInvoiceClicked(invoice: Invoice) = viewModelScope.launch {
        orderEventChannel.send(OrderEvent.NavigateToOrderDetailsFragment(invoice))
    }

    sealed class OrderEvent{
        data class NavigateToOrderDetailsFragment(val invoice: Invoice) : OrderEvent()
    }
}
  1. Initial viewModel by activityViewModels and listen to the Channel in Fragment that contains viewPager2 view:
@AndroidEntryPoint
class OrdersFragment : Fragment(R.layout.fragment_orders) {
    private val viewModel: OrderViewModel by activityViewModels()
    
        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        ...
        
        viewLifecycleOwner.lifecycleScope.launchWhenStarted {
            viewModel.orderEvent.collect { event ->
                when(event){
                    is OrderViewModel.OrderEvent.NavigateToOrderDetailsFragment -> {
                        findNavController().navigate(
                            OrdersFragmentDirections.actionOrdersFragmentToOrderDetailsFragment(event.invoice)
                        )
                    }
                }
            }
        }
    }
  1. Now in all Fragment that bind in viewPager initial viewModel by activityViewModels and call onInvoiceClicked() function:
class InProgressViewPagerFragment : Fragment(R.layout.fragment_in_progress_view_pager){
    private val viewModel: OrderViewModel by activityViewModels()
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ...
        binding.invoice.setOnClickListener{
           viewModel.onInvoiceClicked(invoice)
        }
    }
}
Related