Separate backstacks of fragment in each tab with ViewPager

As I found how to realize screen transition in each tab using ViewPager (not TabActivity!), let me share it.

The beginning and motivation

First, I wanted to create the screen transition pattern like below.


Screen -+- Tab[1] - Sub screen(1-1) -> Sub screen(1-2) -> Sub screen(1-3) -> ...
|
+- Tab[2] - Sub screen(2-1) -> Sub screen(2-2) -> Sub screen(2-3) -> ...
|
+- Tab[3] - Sub screen(3-1) -> Sub screen(3-2) -> Sub screen(3-3) -> ...
...


I implemented this pattern like below.


TabActivity -+- Activity[1] - Fragment(1-1) -> Fragment(1-2) -> Fragment(1-3) -> ...
|
+- Activity[2] - Fragment(2-1) -> Fragment(2-2) -> Fragment(2-3) -> ...
|
+- Activity[3] - Fragment(3-1) -> Fragment(3-2) -> Fragment(3-3) -> ...
...


But this approach has been deprecated short time ago as you know. Google has recommended to use Fragment instead of TabActivity. So I decided to change this screen transition design last week.


My goal was like below using a ViewPager and ViewIndicator components.


Activity -+- Fragment(1-1) -> Fragment(1-2) -> Fragment(1-3) -> ...
|
+- Fragment(2-1) -> Fragment(2-2) -> Fragment(2-3) -> ...
|
+- Fragment(3-1) -> Fragment(3-2) -> Fragment(3-3) -> ...
...

The first implementation

I thought it was easy to implement it. So I tried to write a simple application like below. The layout definition is like this using ViewPagerIndicator library (It is really awesome!)

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.viewpagerindicator.TitlePageIndicator
        android:id="@+id/indicator"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <android.support.v4.view.ViewPager
        android:id="@+id/pager"
        android:layout_width="match_parent"
        android:layout_weight="1"
        android:layout_height="0dp" />

</LinearLayout>


The Activity class definition is like this.

public class MainActivity extends FragmentActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        TestFragmentPagerAdapter adapter = new TestFragmentPagerAdapter(this);

        ViewPager pager = (ViewPager)findViewById(R.id.pager);
        pager.setAdapter(adapter);

        TitlePageIndicator indicator = (TitlePageIndicator)findViewById(R.id.indicator);
        indicator.setViewPager(pager);
    }
}


The ViewPager component needs a ViewPager adapter object to display some pages. So I implemented my ViewPager adapter like below.

private class TestFragmentPagerAdapter extends FragmentPagerAdapter {

    private String[] titles = null;
    private int[] ids = {
        R.string.page_title_1,
        R.string.page_title_2,
        R.string.page_title_3,
        R.string.page_title_4,
        R.string.page_title_5,
    };
    private Fragment[] fragments = null;

    public TestFragmentPagerAdapter(FragmentActivity activity) {
        super(activity.getSupportFragmentManager());

        titles = new String[ids.length];
        fragments = new Fragment[ids.length];
        for(int i = 0; i < ids.length; i ++) {
            titles[i] = activity.getString(ids[i]);
            fragments[i] = TestFragmentPage.newInstance(titles[i], ids[i]);
        }
    }

    @Override
    public Fragment getItem(int position) {
        return fragments[position];
    }

    @Override
    public CharSequence getPageTitle(int position) {
        return titles[position];
    }

    @Override
    public int getCount() {
        return titles.length;
    }
}


A TestFragmentPage object which the adapter provides is very simple definition like this.

public static class TestFragmentPage extends Fragment {

    public static TestFragmentPage newInstance(String title, int fragmentId) {

        Bundle arguments = new Bundle();
        arguments.putString("KEY", title);
        arguments.putInt("FRAGMENT", fragmentId);

        TestFragmentPage fragment = new TestFragmentPage();
        fragment.setArguments(arguments);

        return fragment;
    }

