NavController no current navigation node after device rotation - android

I am making app, which supports different device orientations. Navigation is carried out by Android Jetpack's Navigation. App main screen for landscape orientation is present below. It is list wrapper fragment (it is NavHostFragment, it is added to activity's layout in fragment tag), wrapper includes list fragment (fragment) and details fragment (FrameLayout). Portrait orientation is similar (wrapper and list in it, details is accessible throw navigation).
My problem is after I change device orientation I get exception
java.lang.IllegalStateException: no current navigation node
First version of my layout with mocked data worked fine, the error appears after I add ROOM to my app, new order and update order fragments. It is a pity, I cannot localize source of error more exact.
List wrapper code
class OrderListWrapperFragment : RxFragment() {
private val disposable = CompositeDisposable()
var selectedOrderId: Long = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val bundle = savedInstanceState ?: arguments
bundle?.let {
selectedOrderId = it.getLong(EXTRA_ORDER_ID)
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? = inflater.inflate(R.layout.orders__list_wrapper, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initializeToolbar(toolbar, getString(R.string.orders_title), false)
newOrderButton
.clicks()
.subscribe {
findNavController()
.navigate(R.id.action_orderListWrapperFragment_to_orderNewFragment)
}
.addTo(disposable)
childFragmentManager.registerFragmentLifecycleCallbacks(callback, false)
}
override fun onDestroyView() {
super.onDestroyView()
childFragmentManager.unregisterFragmentLifecycleCallbacks(callback)
disposable.clear()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putLong(EXTRA_ORDER_ID, selectedOrderId)
}
private val callback = object : FragmentManager.FragmentLifecycleCallbacks() {
private val disposable = CompositeDisposable()
override fun onFragmentResumed(fm: FragmentManager, fragment: Fragment) {
super.onFragmentResumed(fm, fragment)
if (fragment is OrderListFragment) {
fragment
.selectedItemIdChanges
.subscribeBy(onNext = {
selectedOrderId = it
if (orderDetailsContainer != null) {
childFragmentManager.commit {
replace(
R.id.orderDetailsContainer,
OrderDetailsFragment.newInstance(it)
)
}
} else {
findNavController()
.navigate(
R.id.action_orderListWrapperFragment_to_orderDetailsFragment,
bundleOf(EXTRA_ORDER_ID to it)
)
selectedOrderId = 0
}
},
onError = {
Log.d("detailView", it.toString())
})
.addTo(disposable)
val orderId = selectedOrderId
if (orderId != 0L) {
if (orderDetailsContainer != null) {
childFragmentManager.commit {
replace(
R.id.orderDetailsContainer,
OrderDetailsFragment.newInstance(orderId)
)
}
} else {
findNavController()
.navigate(//exception throws here
R.id.action_orderListWrapperFragment_to_orderDetailsFragment,
bundleOf(EXTRA_ORDER_ID to orderId)
)
selectedOrderId = 0
}
}
}
}
override fun onFragmentPaused(fm: FragmentManager, fragment: Fragment) {
super.onFragmentPaused(fm, fragment)
if (fragment is OrderListFragment) {
disposable.clear()
}
}
}
companion object {
private const val EXTRA_ORDER_ID = "EXTRA_ORDER_ID"
}
}
My navigation graph
<?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/navigation_graph"
app:startDestination="#id/orderListWrapperFragment">
<fragment
android:id="#+id/orderListWrapperFragment"
android:name="com.mtgshipping.app.orders.orderList.OrderListWrapperFragment"
android:label="OrderListWrapperFragment"
tools:layout="#layout/orders__list_wrapper">
<action
android:id="#+id/action_orderListWrapperFragment_to_orderDetailsFragment"
app:destination="#id/orderDetailsFragment"/>
<action
android:id="#+id/action_orderListWrapperFragment_to_orderNewFragment"
app:destination="#id/orderNewFragment"/>
<action
android:id="#+id/action_orderListWrapperFragment_to_orderUpdateFragment"
app:destination="#id/orderUpdateFragment"/>
</fragment>
<fragment
android:id="#+id/orderDetailsFragment"
android:name="com.mtgshipping.app.orders.orderDetails.OrderDetailsFragment"
android:label="OrderDetailsFragment"
tools:layout="#layout/orders__order_details">
<action
android:id="#+id/action_orderDetailsFragment_to_orderUpdateFragment"
app:destination="#id/orderUpdateFragment"/>
</fragment>
<fragment
android:id="#+id/orderNewFragment"
android:name="com.mtgshipping.app.orders.orderNew.OrderNewFragment"
android:label="OrderNewFragment"
tools:layout="#layout/orders__order_new">
<action
android:id="#+id/action_orderNewFragment_to_orderListWrapperFragment"
app:destination="#id/orderListWrapperFragment"/>
</fragment>
<fragment
android:id="#+id/orderUpdateFragment"
android:name="com.mtgshipping.app.orders.orderUpdate.OrderUpdateFragment"
android:label="OrderUpdateFragment"
tools:layout="#layout/orders__order_update">
<action
android:id="#+id/action_orderUpdateFragment_to_orderListWrapperFragment"
app:destination="#id/orderListWrapperFragment"/>
</fragment>
</navigation>
I made some debug in NavController, it showed in line 746 NavDestination currentNode = mBackStack.isEmpty() ? mGraph : mBackStack.getLast().getDestination(); after device rotation mGraph is null, other private fields is also null. Maybe something prevents NavController to initialize properly. I can provide layouts and other code, if it will be need.

Thanks to Slav's comment, he was right. I updated navigation module to 2.2.0 navigation_version = '2.2.0' in app's module build.gradle
implementation "androidx.navigation:navigation-fragment-ktx:$navigation_version"
implementation "androidx.navigation:navigation-ui-ktx:$navigation_version"
After doing this problem is no longer appears, looks like that was a bug in navigation.

You can also fix it like this.
In your host activity in manifest adding this atribute:
<activity android:name=".MainActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
But the best way is change your dependencies for navigation from:
implementation "android.arch.navigation:navigation-fragment-ktx:$navigation_version"
implementation "android.arch.navigation:navigation-ui-ktx:$navigation_version"
to
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"

The problem for me was the version of lifecycle dependency
"androidx.lifecycle:lifecycle-extensions:$lifecycle_version
version '2.2.0' cause the problem, I returned to use '2.1.0'
lifecycle_version = '2.1.0'

None of these solutions fixed my issue.
I fixed it by including my nested graph into the main graph.
I have a nav_main which includes a nav_sub_1.
nav_sub_1 also includes another sub graph, nav_sub_2
When I tried to start my activity by setting nav_sub_2, the IllegalStateException occured
java.lang.IllegalStateException: no current navigation node
But this would not happen by setting nav_main or nav_sub_1.
My main graph nav_main looks like this:
<navigation
<fragment...>
<include app:graph="#navigation/nav_sub_1
<navigation/>
and nav_sub_1:
<navigation
<fragment...>
<include app:graph="#navigation/nav_sub_2
<navigation/>
I included nav_sub_2 in nav_main and the issue was fixed!
<navigation
<fragment...>
<include app:graph="#navigation/nav_sub_1
<include app:graph="#navigation/nav_sub_2
<navigation/>

For me the fragment had the wrong parent. Changing this
class MyFragment: DynamicNavHostFragment() ...
to this
class MyFragment: Fragment() ...
Fixed that issue

Related

The problem that the Safe Args's argument continues to remain when switching screens

I use Navigation to switch screens.
Move to B fragment on the A fragment screen of the bottom Write menu.
Arguments are also passed while returning back using Safe Args from the moved screen B.
In this state, if i move to another bottom menu and then return to the A screen of the Write Menu, Args is maintained as it is.
I don't know why the args are being persisted, but I don't want this.
When data comes from another screen, null comes and I want the code not to be executed.
I want the A fragment screen to receive data only from the B screen.
For this, I set null as the default value in nav_gaph, but it doesn't make sense because the args are being maintained.
Please tell me the solution and why!
A Fragment
class WriteRoutineFragment : Fragment() {
private var _binding : FragmentWriteRoutineBinding? = null
private val binding get() = _binding!!
private lateinit var adapter : RoutineAdapter
private val args : WriteRoutineFragmentArgs by navArgs()
private val vm : WriteRoutineViewModel by activityViewModels { WriteRoutineViewModelFactory() }
override fun onCreateView(inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?): View? {
_binding = FragmentWriteRoutineBinding.inflate(inflater, container, false)
adapter = RoutineAdapter(::addDetail, ::deleteDetail)
binding.rv.adapter = this.adapter
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
args.workout?.let { workout -> // here!! args is maintained..
vm.addRoutine(workout)
}
vm.items.observe(viewLifecycleOwner) { updatedItems ->
adapter.setItems(updatedItems)
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
nav_graph.xml
<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/write_routine_home"
app:startDestination="#id/writeRoutineHome">
<fragment
android:id="#+id/writeRoutine"
android:name="com.example.lightweight.fragment.WriteRoutineFragment"
android:label="fragment_write_routine"
tools:layout="#layout/fragment_write_routine" >
<action
android:id="#+id/action_writeRoutineFragment_to_workoutListTabFragment"
app:destination="#id/workoutListTabFragment" />
<argument
android:name="workout"
app:argType="string"
app:nullable="true"
android:defaultValue="#null"/>
</fragment>
</navigation>
The issue is not that the args are maintained.
But since you are using activity view models, the data is persistent in the view model.
Use this,
args.workout.let { workout -> // here!! args is maintained..
vm.addRoutine(workout)
}
The change is that we are not using safe calls(.?) anymore.
Make necessary changes in addRoutine() to accept null values if they don't accept null.

Android Navigation-Component: Back Button not navigating back to viewpager2

I have a Button in my viewpager2 (Page: UserDataFragment) which navigates the user to another screen (UserDataChangeEmailFragment), where they can change their password etc. The problem I encounter is that the back button on this screen is not working (e.g when the user presses back and or the toolbar button, the user is not navigated back to UserDataFragment).
The only error I then get is: SPAN_EXCLUSIVE_EXCLUSIVE spans cannot have a zero length.
Graph Picture (trying to navigate from x to y):
Graph Structure
UserDataHolderFragment (Holder for the ViewPager2)
|
|--UserDataFragment (First Page of the ViewPager2, here is the **Button**)
| |
| |-- UserDataChangeEmailFragment (Back button not working)
|
|
|--UserDataAddressFragment (Second Page of the ViewPager2)
Graph Code (Deleted unnecessary stuff)
<navigation android:id="#+id/nav_user_data"
app:startDestination="#id/userDataHolderFragment">
<fragment
android:id="#+id/userDataHolderFragment"
android:name="com.rsb3000.app.framework.ui.view.fragment.user.loggedIn.userdata.UserDataHolderFragment"
tools:layout="#layout/fragment_user_data_holder" >
<action
android:id="#+id/action_userDataHolderFragment_to_userDataChangeEmailFragment"
app:destination="#id/userDataChangeEmailFragment" />
<action
android:id="#+id/action_userDataHolderFragment_to_userDataChangePasswordFragment"
app:destination="#id/userDataChangePasswordFragment" />
</fragment>
<fragment
android:id="#+id/userDataFragment"
android:name="com.rsb3000.app.framework.ui.view.fragment.user.loggedIn.userdata.UserDataFragment"
tools:layout="#layout/fragment_user_data">
</fragment>
<fragment
android:id="#+id/userDataAddressFragment"
android:name="com.rsb3000.app.framework.ui.view.fragment.user.loggedIn.userdata.UserDataAddressFragment"
tools:layout="#layout/fragment_user_data_address" >
</fragment>
<fragment
android:id="#+id/userDataChangeEmailFragment"
android:name="com.rsb3000.app.framework.ui.view.fragment.user.loggedIn.userdata.UserDataChangeEmailFragment"
tools:layout="#layout/fragment_user_data_change_email" />
</navigation>
UserDataHolderFragment Code (Navigation button here)
#AndroidEntryPoint
class UserDataHolderFragment : Fragment(R.layout.fragment_user_data_holder) {
private val binding: FragmentUserDataHolderBinding by viewBinding(FragmentUserDataHolderBinding::bind)
val userDataViewPagerAdapter: UserDataViewPagerAdapter get() = UserDataViewPagerAdapter(SnackbarUtils(), this)
#Inject lateinit var tabLayoutHelper: TabLayoutHelper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.headline.userStandardToolbar.setupWithNavController(findNavController(), AppBarConfiguration(findNavController().graph))
bindObjects()
bindTablayout()
}
private fun bindObjects() {
with(binding) {
lifecycleOwner = viewLifecycleOwner
vpUserData.adapter = userDataViewPagerAdapter
}
}
private fun bindTablayout() {
tabLayoutHelper.init(binding.tlUserData, binding.vpUserData) { tab, position ->
when(position) {
0 -> tab.text = requireContext().getString(R.string.fragment_user_data_holder_user_data_tab)
1 -> tab.text = requireContext().getString(R.string.fragment_user_data_holder_user_address_tab)
}
}
}
override fun onDestroyView() {
super.onDestroyView()
tabLayoutHelper.onDestroyView()
binding.vpUserData.adapter = null
}
}
UserDataFragment
private fun bindBtnClicks() {
btnGoToChangeAddress.setOnClickListener {
findNavController().navigate(
UserDataHolderFragmentDirections.actionUserDataFragmentHolderToUserDataChangeEmailFragment()
)
}
}
UserDataChangeEmailFragment (BACK BUTTON NOT WORKING HERE)
class UserDataChangeEmailFragment : Fragment(R.layout.fragment_user_data_change_email) {
private val binding: FragmentUserDataChangeEmailBinding by viewBinding(FragmentUserDataChangeEmailBinding::bind)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.headline.userStandardToolbar.setupWithNavController(findNavController(), AppBarConfiguration(findNavController().graph))
}
}
Okay, I've solved my error. This had nothing to do with my nav_graph nor the code in the fragment. The problem was that I navigated to the other fragment within the livedata. Clicking the back button immediately triggered the observation of the livedata and navigated me again and again.
Solution: Before navigating, reset the livedata. You can see more solutions in this stackoverflow post

How do I fix `Navigation action/destination cannot be found from the current destination Destination` error in my case?

I am using bottom navigation.
In the switched screen, there is also a function to open a dialog fragment.
I also used navigation for this.
This is because, as soon as this dialog is finished, data must be delivered to the screen that opened the dialog.
I used safe args for this.
But I got the same error as the title.
i know where the error is, but i don't know exactly why it occurred.
According to a search on Stack Overflow, there are people who have had the same problem as me, but it doesn't seem to be an exact solution because the cases are different.
nav_graph
<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/nav_graph"
app:startDestination="#id/calendar">
<fragment
android:id="#+id/calendar"
android:name="com.example.writeweight.fragment.CalendarFragment"
android:label="fragment_calendar"
tools:layout="#layout/fragment_calendar" >
</fragment>
<fragment
android:id="#+id/list"
android:name="com.example.writeweight.fragment.WorkoutListFragment"
android:label="fragment_workout_list"
tools:layout="#layout/fragment_workout_list" />
<fragment
android:id="#+id/write"
android:name="com.example.writeweight.fragment.WritingRoutineFragment"
android:label="WritingRoutineFragment"
tools:layout="#layout/fragment_writing_routine">
<action
android:id="#+id/action_write_to_bodyPartDialog"
app:destination="#id/bodyPartDialog" />
<argument
android:name="title"
app:argType="string"
android:defaultValue="Write" />
</fragment>
<dialog
android:id="#+id/bodyPartDialog"
android:name="com.example.writeweight.fragment.BodyPartDialogFragment"
android:label="BodyPartDialogFragment"
tools:layout="#layout/fragment_body_part_dialog">
<action
android:id="#+id/action_bodyPartDialog_to_write"
app:destination="#id/write">
</action>
</dialog>
</navigation>
MainActivity
class MainActivity : AppCompatActivity() {
private lateinit var bottomNav: BottomNavigationView
private lateinit var navController: NavController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
bottomNav = findViewById(R.id.bottom_nav)
val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_container) as NavHostFragment
navController = navHostFragment.navController
bottomNav.setupWithNavController(navController)
}
}
DialogFragment
class BodyPartDialogFragment : DialogFragment() {
private lateinit var ll: LinearLayout
private lateinit var startBtn: Button
private lateinit var title: String
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view: View = inflater.inflate(R.layout.fragment_body_part_dialog, container, false)
initView(view)
setLayout()
startBtn?.setOnClickListener { v ->
title = BodyPartCustomView.getTitle()
val action = BodyPartDialogFragmentDirections.actionBodyPartDialogToWrite(title)
findNavController()?.navigate(action) // Possibly the location of the error.
dismiss()
}
return view
}
private fun initView(view: View) {
ll = view.findViewById(R.id.ll_body_part)
startBtn = view.findViewById(R.id.start)
}
private fun setLayout() {
ll?.apply { clipToOutline = true }
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog:Dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
dialog.setCanceledOnTouchOutside(false)
return dialog
}
override fun onDismiss(dialog: DialogInterface) {
Toast.makeText(context, "CHECK", Toast.LENGTH_SHORT).show()
}
}
It can be resolved by checking the current destination first before navigating
For example
Fragments A, B, and C
navigating from A to B while clicking on a button in fragment A that navigates to C might lead to crashes in some cases
for that you should check the current destination first as follows:
if(findNavController().currentDestination?.id==R.id.AFragment)
findNavController().navigate(
AFragmentDirections.actionAFragmentToCFragment()
)
maybe you can just make sure id in fragment navigation is same with id in menu button because i already experienced it

