Currently, I am playing around Android Navigation Component with Bottom Navigation Bar. While playing I realized two facts:
Fragments are always recreated (onCreate, onViewCreated, onViewDestroyed are called as soon as the user navigates to another fragment)
savedInstanceState is always null (in onCreate, onViewCreated, etc.)
The first issue can be fixed by using custom FragmentNavigator, which will reuse fragment if it already exists
package am.chamich.apps.advancedbottomnavigation.navigator
import android.content.Context
import android.os.Bundle
import androidx.navigation.NavDestination
import androidx.navigation.NavOptions
import androidx.navigation.Navigator
import androidx.navigation.fragment.FragmentNavigator
#Navigator.Name("retain_state_fragment")
class RetainStateFragmentNavigator(
private val context: Context,
private val manager: androidx.fragment.app.FragmentManager,
private val containerId: Int
) : FragmentNavigator(context, manager, containerId) {
override fun navigate(
destination: Destination,
args: Bundle?,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?
): NavDestination? {
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) {
val className = destination.className
fragment = instantiateFragment(context, manager, className, args)
transaction.add(containerId, fragment, tag)
} else {
transaction.attach(fragment)
}
transaction.setPrimaryNavigationFragment(fragment)
transaction.setReorderingAllowed(true)
transaction.commit()
return destination
}
}
Question
For the second issue, I have no idea how to fix it, actually, I even didn't understand how the fragment is restoring its state (for example when you rotate the screen), I tied to use fragment.setInitialSavedState(savedState) to save and restore fragment state, but that doesn't help in this situation.
Actually what I need to know is when fragment view was recreated
Here is a link to my GitHub project, any help is welcome.
Fragment will save its state only when activity is recreated (e.g. screen rotation) and changing the fragment doesn't matter. From documentation:
There are many situations where a fragment may be mostly torn down (such as when placed on the back stack with no UI showing), but its state will not be saved until its owning activity actually needs to save its state.
Source
Saving custom state:
Put this method inside fragment:
override fun onSaveInstanceState(outState: Bundle) {
outState.putString("text", "some value")
super.onSaveInstanceState(outState)
}
And read the value, for example, inside onViewCreated:
val text = savedInstanceState?.getString("text")
You will receive desired value after screen rotation / phone language change or other config changes - when activity (and fragment) is being recreated.
Check this Blog, http://www.androiddevelopment.co.in/2019/05/how-to-save-android-activity-state.html, This Blog explain how to save activity state when the activity is destroyed.
For example, If you change the language of your phone while the activity was running (and so different resources from your project need to be loaded). Another very common scenario is when you rotate your phone to the side so that the activity is recreated and displayed in landscape. You can use this technique to store instance values for your application (selections, unsaved text, etc.).
Related
I am setting a navigation graph programmatically to set the start destination depending on some condition (for example, active session), but when I tested this with the "Don't keep activities" option enabled I faced the following bug.
When activity is just recreated and the app calls method NavController.setGraph, NavController forces restoring the Navigation back stack (from internal field mBackStackToRestore in onGraphCreated method) even if start destination is different than before so the user sees the wrong fragment.
Here is my MainActivity code:
class MainActivity : AppCompatActivity() {
lateinit var navController: NavController
lateinit var navHost: NavHostFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
log("fresh start = ${savedInstanceState == null}")
navHost = supportFragmentManager.findFragmentById(R.id.main_nav_host) as NavHostFragment
navController = navHost.navController
createGraph(App.instance.getValue())
}
private fun createGraph(bool: Boolean) {
Toast.makeText(this, "Is session active: $bool", Toast.LENGTH_SHORT).show()
log("one: ${R.id.fragment_one}, two: ${R.id.fragment_two}")
val graph =
if (bool) {
log("fragment one")
navController.navInflater.inflate(R.navigation.nav_graph).also {
it.startDestination = R.id.fragment_one
}
} else {
log("fragment two")
navController.navInflater.inflate(R.navigation.nav_graph).also {
it.startDestination = R.id.fragment_two
}
}
navController.setGraph(graph, null)
}
}
App code:
class App : Application() {
companion object {
lateinit var instance: App
}
private var someValue = true
override fun onCreate() {
super.onCreate()
instance = this
}
fun getValue(): Boolean {
val result = someValue
someValue = !someValue
return result
}
}
Fragment One and Two are just empty fragments.
How it looks like:
Repository with full code and more explanation available by link
My question: is it a Navigation library bug or I am doing something wrong? Maybe I am using a bad approach and there is a better one to achieve what I want?
As you tried in your repository, It comes from save/restoreInstanceState.
It means you set suit graph in onCreate via createGraph(App.instance.getValue()) and then fragmentManager in onRestoreInstanceState will override your configuration for NavHostFragment.
So you can set another another time the graph in onRestoreInstanceState. But it will not work because of this line and backstack is not empty. (I think this behavior may be a bug...)
Because of you're using a graph (R.navigation.nav_graph) for different situation and just change their startDestination, you can be sure after process death, used graph is your demand graph. So just override startDestination in onRestoreInstanceState.
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
if (codition) {
navController.graph.startDestination = R.id.fragment_one
} else {
navController.graph.startDestination = R.id.fragment_two
}
}
Looks like there is some wrong behaviour in the library and my approach wasn't 100% correct too. At least, there is the better one and it works well.
Because I am using the same graph and only changing the start destination, I can simply set that graph in onCreate of my activity and set some default start destination there. Then, in createGraph method, I can do the following:
// pop backStack while it is not empty
while (navController.currentBackStackEntry != null) {
navController.popBackStack()
}
// then just navigate to desired destination with additional arguments if needed
navController.navigate(destinationId, destinationBundle)
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.
I use bottomNavigationView and navigation component. Please tell me how I don't destroy the fragment after switching to another tab and return to the old one? For example I have three tabs - A, B, C. My start tab is A. After I navigate to B, then return A. When I return to tab A, I do not want it to be re-created. How do it? Thanks
As per the open issue, Navigation does not directly support multiple back stacks - i.e., saving the state of stack B when you go back to B from A or C since Fragments do not support multiple back stacks.
As per this comment:
The NavigationAdvancedSample is now available at https://github.com/googlesamples/android-architecture-components/tree/master/NavigationAdvancedSample
This sample uses multiple NavHostFragments, one for each bottom navigation tab, to work around the current limitations of the Fragment API in supporting multiple back stacks.
We'll be proceeding with the Fragment API to support multiple back stacks and the Navigation API to plug into it once created, which will remove the need for anything like the NavigationExtensions.kt file. We'll continue to use this issue to track that work.
Therefore you can use the NavigationAdvancedSample approach in your app right now and star the issue so that you get updates for when the underlying issue is resolved and direct support is added to Navigation.
In case you can deal with destroying fragment, but want to save ViewModel, you can scope it into the Navigation Graph:
private val viewModel: FavouritesViewModel by
navGraphViewModels(R.id.mobile_navigation) {
viewModelFactory
}
Read more here
EDIT
As #SpiralDev noted, using Hilt simplifies a bit:
private val viewModel: MainViewModel by
navGraphViewModels(R.id.mobile_navigation) {
defaultViewModelProviderFactory
}
just use navigation component version 2.4.0-alpha01 or above
Update:
Using last version of fragment navigation component, handle fragment states itself. see this sample
Old:
class BaseViewModel : ViewModel() {
val bundleFromFragment = MutableLiveData<Bundle>()
}
class HomeViewModel : BaseViewModel () {
... HomeViewModel logic
}
inside home fragment (tab of bottom navigation)
private var viewModel: HomeViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.bundleFromFragment.observe(viewLifecycleOwner, Observer {
val message = it.getString("ARGUMENT_MESSAGE", "")
binding.edtName.text = message
})
}
override fun onDestroyView() {
super.onDestroyView()
viewModel.bundleFromFragment.value = bundleOf(
"ARGUMENT_MESSAGE" to binding.edtName.text.toString(),
"SCROLL_POSITION" to binding.scrollable.scrollY
)
}
You can do this pattern for all fragments inside bottom navigation
Update 2021
use version 2.4.0-alpha05 or above.
don't use this answer or other etc.
This can be achieved using Fragment show/hide logic.
private val bottomFragmentMap = hashMapOf<Int, Fragment>()
bottomFragmentMap[0] = FragmentA.newInstance()
bottomFragmentMap[1] = FragmentB.newInstance()
bottomFragmentMap[2] = FragmentC.newInstance()
bottomFragmentMap[3] = FragmentD.newInstance()
private fun loadFragment(fragmentIndex: Int) {
val fragmentTransaction = childFragmentManager.beginTransaction()
val bottomFragment = bottomFragmentMap[fragmentIndex]!!
// first time case. Add to container
if (!bottomFragment.isAdded) {
fragmentTransaction.add(R.id.container, bottomFragment)
}
// hide remaining fragments
for ((key, value) in bottomFragmentMap) {
if (key == fragmentIndex) {
fragmentTransaction.show(value)
} else if (value.isVisible) {
fragmentTransaction.hide(value)
}
}
fragmentTransaction.commit()
}
Declare fragment on the activity & create fragment instance on onCreate method, then pass the fragment instance in updateFragment method. Create as many fragment instances as required corresponding to bottom navigation listener item id.
Fragment fragmentA;
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_home);
fragmentA = new Fragment();
updateFragment(fragmentA);
}
public void updateFragment(Fragment fragment) {
FragmentTransaction transaction =
getSupportFragmentManager().beginTransaction();
transaction.add(R.id.layoutFragment, fragment);
transaction.commit();
}
Furthermore be sure you are using android.support.v4.app.Fragment and calling getSupportFragmentManager()
I am launching a coroutine that after a specified delay display a counter value on the screen.
job = launch(UI) {
var count= 0
while (true) {
textView.text = "${count++}"
delay(200L)
}
}
Now on screen rotation I want UI keeps getting updated with correct counter value. Does someone has any idea how to resume the job on configuration(e.g. screen rotation) change.
Does someone has any idea how to resume the job on configuration(e.g. screen rotation) change.
Your job never stopped running, but you keep holding on to and updating a TextView which is no longer showing on the screen. After the configuration changed, your activity and its entire view hierarchy got scraped.
While technically you can configure your app not to recreate the activity on rotation, Google strongly discourages you from doing that. The app will seem to work for the case of rotation, but then will break on another kind of config change like timezone, location etc. You just have to bite the bullet and make your app work across activity recreation events.
I made my coroutines work across activity recreation by relying an a Fragment in which I set
retainInstance = true
This means that your fragment instance survives the death of its parent activity and, when the new activity replaces it, Android injects your fragment into it instead of creating a new one. It does not prevent the destruction of the view hierarchy, you must write code that updates the fragment's state to reflect these changes. It helps because it allows you to keep the fragment's state instead of bothering with parcelization.
On configuration change, your fragment will go through these lifecycle events:
onDestroyView
onCreateView
It doesn't go through onPause/onResume, this only happens when you switch activities or exit the app. You can start your coroutine in onResume and cancel it in onPause.
As of the recently released version 0.23 of kotlinx.coroutines, launch became an extension function: you must call it in the context of some CoroutineScope which controls the resulting job's lifecycle. You should bind its lifecycle to the fragment, so let your fragment implement CoroutineScope. Another change is that the UI coroutine context is now deprecated in favor of Dispatchers.Main.
Here's a brief example that demonstrates all the points I mentioned:
class MyFragment : Fragment, CoroutineScope {
private var textView: TextView? = null
private var rootJob = Job()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + rootJob
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
val rootView = inflater.inflate(R.layout.frag_id, container, false)
this.textView = rootView.findViewById(R.id.textview)
return rootView
}
override fun onDestroyView() {
this.textView = null
}
override fun onResume() {
this.launch {
var count = 0
while (true) {
textView?.text = "$count"
count++
delay(200L)
}
}
}
override fun onPause() {
rootJob.cancel()
rootJob = Job()
}
}
Now, as the view hierarchy gets rebuilt, your coroutine will automatically fetch the current instance of textView. If a timer tick happens to occur at an inconvenient moment while the UI is being rebuilt, the coroutine will just silently skip updating the view and try again at the next tick.
By default rotation kills the activity and restarts it. That means your textview will no longer be the one on screen, it will be the one belonging to the old activity.
Your options are:
1)Add a configSettings to your manifest to turn off this behavior.
2)Use something that can persist around activity restarts like a View Model, a loader, an injected event bus, etc.
Personally unless you have a different layout for portrait and landscape I'd just go with number 1, its easier.
You can do it in ViewModel instead of Activity.
Update: accepted answer points to explanation (bug) with a work-around, but also see my Kotlin based work-around attached as an answer below.
This code is in Kotlin, but I think it is a basic android fragment life-cycle issue.
I have a Fragment that holds a reference to an other "subfragment"
Here is basically what I am doing:
I have a main fragment that has retainInstance set to true
I have a field in the main fragment that will hold a reference to the subfragment, initially this field is null
In the main fragment's onCreateView, I check to see if the subfragment field is null, if so, I create an instance of the subFragment and assign it to the field
Finally I add the subfragment to a container in the layout of the main fragment.
If the field is not null, ie we are in onCreateView due to a configuration change, I don't re-create the subfragment, I just try to added it to the containter.
When the device is rotated, I do observe the onPaused() and onDestroyView() methods of the subfragment being called, but I don't see any lifecyle methods being called on the subfragment during the process of adding the retained reference to the subfragment, to the child_container when the main fragments view is re-created.
The net affect is that I don't see the subfragment view in the main fragment. If I comment out the if (subfragment == null) and just create a new subfragment everytime, i do see the subfragment in the view.
Update
The answer below does point out a bug, in which the childFragmentManager is not retained on configuration changes. This will ultimately break my intended usage, which was to preserve the backstack after rotation, however I think what I am seeing is something different.
I added code to the activities onWindowFocusChanged method and I see something like this when the app is first launched:
activity is in view
fm = FragmentManager{b13b9b18 in Tab1Fragment{b13b2b98}}
tab 1 fragments = [DefaultSubfragment{b13bb610 #0 id=0x7f0c0078}]
and then after rotation:
activity is in view
fm = FragmentManager{b13f9c30 in Tab1Fragment{b13b2b98}}
tab 1 fragments = null
here fm is the childFragmentManager, and as you can see, we still have the same instance of Tab1Fragment, but it has a new childFragmentManager, which I think is unwanted and due to the bug reported in the answer below.
The thing is that I did add the subfragment to this new childFragmentManger.
So it seems like the transaction never executes with the reference to the fragment that was retained, but does complete if I create a brand new fragment. (I did try calling executePendingTransactions on the new childFragmentManager)
class Tab1Fragment: Fragment() {
var subfragment: DefaultSubfragment? = null
override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
val rootView = inflater!!.inflate(R.layout.fragment_main, container, false)
if (subfragment == null ) {
subfragment = DefaultSubfragment()
subfragment!!.sectionLabel = "label 1"
subfragment!!.buttonText = "button 1"
}
addRootContentToContainer(R.id.child_container, content = subfragment!!)
return rootView
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
}
inline fun Fragment.addRootContentToContainer(containerId: Int, content: Fragment) {
val transaction = childFragmentManager.beginTransaction()
transaction.replace(containerId, content)
transaction.commit()
}
Your problem looks similar to the issue described here:
https://code.google.com/p/android/issues/detail?id=74222
unfortunately this issue will probably not be fixed by google.
Using retained fragments for UI or nested fragments is not a good idea - they are recomended to be used in place of onRetainNonConfigurationInstance, so ie. for large collections/data structures. Also you could find Loaders better than retained fragments, they also are retained during config changes.
btw. I find retained fragments more of a hack - like using android:configChanges to "fix" problems caused by screen rotations. It all works until user presses home screen and android decides to kill your app process. Once user will like to go back to your app - your retained fragments will be destroyed - and you will still have to recreate it. So its always better to code everything like if your resources could be destroyed any time.
The accepted answer to my question above points out a reported bug in the support library v4 in which nested fragments (and child fragment managers) are no longer retained on configuration changes.
One of the posts provides a work-around (which seems to work well).
The work around involves creating a subclass of Fragment and uses reflection.
Since my original question used Kotlin code, I thought I would share my Kotlin version of the work around here in case anyone else hits this. In the end, I am not sure I will stick with this solution, since it is still somewhat of a hack, it still manipulates private fields, however if the field name is changed, the error will be found at compile time rather than runtime.
The way this works is this:
In your fragment that will contain child fragments you create a field retainedChildFragmentManager, that will hold the childFragmentManager that will be lost during the configuration change
In the onCreate callback for the same fragment, you set retainInstance to true
In the onAttach callback for the same fragment, you check to see if retainedChildFragmentManger is non-null, if so you call a Fragment extension function that re-attaches the retainedChildFragmentManager, otherwise you set the retainedChildFragmentManager to the current childFragmentManager.
Finally you need to fix the child fragments to point back to the newly created hosting activity (the bug leaves them referencing the old activity, which I think results in a memory leak).
Here is an example:
Kotlin Fragment extensions
// some convenience functions
inline fun Fragment.pushContentIntoContainer(containerId: Int, content: Fragment) {
val transaction = fragmentManager.beginTransaction()
transaction.replace(containerId, content)
transaction.addToBackStack("tag")
transaction.commit()
}
inline fun Fragment.addRootContentToContainer(containerId: Int, content: Fragment) {
val transaction = childFragmentManager.beginTransaction()
transaction.replace(containerId, content)
transaction.commit()
}
// here we address the bug
inline fun Fragment.reattachRetainedChildFragmentManager(childFragmentManager: FragmentManager) {
setChildFragmentManager(childFragmentManager)
updateChildFragmentsHost()
}
fun Fragment.setChildFragmentManager(childFragmentManager: FragmentManager) {
if (childFragmentManager is FragmentManagerImpl) {
mChildFragmentManager = childFragmentManager // mChildFragmentManager is private to Fragment, but the extension can touch it
}
}
fun Fragment.updateChildFragmentsHost() {
mChildFragmentManager.fragments.forEach { fragment -> // fragments is hidden in Fragment
fragment?.mHost = mHost // mHost is private also
}
}
The Fragment Hosting the child Fragments
class Tab1Fragment : Fragment() , TabRootFragment {
var subfragment: DefaultSubfragment? = null
var retainedChildFragmentManager: FragmentManager? = null
override val title = "Tab 1"
override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
val rootView = inflater!!.inflate(R.layout.fragment_main, container, false)
if (subfragment == null ) {
subfragment = DefaultSubfragment()
subfragment!!.sectionLable = "label 1x"
subfragment!!.buttonText = "button 1"
addRootContentToContainer(R.id.child_container, content = subfragment!!)
}
return rootView
}
override fun onAttach(context: Context?) {
super.onAttach(context)
if (retainedChildFragmentManager != null) {
reattachRetainedChildFragmentManager(retainedChildFragmentManager!!)
} else {
retainedChildFragmentManager = childFragmentManager
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
retainInstance = true
}
}