Dual-pane using fragments

  • Tutorial

A small introduction, or why all this is needed


Not so long ago, I needed to implement switching between single-pane and dual-pane modes when rotating the screen. Since the ready-made solutions that I could find did not suit me, I had to refine my work and invent my own bicycle.

Alternative text


The documentation, as well as in the material design notations, indicate that with standard processing for screen rotation, the place can be used inefficiently, and therefore two modes should be distinguished: single-pane (there is one fragment on the screen below the hierarchy) and dual / multi-pane (the user is invited to interact with several fragments running sequentially in the hierarchy)

All the approaches to solving this problem that I saw used either ViewPager or an additional Activity. I decided this case in a slightly different form, using only the FragmentManager and two containers.

General appearance


The first thing to do is decide on how we want the user to interact with the backstack. I preferred the promotion of the following kind:

portrait:

A → A (invisible), B → A (invisible), B (invisible), C → (popBackStack) → A (invisible), B

landscape:

A, B → A (invisible ), B, C → (popBackStack) → A, B.

That is, the general view will resemble a ViewPager with 1 or 2 views visible to the user.
You will also need to consider that:

  1. It is necessary to provide for the change of the main fragment (the user switched to another Drawer tab, for example);
  2. It is necessary to preserve the last state of the fragment visible to the user only at the moment when it ceases to be visible, that is, when the old fragment is removed with a new one.

Let's get started


First, create some util-classes that will make the resulting component more readable:

Config
public class Config {
    public enum Orientation {
        LANDSCAPE,
        PORTRAIT
    }
}


Info
public class Info implements Parcelable {
    private static final byte ORIENTATION_LANDSCAPE = 0;
    private static final byte ORIENTATION_PORTRAIT = 1;
    @IdRes
    private int generalContainer;
    @IdRes
    private int detailsContainer;
    private Config.Orientation orientation;
    public Info(Parcel in) {
        this.generalContainer = in.readInt();
        this.detailsContainer = in.readInt();
        this.orientation = in.readByte() == ORIENTATION_LANDSCAPE ? Config.Orientation.LANDSCAPE : Config.Orientation.PORTRAIT;
    }
    public Info(int generalContainer, int detailsContainer, Config.Orientation orientation) {
        this.generalContainer = generalContainer;
        this.detailsContainer = detailsContainer;
        this.orientation = orientation;
    }
    public int getGeneralContainer() {
        return generalContainer;
    }
    public void setGeneralContainer(int generalConteiner) {
        this.generalContainer = generalConteiner;
    }
    public int getDetailsContainer() {
        return detailsContainer;
    }
    public void setDetailsContainer(int detailsContainer) {
        this.detailsContainer = detailsContainer;
    }
    public Config.Orientation getOrientation() {
        return orientation;
    }
    public void setOrientation(Config.Orientation orientation) {
        this.orientation = orientation;
    }
    @Override
    public int describeContents() {
        return 0;
    }
    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeInt(generalContainer);
        dest.writeInt(detailsContainer);
        dest.writeByte(orientation == Config.Orientation.LANDSCAPE ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT);
    }
    public static Parcelable.Creator CREATOR = new Creator() {
        @Override
        public Info createFromParcel(Parcel in) {
            return new Info(in);
        }
        @Override
        public Info[] newArray(int size) {
            return new Info[0];
        }
    };
}


It should be noted separately that everything related to the state of the solution itself must implement the Parcelable interface in order to be able to survive the device configuration changes.

We add to fully satisfy the callback to catch the moment the backstack changes in depth:

OnBackStackChangeListener
public interface OnBackStackChangeListener {
    void onBackStackChanged();
}


The main part of the component


The first thing to understand when embarking on the implementation of this component is that all the work of maintaining the state of the fragments will have to be done manually, moreover, it should be understood that it will be necessary to use reflection to restore the state of the fragment by the value returned by getCanonicalName (). The class Stateimplements DTO for these purposes, being sufficient to restore an identical stored state.

State
public class State implements Parcelable {
    private String fragmentName;
    private Fragment.SavedState fragmentState;
    public State(Parcel in) {
        fragmentName = in.readString();
        fragmentState = in.readParcelable(Fragment.SavedState.class.getClassLoader());
    }
    public State(String fragmentName, Fragment.SavedState fragmentState) {
        this.fragmentName = fragmentName;
        this.fragmentState = fragmentState;
    }
    public String getFragmentName() {
        return fragmentName;
    }
    public void setFragmentName(String fragmentName) {
        this.fragmentName = fragmentName;
    }
    public Fragment.SavedState getFragmentState() {
        return fragmentState;
    }
    public void setFragmentState(Fragment.SavedState fragmentState) {
        this.fragmentState = fragmentState;
    }
    @Override
    public int describeContents() {
        return 0;
    }
    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeString(fragmentName);
        dest.writeParcelable(fragmentState, 0);
    }
    public static Parcelable.Creator CREATOR = new Creator() {
        @Override
        public State createFromParcel(Parcel in) {
            return new State(in);
        }
        @Override
        public State[] newArray(int size) {
            return new State[0];
        }
    };
}