    private String title;
    private int fragmentId;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Bundle parameters = getArguments();
        if(parameters != null) {
            title = parameters.getString("KEY");
            fragmentId = parameters.getInt("FRAGMENT");
        }
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {

        Context context = getActivity();

        LinearLayout base = new LinearLayout(context);
        base.setOrientation(LinearLayout.VERTICAL);
        LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
        base.setLayoutParams(params);

        TextView text = new TextView(context);
        text.setId(R.string.title);
        text.setGravity(Gravity.CENTER);
        text.setText("Pager: " + title);
        text.setTextSize(20 * getResources().getDisplayMetrics().density);
        text.setPadding(20, 20, 20, 20);
        params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
        text.setLayoutParams(params);

        FrameLayout layout = new FrameLayout(getActivity());
        layout.setId(fragmentId);
        params = new LayoutParams(LayoutParams.MATCH_PARENT, 0);
        params.weight = 1;
        layout.setLayoutParams(params);
        layout.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                FragmentManager manager = getFragmentManager();
                FragmentTransaction transaction = manager.beginTransaction();
                transaction.replace(fragmentId, TestFragment.newInstance(title, 0, fragmentId));
                transaction.addToBackStack(null);
                transaction.commit();
            }
        });

        base.addView(text);
        base.addView(layout);

        return base;
    }
}


I implemented the fragment's layout not from a layout file (xml) but programmaticaly, because it is impossible to use FragmentTransaction#replace method if the fragment is constructed from a layout file according to the web site.


Finally I implemented the fragment which is replaced when user taps on a screen.

public static class TestFragment extends Fragment {
    public static TestFragment newInstance(String title, int depth, int fragmentId) {

        Bundle arguments = new Bundle();
        arguments.putString("TITLE", title);
        arguments.putInt("DEPTH", depth);
        arguments.putInt("FRAGMENT", fragmentId);

        TestFragment fragment = new TestFragment();
        fragment.setArguments(arguments);

        return fragment;
    }

    private String title;
    private int depth;
    private int fragmentId;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        Bundle arguments = getArguments();
        if(arguments != null) {
            title = arguments.getString("TITLE");
            depth = arguments.getInt("DEPTH");
            fragmentId = arguments.getInt("FRAGMENT");
        }
    }

    private static final int[] colors = {Color.RED, Color.BLUE, Color.GREEN,};
		
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        FrameLayout layout = new FrameLayout(getActivity());
        layout.setId(fragmentId);
        layout.setBackgroundColor(colors[depth % colors.length]);
        LayoutParams params = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
        layout.setLayoutParams(params);
        layout.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                FragmentManager manager = getFragmentManager();
                FragmentTransaction transaction = manager.beginTransaction();
                transaction.replace(fragmentId, TestFragment.newInstance(title, depth + 1, fragmentId));
                transaction.addToBackStack(null);
                transaction.commit();
            }
        });

        return layout;
    }
}


The class is also very simple. I deployed this application into my Android phone and executed it. The screenshot is below.


And the screen transition of the application is below.


Activity -+- TestFragmentPage('THIS') -> TestFragment(Red) -> TestFragment(Blue) -> TestFragment(Green) -> ...
|
+- TestFragmentPage('IS') -> TestFragment(Red) -> TestFragment(Blue) -> TestFragment(Green) -> ...
|
+- TestFragmentPage('A') -> TestFragment(Red) -> TestFragment(Blue) -> TestFragment(Green) -> ...
|
+- TestFragmentPage('SAMPLE') -> TestFragment(Red) -> TestFragment(Blue) -> TestFragment(Green) -> ...
|
+- TestFragmentPage('APP!') -> TestFragment(Red) -> TestFragment(Blue) -> TestFragment(Green) -> ...

The problem has happened

I tested the lovely application. I tapped 'THIS' page and it turned red. I tapped again and it turned blue. Another page also seemed to work well. However I found strange behavior (of course it was a bug.) For example, firstly I tapped 'THIS' page once and it resulted red page. Secondly I tapped on 'IS' page which is in the right side of 'THIS' page and it also resulted red page. It was OK but when I returned to 'THIS' page and pressed the back button, Nothing happened. Moreover when I moved to 'IS' page, I saw the blank page. Why?

The cause of the problem

After some tests, I found the pattern of the bug. I expected the backstack of the fragments which are shown on the pages did not work well. It seemed the Activity object had only one backstack for the fragments.

When I tapped 'THIS' page, the backstack became like below.

blue page on 'THIS' page <- Tapped 'THIS' page
                                                      • +