android jetpack navigation instrumented test fail on back navigation

I've created a simple, two fragment example app using jetpack Navigation component (androidx.navigation). First fragment navigates to second one, which overrides backbutton behavior with OnBackPressedDispatcher.
activity layout
<LinearLayout 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:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="#dimen/box_inset_layout_padding"
tools:context=".navigationcontroller.NavigationControllerActivity">
<fragment
android:name="androidx.navigation.fragment.NavHostFragment"
android:id="#+id/nav_host"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="#navigation/nav_graph" />
</LinearLayout>
FragmentA:
class FragmentA : Fragment() {
lateinit var buttonNavigation: Button
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_a, container, false)
buttonNavigation = view.findViewById<Button>(R.id.button_navigation)
buttonNavigation.setOnClickListener { Navigation.findNavController(requireActivity(), R.id.nav_host).navigate(R.id.fragmentB) }
return view
}
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".navigationcontroller.FragmentA">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="fragment A" />
<Button
android:id="#+id/button_navigation"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="go to B" />
</LinearLayout>
FragmentB:
class FragmentB : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_b, container, false)
requireActivity().onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val textView = view.findViewById<TextView>(R.id.textView)
textView.setText("backbutton pressed, press again to go back")
this.isEnabled = false
}
})
return view
}
}
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".navigationcontroller.FragmentA">
<TextView
android:id="#+id/textView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:text="fragment B" />
</FrameLayout>
Intended behavior of backbutton in FragmentB (first touch changes text without navigation, second navigates back) works fine when I test the app manually.
I've added instrumented tests to check backbutton behavior in FragmentB and that's where problems started to arise:
class NavigationControllerActivityTest {
lateinit var fragmentScenario: FragmentScenario<FragmentB>
lateinit var navController: TestNavHostController
#Before
fun setUp() {
navController = TestNavHostController(ApplicationProvider.getApplicationContext())
fragmentScenario = FragmentScenario.launchInContainer(FragmentB::class.java)
fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
override fun perform(fragment: FragmentB) {
Navigation.setViewNavController(fragment.requireView(), navController)
navController.setLifecycleOwner(fragment.viewLifecycleOwner)
navController.setOnBackPressedDispatcher(fragment.requireActivity().getOnBackPressedDispatcher())
navController.setGraph(R.navigation.nav_graph)
// simulate backstack from previous navigation
navController.navigate(R.id.fragmentA)
navController.navigate(R.id.fragmentB)
}
})
}
#Test
fun whenButtonClickedOnce_TextChangedNoNavigation() {
Espresso.pressBack()
onView(withId(R.id.textView)).check(matches(withText("backbutton pressed, press again to go back")))
assertEquals(R.id.fragmentB, navController.currentDestination?.id)
}
#Test
fun whenButtonClickedTwice_NavigationHappens() {
Espresso.pressBack()
Espresso.pressBack()
assertEquals(R.id.fragmentA, navController.currentDestination?.id)
}
}
Unfortunately, while whenButtonClickedTwice_NavigationHappens passes, whenButtonClickedOnce_TextChangedNoNavigation fails due to text not being changed, just like OnBackPressedCallback was never called. Since app works fine during manual tests, there must be something wrong with test code. Can anyone help me ?
If you're trying to test your OnBackPressedCallback logic, it is better to do that directly, rather than try to test the interaction between Navigation and the default activity's OnBackPressedDispatcher.
That would mean that you'd want to break the hard dependency between the activity's OnBackPressedDispatcher (requireActivity().onBackPressedDispatcher) and your Fragment by instead injecting in the OnBackPressedDispatcher, thus allowing you to provide a test specific instance:
class FragmentB(val onBackPressedDispatcher: OnBackPressedDispatcher) : Fragment() {
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.fragment_b, container, false)
onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val textView = view.findViewById<TextView>(R.id.textView)
textView.setText("backbutton pressed, press again to go back")
this.isEnabled = false
}
})
return view
}
}
This allows you to have your production code provide a FragmentFactory:
class MyFragmentFactory(val activity: FragmentActivity) : FragmentFactory() {
override fun instantiate(classLoader: ClassLoader, className: String): Fragment =
when (loadFragmentClass(classLoader, className)) {
FragmentB::class.java -> FragmentB(activity.onBackPressedDispatcher)
else -> super.instantiate(classLoader, className)
}
}
// Your activity would use this via:
override fun onCreate(savedInstanceState: Bundle?) {
supportFragmentManager.fragmentFactory = MyFragmentFactory(this)
super.onCreate(savedInstanceState)
// ...
}
This would mean you could write your tests such as:
class NavigationControllerActivityTest {
lateinit var fragmentScenario: FragmentScenario<FragmentB>
lateinit var onBackPressedDispatcher: OnBackPressedDispatcher
lateinit var navController: TestNavHostController
#Before
fun setUp() {
navController = TestNavHostController(ApplicationProvider.getApplicationContext())
// Create a test specific OnBackPressedDispatcher,
// giving you complete control over its behavior
onBackPressedDispatcher = OnBackPressedDispatcher()
// Here we use the launchInContainer method that
// generates a FragmentFactory from a constructor,
// automatically figuring out what class you want
fragmentScenario = launchFragmentInContainer {
FragmentB(onBackPressedDispatcher)
}
fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
override fun perform(fragment: FragmentB) {
Navigation.setViewNavController(fragment.requireView(), navController)
navController.setGraph(R.navigation.nav_graph)
// Set the current destination to fragmentB
navController.setCurrentDestination(R.id.fragmentB)
}
})
}
#Test
fun whenButtonClickedOnce_FragmentInterceptsBack() {
// Assert that your FragmentB has already an enabled OnBackPressedCallback
assertTrue(onBackPressedDispatcher.hasEnabledCallbacks())
// Now trigger the OnBackPressedDispatcher
onBackPressedDispatcher.onBackPressed()
onView(withId(R.id.textView)).check(matches(withText("backbutton pressed, press again to go back")))
// Check that FragmentB has disabled its Callback
// ensuring that the onBackPressed() will do the default behavior
assertFalse(onBackPressedDispatcher.hasEnabledCallbacks())
}
}
This avoids testing Navigation's code and focuses on testing your code and specifically your interaction with OnBackPressedDispatcher.
The reason for FragmentB's OnBackPressedCallback to be ignored is the way how OnBackPressedDispatcher treats its OnBackPressedCallbacks. They are run as chain-of-command, meaning that most recently registered one that is enabled will 'eat' the event so others will not receive it. Therefore, most recently registered callback inside FragmentScenario.onFragment() (which is enabled by lifecycleOwner, so whenever Fragment is at least in lifecycle STARTED state. Since fragment is visible during the test when backbutton is pressed, callback is always enabled at the time), will have priority over previously registered one in FragmentB.onCreateView().
Therefore, TestNavHostController's callback must be added before FragmentB.onCreateView() is executed.
This leads to changes in test code #Before method:
#Before
fun setUp() {
navController = TestNavHostController(ApplicationProvider.getApplicationContext())
fragmentScenario = FragmentScenario.launchInContainer(FragmentB::class.java, initialState = Lifecycle.State.CREATED)
fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
override fun perform(fragment: FragmentB) {
navController.setLifecycleOwner(fragment.requireActivity())
navController.setOnBackPressedDispatcher(fragment.requireActivity().getOnBackPressedDispatcher())
navController.setGraph(R.navigation.nav_graph)
// simulate backstack from previous navigation
navController.navigate(R.id.fragmentA)
navController.navigate(R.id.fragmentB)
}
})
fragmentScenario.moveToState(Lifecycle.State.RESUMED)
fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
override fun perform(fragment: FragmentB) {
Navigation.setViewNavController(fragment.requireView(), navController)
}
})
}
Most important change is to launch Fragment in CREATED state (instead of default RESUMED) to be able to tinker with it before onCreateView().
Also, notice that Navigation.setViewNavController() is run in separate onFragment() after moving fragment to RESUMED state - it accepts View parameter, so it cannot be used before onCreateView()