In order to force the state of the fragment to be saved, the method FragmentManager.saveFragmentInstanceState(Fragment)

The most boring method kindly provided by the system will be used behind, it remains only to think over the work of our decorator on the FragmentManager and implement the necessary methods, saving the state in Activity.onSaveInstanceState(Bundle)and restoring according to the orientation - in onCreate.

MultipaneFragmentManager
public class MultipaneFragmentManager implements Parcelable {
    public static final String KEY_DUALPANE_OBJECT = "net.styleru.i_komarov.core.MultipaneFragmentManager";
    private static final String TAG = "MultipaneFragmentManager";
    private FragmentManager fragmentManager;
    private OnBackStackChangeListener listenerNull = new OnBackStackChangeListener() {
        @Override
        public void onBackStackChanged() {
        }
    };
    private OnBackStackChangeListener listener = listenerNull;
    private LinkedList fragmentStateList;
    private Info info;
    private boolean onRestoreInstanceState;
    private boolean onSaveInstanceState;
    public MultipaneFragmentManager(Parcel in) {
        in.readList(fragmentStateList, LinkedList.class.getClassLoader());
        info = in.readParcelable(Info.class.getClassLoader());
        this.onRestoreInstanceState = false;
        this.onSaveInstanceState = false;
    }
    public MultipaneFragmentManager(FragmentManager fragmentManager, Info info) {
        this.fragmentManager = fragmentManager;
        this.fragmentStateList = new LinkedList<>();
        this.info = info;
        onRestoreInstanceState = true;
    }
    public void attachFragmentManager(FragmentManager fragmentManager) {
        this.fragmentManager = fragmentManager;
    }
    public void detachFragmentManager() {
        this.fragmentManager = null;
    }
    public void setOrientation(Config.Orientation orientation) {
        this.info.setOrientation(orientation);
    }
    public void add(Fragment fragment) {
        this.add(fragment, true);
        listener.onBackStackChanged();
    }
    public boolean allInLayout() {
        if(info.getOrientation() == Config.Orientation.LANDSCAPE) {
            if(fragmentManager.findFragmentById(info.getGeneralContainer()) != null && fragmentManager.findFragmentById(info.getDetailsContainer()) != null) {
                return true;
            } else {
                return false;
            }
        } else {
            if(getBackStackDepth() > 1) {
                return true;
            } else {
                return false;
            }
        }
    }
    @SuppressLint("LongLogTag")
    public synchronized void replace(Fragment fragment) {
        Log.d(TAG, "replace called, backstack was: " + fragmentStateList.size());
        if(info.getOrientation() == Config.Orientation.PORTRAIT) {
            if(fragmentManager.findFragmentById(info.getGeneralContainer()) != null) {
                fragmentManager.beginTransaction().remove(fragmentManager.findFragmentById(info.getGeneralContainer())).commit();
                fragmentManager.executePendingTransactions();
            }
            fragmentManager.beginTransaction().replace(info.getGeneralContainer(), fragment).commit();
            fragmentManager.executePendingTransactions();
        } else {
            if(fragmentManager.findFragmentById(info.getDetailsContainer()) != null) {
                fragmentManager.beginTransaction()
                        .remove(fragmentManager.findFragmentById(info.getDetailsContainer()))
                        .commit();
                fragmentManager.executePendingTransactions();
            }
            fragmentManager.beginTransaction()
                    .replace(info.getDetailsContainer(), fragment)
                    .commit();
        }
    }
    private synchronized void add(Fragment fragment, boolean addToBackStack) {
        if(info.getOrientation() == Config.Orientation.PORTRAIT) {
            if(fragmentManager.findFragmentById(info.getGeneralContainer()) != null) {
                if(addToBackStack) {
                    saveOldestVisibleFragmentState();
                }
                fragmentManager.beginTransaction().remove(fragmentManager.findFragmentById(info.getGeneralContainer())).commit();
                fragmentManager.executePendingTransactions();
            }
            fragmentManager.beginTransaction().replace(info.getGeneralContainer(), fragment).commit();
            fragmentManager.executePendingTransactions();
        } else if(fragmentManager.findFragmentById(info.getGeneralContainer()) == null) {
            fragmentManager.beginTransaction().replace(info.getGeneralContainer(), fragment).commit();
            fragmentManager.executePendingTransactions();
        } else if(fragmentManager.findFragmentById(info.getDetailsContainer()) == null) {
            fragmentManager.beginTransaction().replace(info.getDetailsContainer(), fragment).commit();
            fragmentManager.executePendingTransactions();
        } else {
            if(addToBackStack) {
                saveOldestVisibleFragmentState();
            }
            saveDetailsFragmentState();
            fragmentManager.beginTransaction()
                    .remove(fragmentManager.findFragmentById(info.getGeneralContainer()))
                    .remove(fragmentManager.findFragmentById(info.getDetailsContainer()))
                    .commit();
            fragmentManager.executePendingTransactions();
            fragmentManager.beginTransaction()
                    .replace(info.getGeneralContainer(), restoreFragment(fragmentStateList.getLast()))
                    .replace(info.getDetailsContainer(), fragment)
                    .commit();
            fragmentManager.executePendingTransactions();
            fragmentStateList.removeLast();
        }
    }
    @SuppressLint("LongLogTag")
    public void popBackStack() {
        Log.d(TAG, "popBackStack called, backstack was: " + fragmentStateList.size());
        if(info.getOrientation() == Config.Orientation.PORTRAIT) {
            //fragmentStateList.removeLast();
            fragmentManager.beginTransaction()
                    .remove(fragmentManager.findFragmentById(info.getGeneralContainer()))
                    .commit();
            fragmentManager.executePendingTransactions();
            fragmentManager.beginTransaction()
                    .replace(info.getGeneralContainer(), restoreFragment(fragmentStateList.getLast()))
                    .commit();
            fragmentStateList.removeLast();
        } else if(fragmentStateList.size() > 0) {
            //fragmentStateList.removeLast();
            saveOldestVisibleFragmentState();
            fragmentManager.beginTransaction()
                    .remove(fragmentManager.findFragmentById(info.getDetailsContainer()))
                    .remove(fragmentManager.findFragmentById(info.getGeneralContainer()))
                    .commit();
            fragmentManager.executePendingTransactions();
            fragmentManager.beginTransaction()
                    .replace(info.getGeneralContainer(), restoreFragment(fragmentStateList.get(fragmentStateList.size() - 2)))
                    .replace(info.getDetailsContainer(), restoreFragment(fragmentStateList.getLast()))
                    .commit();
            //remove the fragment that was in the details container before popbackstack was called as it is no longer accessible to user
            fragmentStateList.removeLast();
            fragmentStateList.removeLast();
        } else if(getFragmentCount() == 2) {
            fragmentManager.beginTransaction()
                    .remove(fragmentManager.findFragmentById(info.getDetailsContainer()))
                    .commit();
            fragmentManager.executePendingTransactions();
        }
        listener.onBackStackChanged();
    }
    @SuppressLint("LongLogTag")
    public void onRestoreInstanceState() {
        onSaveInstanceState = false;
        if(!onRestoreInstanceState) {
            onRestoreInstanceState = true;
            if (fragmentStateList != null) {
                if(info.getOrientation() == Config.Orientation.LANDSCAPE) {
                    if (fragmentStateList.size() > 1) {
                        fragmentManager.beginTransaction()
                                .replace(info.getGeneralContainer(), restoreFragment(fragmentStateList.get(fragmentStateList.size() - 2)))
                                .replace(info.getDetailsContainer(), restoreFragment(fragmentStateList.getLast()))
                                .commit();
                        //remove state of visible fragments
                        fragmentStateList.removeLast();
                        fragmentStateList.removeLast();
                        Log.d(TAG, "restored in landscape mode, backstack: " + fragmentStateList.size());
                    } else if (fragmentStateList.size() == 1) {
                        fragmentManager.beginTransaction()
                                .replace(info.getGeneralContainer(), restoreFragment(fragmentStateList.getLast()))
                                .commit();
                        //remove state of only visible fragment
                        fragmentStateList.removeLast();
                        Log.d(TAG, "restored in landscape mode, backstack is clear");
                    }
                } else {
                    fragmentManager.beginTransaction()
                            .replace(info.getGeneralContainer(), restoreFragment(fragmentStateList.getLast()))
                            .commit();
                    //remove state of visible fragment
                    fragmentStateList.removeLast();
                    Log.d(TAG, "restored in portrait mode, backstack: " + fragmentStateList.size());
                }
            }
        }
        fragmentManager.executePendingTransactions();
    }
    @SuppressLint("LongLogTag")
    public void onSaveInstanceState() {
        if(!onSaveInstanceState) {
            onRestoreInstanceState = false;
            onSaveInstanceState = true;
            if(info.getOrientation() == Config.Orientation.LANDSCAPE) {
                if(saveOldestVisibleFragmentState()) {
                    saveDetailsFragmentState();
                }
                Log.d(TAG, "saved state before recreating fragments in portrait, now stack is: " + fragmentStateList.size());
            } else if(info.getOrientation() == Config.Orientation.PORTRAIT) {
                saveOldestVisibleFragmentState();
                Log.d(TAG, "saved state before recreating fragments in landscape, now stack is: " + fragmentStateList.size());
            }
            FragmentTransaction transaction = fragmentManager.beginTransaction();
            if (fragmentManager.findFragmentById(info.getGeneralContainer()) != null) {
                transaction.remove(fragmentManager.findFragmentById(info.getGeneralContainer()));
            }
            if (fragmentManager.findFragmentById(info.getDetailsContainer()) != null) {
                transaction.remove(fragmentManager.findFragmentById(info.getDetailsContainer()));
            }
            transaction.commit();
        }
    }
    public int getBackStackDepth() {
        return fragmentStateList.size();
    }
    public int getFragmentCount() {
        int count = 0;
        if(fragmentManager.findFragmentById(info.getGeneralContainer()) != null) {
            count++;
            if(info.getOrientation() == Config.Orientation.LANDSCAPE && fragmentManager.findFragmentById(info.getDetailsContainer()) != null) {
                count++;
            }
            count += getBackStackDepth();
        }
        return count;
    }
    private Fragment restoreFragment(State state) {
        try {
            Fragment fragment = ((Fragment) Class.forName(state.getFragmentName()).newInstance());
            fragment.setInitialSavedState(state.getFragmentState());
            return fragment;
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return null;
    }
    @SuppressLint("LongLogTag")
    private boolean saveOldestVisibleFragmentState() {
        Fragment current = fragmentManager.findFragmentById(info.getGeneralContainer());
        if (current != null) {
            Log.d(TAG, "saveOldestVisibleFragmentState called, current was not null");
            fragmentStateList.add(new State(current.getClass().getCanonicalName(), fragmentManager.saveFragmentInstanceState(current)));
        }
        return current != null;
    }
    @SuppressLint("LongLogTag")
    private boolean saveDetailsFragmentState() {
        Fragment details = fragmentManager.findFragmentById(info.getDetailsContainer());
        if(details != null) {
            Log.d(TAG, "saveDetailsFragmentState called, details was not null");
            fragmentStateList.add(new State(details.getClass().getCanonicalName(), fragmentManager.saveFragmentInstanceState(details)));
        }
        return details != null;
    }
    public void setOnBackStackChangeListener(OnBackStackChangeListener listener) {
        this.listener = listener;
    }
    public void removeOnBackStackChangeListener() {
        this.listener = listenerNull;
    }
    @Override
    public int describeContents() {
        return 0;
    }
    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeList(fragmentStateList);
        dest.writeParcelable(info, 0);
    }
    public static Parcelable.Creator CREATOR = new Creator() {
        @Override
        public MultipaneFragmentManager createFromParcel(Parcel in) {
            return new MultipaneFragmentManager(in);
        }
        @Override
        public MultipaneFragmentManager[] newArray(int size) {
            return new MultipaneFragmentManager[0];
        }
    };
}