Next, when I moved to 'IS' page and tapped it, the backstack changed like below.

blue page on 'IS' page <- Tapped 'IS' page
blue page on 'THIS' page
                                                      • +


Next, when I returned to 'THIS' page and pressed the back button, the backstack changed like below.

-+-> blue page on 'IS' page <- Pressed the back button
blue page on 'THIS' page
                                                      • +


Finally I moved to the 'IS' page but as the blue page on 'IS' page was removed above, nothing displayed.

Try to solve the problem

I asked google the solution of the bug and I found the post on stackoverflow.com again.

I'm not sure how to do this in Mono, but to add child fragments to another fragment, you can't use the FragmentManager of the Activity. Instead, you have to use the ChildFragmentManager of the hosting Fragment.

http://stackoverflow.com/questions/15349838/android-fragmenttab-host-and-fragments-inside-fragments


So I decided to change the code like below.

    public static class TestFragmentPage extends Fragment {

        // ...

        @Override
        public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {

            layout.setOnClickListener(new View.OnClickListener() {

                // ...

                @Override
                public void onClick(View view) {
                    FragmentManager manager = getFragmentManager();
                    FragmentTransaction transaction = manager.beginTransaction();
                    // transaction.replace(fragmentId, TestFragment.newInstance(title, 0, fragmentId));
                    transaction.replace(
                        fragmentId, TestFragment.newInstance(title, 0, fragmentId, getChildFragmentManager()));
                    transaction.addToBackStack(null);
                    transaction.commit();
                }
            });

            // ...
        }
    }

   public static class TestFragment extends Fragment {

        // Add the last parameter
        public static TestFragment newInstance(String title, int depth, int fragmentId, FragmentManager manager) {

            // ...

            TestFragment fragment = new TestFragment();
            fragment.setArguments(arguments);

            // Set the FragmentManager object to the Fragment object.
            fragment.setFragmentManager(manager);

            return fragment;
        }

        // ...
        private FragmentManager manager;

        // ...
    		
        @Override
        public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {

            // ...

            layout.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    FragmentTransaction transaction = manager.beginTransaction();

                    // Pass the FragmentManager which is given by the parent as the argument.
                    transaction.replace(fragmentId, TestFragment.newInstance(title, depth + 1, fragmentId, manager));

                    transaction.addToBackStack(null);
                    transaction.commit();
                }
            });

            return layout;
        }
        
        public void setFragmentManager(FragmentManager manager) {
            this.manager = manager;
        }
    }


I tried to test the application again but it did not seem to work well. The bug is not fixed at all. On the contrary, the bug got worse. The back button did not work at all. When I pressed the back button on the blue page of 'THIS' tab, the application has finished unexpectedly.

Analyze again

As I thought why the application has finished when pressing the back button, I decided to print the state of the backstacks with Logcat console.

public class MainActivity extends FragmentActivity {

    // ...

    @Override
    protected void onPause() {
    	super.onPause();
    }
    
    public void printBackStack() {
        FragmentManager manager = getSupportFragmentManager();
        int numFragments = manager.getBackStackEntryCount();
        int countFragments = 0;
        ViewPager pager = (ViewPager)findViewById(R.id.pager);
        FragmentPagerAdapter adapter = (FragmentPagerAdapter)pager.getAdapter();
        for(int i = 0; i < adapter.getCount(); i ++) {
            Fragment fragment = adapter.getItem(i);
            manager = fragment.getChildFragmentManager();
            Log.d("MainActivity",
                  "MainActivity:" + "ChildFragment[" + i +"]" + "numFragments: " + manager.getBackStackEntryCount());
            countFragments += manager.getBackStackEntryCount();
        }
    }

    // ...
}


The log when the problem happened is below.


03-17 16:37:53.260: D/MainActivity(22762): MainActivity:ChildFragment[0]numFragments: 2
03-17 16:37:53.260: D/MainActivity(22762): MainActivity:ChildFragment[1]numFragments: 0
03-17 16:37:53.260: D/MainActivity(22762): MainActivity:ChildFragment[2]numFragments: 0
03-17 16:37:53.260: D/MainActivity(22762): MainActivity:ChildFragment[3]numFragments: 0
03-17 16:37:53.260: D/MainActivity(22762): MainActivity:ChildFragment[4]numFragments: 0


