Monday, June 2, 2014

NavigationDrawer creates fragment twice on rotation

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:

  1. Device was rotated. FragmentManager saves state for all fragments, then frees them.
  2. New activity. New fragments get instantiated, with states restored.
  3. Fragments get moved into onCreate.
  4. onCreate of NavigationDrawerFragment triggers a replace transaction.
  5. 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:

  1. onAttach(Activity) called once the fragment is associated with its activity.
  2. onCreate(Bundle) called to do initial creation of the fragment.
  3. onCreateView(LayoutInflater, ViewGroup, Bundle) creates and returns the view hierarchy associated with the fragment.
  4. onActivityCreated(Bundle) tells the fragment that its activity has completed its own Activity.onCreate().
  5. onViewStateRestored(Bundle) tells the fragment that all of the saved state of its view hierarchy has been restored.
  6. onStart() makes the fragment visible to the user (based on its containing activity being started).
  7. 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.

10 comments:

Inline coding questions will not be answsered. Instead, ask on StackOverflow and put the link in the comment.

  1. Here is how I tackle this problem in my apps:
    - 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

    ReplyDelete
    Replies
    1. I'd like to use your solution in my project? Do I have to indicate your authorship in the class description?

      Delete
    2. For me, please put a link to this article in the comment.

      For Christophe, you need to ask him directly: https://github.com/cbeyls

      Delete
  2. Thank you! I was scratching my head for a while trying to figure out why my saved state was disappearing.

    My nav drawer code was generated by the new activity wizard, so I guess this is a bug in the SDK tools.

    ReplyDelete
  3. 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.

    ReplyDelete
    Replies
    1. Glad that it helped.

      Seems silly to ask a question on stackoverflow just to answer it myself when I already wrote this blog post, no?

      Delete
  4. You helped me sooo much. Thanks!

    ReplyDelete
  5. That was really helpful! Thank you!

    ReplyDelete