Navigation Architecture Component - Dialog Fragments

Is it possible to use the new Navigation Architecture Component with DialogFragment? Do I have to create a custom Navigator?
I would love to use them with the new features in my navigation graph.
May 2019 Update:
DialogFragment are now fully supported starting from Navigation 2.1.0, you can read more here and here
Old Answer for Navigation <= 2.1.0-alpha02:
I proceeded in this way:
1) Update Navigation library at least to version 2.1.0-alpha01 and copy both files of this modified gist in your project.
2) Then in your navigation host fragment, change the name parameter to your custom NavHostFragment
<fragment
android:id="#+id/nav_host_fragment"
android:name="com.example.app.navigation.MyNavHostFragment"
app:defaultNavHost="true"
app:navGraph="#navigation/nav_graph"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="#id/toolbar" />
3) Create your DialogFragment subclasses and add them to your nav_graph.xml with:
<dialog
android:id="#+id/my_dialog"
android:name="com.example.ui.MyDialogFragment"
tools:layout="#layout/my_dialog" />
4) Now launch them from fragments or activity with
findNavController().navigate(R.id.my_dialog)
or similar methods.
No, as of the 1.0.0-alpha01 build, there is no support for dialogs as part of your Navigation Graph. You should just continue to use show() to show a DialogFragment.
Yes. The framework is made in such a way that you can create a class extending the Navigator abstract class for the views that does not come out-of-the box and add it to your NavController with the method getNavigatorProvider().addNavigator(Navigator navigator)
If you are using the NavHostFragment, you will also need to extend it to add the custom Navigator or just create your own MyFragment implementing NavHost interface. It's so flexible that you can create your own xml parameters with custom attrs defined in values, like you do creating custom views. Something like this (not tested):
#Navigator.Name("dialog-fragment")
class DialogFragmentNavigator(
val context: Context,
private val fragmentManager: FragmentManager
) : Navigator<DialogFragmentNavigator.Destination>() {
override fun navigate(destination: Destination, args: Bundle?,
navOptions: NavOptions?, navigatorExtras: Extras?
): NavDestination {
val fragment = Class.forName(destination.name).newInstance() as DialogFragment
fragment.show(fragmentManager, destination.id.toString())
return destination
}
override fun createDestination(): Destination = Destination(this)
override fun popBackStack() = fragmentManager.popBackStackImmediate()
class Destination(navigator: DialogFragmentNavigator) : NavDestination(navigator) {
// The value of <dialog-fragment app:name="com.example.MyFragmentDialog"/>
lateinit var name: String
override fun onInflate(context: Context, attrs: AttributeSet) {
super.onInflate(context, attrs)
val a = context.resources.obtainAttributes(
attrs, R.styleable.FragmentNavigator
)
name = a.getString(R.styleable.FragmentNavigator_android_name)
?: throw RuntimeException("Error while inflating XML. " +
"`name` attribute is required")
a.recycle()
}
}
}
Usage
my_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/navigation"
app:startDestination="#id/navigation_home">
<fragment
android:id="#+id/navigation_assistant"
android:name="com.example.ui.HomeFragment"
tools:layout="#layout/home">
<action
android:id="#+id/action_nav_to_dialog"
app:destination="#id/navigation_dialog" />
</fragment>
<dialog-fragment
android:id="#+id/navigation_dialog"
android:name="com.example.ui.MyDialogFragment"
tools:layout="#layout/my_dialog" />
</navigation>
The fragment that will navigate.
class HomeFragment : Fragment(), NavHost {
private val navControllerInternal: NavController by lazy(LazyThreadSafetyMode.NONE){
NavController(context!!)
}
override fun getNavController(): NavController = navControllerInternal
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Built-in navigator for `fragment` XML tag
navControllerInternal.navigatorProvider.addNavigator(
FragmentNavigator(context!!, childFragmentManager, this.id)
)
// Your custom navigator for `dialog-fragment` XML tag
navControllerInternal.navigatorProvider.addNavigator(
DialogFragmentNavigator(context!!, childFragmentManager)
)
navControllerInternal.setGraph(R.navigation.my_navigation)
}
override fun onCreateView(inflater: LayoutInflater,
container: ViewGroup?, savedInstanceState: Bundle?): View? {
super.onCreateView(inflater, container, savedInstanceState)
val view = inflater.inflate(R.layout.home)
view.id = this.id
view.button.setOnClickListener{
getNavController().navigate(R.id.action_nav_to_dialog)
}
return view
}
}
Yes it is possible, You can access view of parent fragment from dialog fragment by calling getParentFragment().getView(). And use the view for navigation.
Here is the example
Navigation.findNavController(getParentFragment().getView()).navigate(R.id.nextfragment);
I created custom navigator for DialogFragment.
Sample is here.
(It's just sample, so it might be any problem.)
#Navigator.Name("dialog_fragment")
class DialogNavigator(
private val fragmentManager: FragmentManager
) : Navigator<DialogNavigator.Destination>() {
companion object {
private const val TAG = "dialog"
}
override fun navigate(destination: Destination, args: Bundle?,
navOptions: NavOptions?, navigatorExtras: Extras?) {
val fragment = destination.createFragment(args)
fragment.setTargetFragment(fragmentManager.primaryNavigationFragment,
SimpleDialogArgs.fromBundle(args).requestCode)
fragment.show(fragmentManager, TAG)
dispatchOnNavigatorNavigated(destination.id, BACK_STACK_UNCHANGED)
}
override fun createDestination(): Destination {
return Destination(this)
}
override fun popBackStack(): Boolean {
return true
}
class Destination(
navigator: Navigator<out NavDestination>
) : NavDestination(navigator) {
private var fragmentClass: Class<out DialogFragment>? = null
override fun onInflate(context: Context, attrs: AttributeSet) {
super.onInflate(context, attrs)
val a = context.resources.obtainAttributes(attrs,
R.styleable.FragmentNavigator)
a.getString(R.styleable.FragmentNavigator_android_name)
?.let { className ->
fragmentClass = parseClassFromName(context, className,
DialogFragment::class.java)
}
a.recycle()
}
fun createFragment(args: Bundle?): DialogFragment {
val fragment = fragmentClass?.newInstance()
?: throw IllegalStateException("fragment class not set")
args?.let {
fragment.arguments = it
}
return fragment
}
}
}
Version 2.1.0-alpha03 was Released so we can finally use DialogFragments. Unfortunately for me, I have some issues with the backstack when using cancelable dialogs. Probably I have a faulty implementation of my dialogs..
[LATER-EDIT] My implementation was good, the problem is related to Wrong dialog counting for DialogFragmentNavigator as is described in the issue tracker
As a workaround you can have a look on my recommendation
Updated for:
implementation "androidx.navigation:navigation-ui-ktx:2.2.0-rc04"
And use in my_nav_graph.xml
<dialog
android:id="#+id/my_dialog"
android:name="com.example.ui.MyDialogFragment"
tools:layout="#layout/my_dialog" />
Yes. It's possible in the latest update of Navigation Component. You can check this link to have a clear concept. raywenderlich.com
One option would be to just use a regular fragment and make it look similar to a dialog. I found it was not worth the hassle so I used the standard way using show(). If you insist See here for a way of doing it.
Yes, It is possible now. In it's initial release it wasn't possible but now
from "androidx.navigation:navigation-fragment:2.1.0-alpha03" this navigation version you can use dialog fragment in navigation component.
Check this out:- Naviagtion dialog fragment support

Categories

Resources