The log tells me that even though there are two fragments in the backstack of 'THIS' page, the application has finished. It seems the event of the back button is not notified to 'THIS' page.

I asked google the solution of the problem again and I found the answer on stackoverflow.com.

View rootView;

/* initialize your rootView */

rootView.setFocusableInTouchMode(true); //this line is important
rootView.requestFocus();
rootView.setOnKeyListener( new OnKeyListener()
{
    @Override
    public boolean onKey( View v, int keyCode, KeyEvent event )
    {
        if( keyCode == KeyEvent.KEYCODE_BACK )
        {
            return true;
        }
        return false;
    }
} );

return rootView;


The answer told me that if the foucs is not given to the fragment, the fragment can not get any event. So I decided to change my code again like this.

public class MainActivity extends FragmentActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {

        // ...

        TitlePageIndicator indicator = (TitlePageIndicator)findViewById(R.id.indicator);
        indicator.setViewPager(pager);
        indicator.setOnPageChangeListener(new ViewPager.OnPageChangeListener() {
            @Override
            public void onPageSelected(int position) {
                TestFragmentPage current = (TestFragmentPage)adapter.getItem(position);
                current.onPageSelected();
            }

            @Override
            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {}

            @Override
            public void onPageScrollStateChanged(int state) {}
        });
    }

    public boolean isSelectedTab(String title) {
        ViewPager pager = (ViewPager)findViewById(R.id.pager);
        TestFragmentPagerAdapter adapter = (TestFragmentPagerAdapter)pager.getAdapter();
        int selectedNo = pager.getCurrentItem();
        String pageTitle = String.valueOf(adapter.getPageTitle(selectedNo));
        return pageTitle.equals(title);
    }
}

// ...

public static class TestFragmentPage extends Fragment {

    // ...

   public void onPageSelected() {
        FragmentManager manager = getChildFragmentManager();
        int numFragments = manager.getBackStackEntryCount();
        if(numFragments <= 0) {
            View view = getView();
            view.setFocusableInTouchMode(true);
            view.requestFocus();
            view.setOnKeyListener(new View.OnKeyListener() {
                @Override
                public boolean onKey(View view, int keyCode, KeyEvent event) {
                    if(keyCode != KeyEvent.KEYCODE_BACK)
                        return false;

                    if(event.getAction() == KeyEvent.ACTION_UP) {
                        getActivity().finish();
                    }
                    return true;
                }
            });

            return;
        }

        FragmentManager.BackStackEntry entry = manager.getBackStackEntryAt(numFragments - 1);
        if(entry == null)
            return;
        String name = entry.getName();
        if(name == null)
            return;

        TestFragment current = (TestFragment)manager.findFragmentByTag(name);
        if(current != null) {
            current.resumeFocus();
        }
    }
}

public static class TestFragment extends Fragment {

    // ...

    @Override
    public void onResume() {
        super.onResume();

        // The top fragment in the selected tab should only be focused.
        // So make sure to check whether the fragment's tab is selected or not,
        // before giving it the focus.
        MainActivity parent = (MainActivity)getActivity();
        if(parent.isSelectedTab(title)) {
            resumeFocus();
        }
    }

    public void resumeFocus() {
        View view = getView();
        view.setFocusableInTouchMode(true);
        view.requestFocus();
        view.setOnKeyListener(new View.OnKeyListener() {
            @Override
            public boolean onKey(View view, int keyCode, KeyEvent event) {
                if(keyCode != KeyEvent.KEYCODE_BACK)
                    return false;

                if(event.getAction() == KeyEvent.ACTION_UP) {
                    manager.popBackStack();
                }
                return true;
            }
        });
    }
}


Although this change looks a little big, it is very simple. There are three points.

  1. Handle the back button pressed event in the fragment.
  2. Notify the tab changed event to the current fragment. When the fragment is given the event, it resumes the focus.
  3. Check whether the fragment's tab is selected or not before the focus is given to the fragment.

The conclusion

It is working very well! I am so happy. As I think it will also help other developers, I am going to share not only information but also full source code (the project of Eclipse.) Please stay tuned!