I have an app with a navigation drawer. Everything was peachy until I rotated the screen, and noticed that the views are not preserving their states. Digging deeper I realized onCreateView
for my fragment was called twice, the second time without saved state. What did I do wrong?
Logging
To isolate the problem, I created another app, generated the activity from the Android Studio template, and just added a single logging line:
@Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { Log.i("sqisland", "onCreateView. Saved state? " + (savedInstanceState != null)); View rootView = inflater.inflate( R.layout.fragment_main, container, false); return rootView; }
And indeed, onCreateView
called twice on rotation, once with saved state and once without:
I/sqisland(24201): onCreateView. Saved state? true I/sqisland(24201): onCreateView. Saved state? false
More logging
I enabled debugging on the FragmentManager
to get more information:
FragmentManager.enableDebugLogging(true);
Time to read the tea leaves, and there are lots of it. I picked out the relevant part:
Saved state of NavigationDrawerFragment {41ea1a38 #0 id=0x7f07003e} Saved state of PlaceholderFragment {41f027a8 #1 id=0x7f07003d} Freeing fragment index NavigationDrawerFragment {41ea1a38 #0 id=0x7f07003e} Freeing fragment index PlaceholderFragment {41f027a8 #1 id=0x7f07003d}
On rotation, the FragmentManager
saves the states of the NavigationDrawerFragment
and PlaceholderFragment
, then frees them.
Instantiated fragment NavigationDrawerFragment {41f90658 #0 id=0x7f07003e} restoreAllState: active #0: NavigationDrawerFragment {41f90658 #0 id=0x7f07003e} Instantiated fragment PlaceholderFragment {41f91018 #1 id=0x7f07003d} restoreAllState: active #1: PlaceholderFragment {41f91018 #1 id=0x7f07003d}
Then it instantiates new fragments for the new activity, and restores the saved states.
moveto CREATED: PlaceholderFragment {41f91018 #1 id=0x7f07003d} moveto CREATED: NavigationDrawerFragment {41f90658 #0 id=0x7f07003e} Commit: BackStackEntry{41ed3088} mName=null mIndex=-1 mCommitted=false Operations: Op #0: REPLACE PlaceholderFragment {41f849c8 id=0x7f07003d}
Next it moves the fragments to onCreate
. There is a commit
because onCreate
in NavigationDrawerFragment
calls selectItem
, which triggers the activity to instantiate a new PlaceholderFragment
.
@Override public void onNavigationDrawerItemSelected(int position) { // update the main content by replacing fragments FragmentManager fragmentManager = getSupportFragmentManager(); fragmentManager.beginTransaction() .replace(R.id.container, PlaceholderFragment.newInstance(position + 1)) .commit(); }
Run: BackStackEntry{41ed3088} OP_REPLACE: adding=PlaceholderFragment {41f849c8 id=0x7f07003d} old=NavigationDrawerFragment {41f90658 #0 id=0x7f07003e}
Now the FragmentManager
runs the transcation, looking for fragment id 0x7f07003d
for replacement, which is R.id.container
.
It skipped the NavigationDrawerFragment
because the id does not match.
OP_REPLACE: adding=PlaceholderFragment {41f849c8 id=0x7f07003d} old=PlaceholderFragment {41f91018 #1 id=0x7f07003d}
Next it found the PlaceholderFragment
which it restored from rotation, with id 0x7f07003d
. Bingo! This is the fragment to replace.
remove: PlaceholderFragment {41f91018 #1 id=0x7f07003d} nesting=0 Freeing fragment index PlaceholderFragment {41f91018 #1 id=0x7f07003d} add: PlaceholderFragment {41f849c8 id=0x7f07003d} Allocated fragment index PlaceholderFragment {41f849c8 #1 id=0x7f07003d} moveto CREATED: PlaceholderFragment {41f849c8 #1 id=0x7f07003d}
Replacing a fragment means removing it and adding it back. Afterwards, the FragmentManager
moves the fragment into onCreate
.
This is why I see onCreate
called twice, once with saved state, and once without.
The fix
A quick summary:
- Device was rotated.
FragmentManager
saves state for all fragments, then frees them. - New activity. New fragments get instantiated, with states restored.
- Fragments get moved into
onCreate
. onCreate
ofNavigationDrawerFragment
triggers a replace transaction.- The replace transaction removes the restored
PlaceholderFragment
with a new one, which has no saved state.
To prevent the second PlaceholderFragment
from being created, we need to tell NavigationDrawerFragment
not to trigger a replace transaction when it is restored from saved state. I added a parameter to selectItem
:
private void selectItem( int position, boolean fromSavedInstanceState) { mCurrentSelectedPosition = position; if (mDrawerListView != null) { mDrawerListView.setItemChecked(position, true); } if (mDrawerLayout != null) { mDrawerLayout.closeDrawer(mFragmentContainerView); } if (mCallbacks != null) { mCallbacks.onNavigationDrawerItemSelected( position, fromSavedInstanceState); } }
When the selectItem
is called from onCreate
, fromSavedInstanceState = (savedInstanceState != null)
. When it is called from the ListView
OnItemClickListener
, it is false
.
The activity only commit the replace transaction if needed:
@Override public void onNavigationDrawerItemSelected( int position, boolean fromSavedInstanceState) { if (!fromSavedInstanceState) { // update the main content by replacing fragments FragmentManager fragmentManager = getSupportFragmentManager(); fragmentManager.beginTransaction() .replace(R.id.container, PlaceholderFragment.newInstance(position + 1)) .commit(); } }
Don't forget to change the signature of the interface:
public static interface NavigationDrawerCallbacks { void onNavigationDrawerItemSelected( int position, boolean fromSavedInstanceState); }
Done, right? Not so fast. This indeed removed the second creation of PlaceholderFragment
, but the action bar title is not updated. Here is why:
@Override public void onAttach(Activity activity) { super.onAttach(activity); ((MainActivity) activity).onSectionAttached( getArguments().getInt(ARG_SECTION_NUMBER)); }
We are updating the title onAttach
, but when the fragment is restored by the FragmentManager
, it is attached before activity onCreate
is called, so the title gets overwritten by the app title.
Instead, we want to update the title after activity onCreate
is called.
Time to consult the fragment lifecycle:
onAttach(Activity)
called once the fragment is associated with its activity.onCreate(Bundle)
called to do initial creation of the fragment.onCreateView(LayoutInflater, ViewGroup, Bundle)
creates and returns the view hierarchy associated with the fragment.onActivityCreated(Bundle)
tells the fragment that its activity has completed its ownActivity.onCreate()
.onViewStateRestored(Bundle)
tells the fragment that all of the saved state of its view hierarchy has been restored.onStart()
makes the fragment visible to the user (based on its containing activity being started).onResume()
makes the fragment interacting with the user (based on its containing activity being resumed).
onActivityCreated
it is:
@Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); ((MainActivity) getActivity()).updateTitle( getArguments().getInt(ARG_SECTION_NUMBER)); }
I renamed onSectionAttached
to updateTitle
as well:
public void updateTitle(int number) { switch (number) { case 1: mTitle = getString(R.string.title_section1); break; case 2: mTitle = getString(R.string.title_section2); break; case 3: mTitle = getString(R.string.title_section3); break; } }
Yay, everything works!
Did you read all the tea leaves with me? Wow. Go get yourself a nice drink. You deserve it.
Inline coding questions will not be answsered. Instead, ask on StackOverflow and put the link in the comment.
Here is how I tackle this problem in my apps:
ReplyDelete- I don't put the drawer content (the menu) in a Fragment since I never need to reuse it in different activities. There is only one root level Activity containing the drawer menu. I put the content directly inside the DrawerLayout inside the main Activity layout and the main Activity handles the section change.
- I use an Enum instead of an int to identify a section. The enum values contain the fragment class name and section title. When you add a section, you just have to update the Enum and not various switch statements.
- I keep the currently selected section in my main Activity and I update the main title at the same time I perform the fragment transaction. When the user selects a section, I do nothing if the newly selected section is identical to the current one.
- For greater flexibility, I also allow some section fragments to be detached and reattached instead of removed and recreated from scratch. I keep this information as a boolean in the Section enum and take it into account when performing the fragment transaction.
You can take a look at this in action in the source code of FOSDEM Companion:
https://github.com/cbeyls/fosdem-companion-android/blob/master/src/be/digitalia/fosdem/activities/MainActivity.java
I'd like to use your solution in my project? Do I have to indicate your authorship in the class description?
DeleteFor me, please put a link to this article in the comment.
DeleteFor Christophe, you need to ask him directly: https://github.com/cbeyls
Thank you! I was scratching my head for a while trying to figure out why my saved state was disappearing.
ReplyDeleteMy nav drawer code was generated by the new activity wizard, so I guess this is a bug in the SDK tools.
Thank you! Your post saved my weekend ;) Have you considered writing a question/answer pair with a short summary of this on stackoverflow? I think it would be very helpful for a lot of people.
ReplyDeleteGlad that it helped.
DeleteSeems silly to ask a question on stackoverflow just to answer it myself when I already wrote this blog post, no?
You helped me sooo much. Thanks!
ReplyDeleteThanks!
ReplyDeleteThat was really helpful! Thank you!
ReplyDeleteHi, thx a lot.
ReplyDeleteYou save my day :)