It should be noted separately that after detaching fragments from containers, the method is called FragmentManager.executePendingTransactions(), this is required so that there is no collision. It can occur due to the fact that transactions occur asynchronously, respectively, when moving a fragment in landscape to another container, a problem may arise because it has not yet been untied from the previous one. Thus, animations cannot be qualitatively implemented in this solution; it will only be possible to workaround with adding animations to the input of fragments in the corresponding containers, but not to the output. Also, using this method can slow down the UI somewhat on weak devices, but for the most part, friezes during transitions will be invisible.


That's all, a reference to the implementation + example: gitlab.com/i.komarov/multipane-fragmentmanager


I will be glad to manifest constructive criticism, as well as the suggestion of alternative solutions.


UPD : I was asked to describe why alternative methods were not to my liking.


So, the first of the options presented is use ViewPager. In my opinion, its main disadvantages are the difficulty of maintaining the state of fragments (it is required to preserve the state of fragments as well as the state of the fragment itself ViewPager), plus personally my reluctance to use the View component as a controller.


Also, since I use not the most reliable mechanism - Loader- to save the presenter between configuration changes, use ViewPagercan adversely affect its operation.


Next is the use of additional ActivityTo display the detailed information described in the Master / Detail flow concept in the official documentation, I was a little confused. Suppose that the user goes to the detailed information section and then flips the screen. In this case, processing should occur inside the new activity, which will transfer data about the state of this screen to the base activity, from which, finally, the state of the fragment with the details will be restored. This mechanism seemed to me too overloaded, because you should not forget that data transfer through arguments has its own, very small, limit on the amount of data transferred. With more steps in the hierarchy of transitions between view components, it can be difficult to even imagine the mechanism of operation of such a solution, let alone its implementation. In reality,


Also popular now: