📜 ⬆️ ⬇️

Dual-pane using fragments

A little introduction, or why all this is needed


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

Alternate text


In the documentation, as well as in the material design notation, it is indicated that the standard screen rotation processing may not be efficiently used, and therefore two modes should be distinguished: single-pane (there is one fragment on the screen at the bottom of the hierarchy) and dual / multi-pane (the user is invited to interact with several fragments going successively in the hierarchy)
')
All approaches for solving this problem, which 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 view of appearance


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

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 foresee the change of the main fragment (the user has moved to another Drawer tab, for example);
  2. It is necessary to save 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 start the implementation


To begin with, let's create several util classes that will make the final 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<Info> CREATOR = new Creator<Info>() { @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.

Add to fully meet the callback to catch the moment when the backstack depth changes:

OnBackStackChangeListener
 public interface OnBackStackChangeListener { void onBackStackChanged(); } 


Main component


The first thing you need to understand when starting to implement this component is that all the work on maintaining the state of the fragments will have to be done manually, moreover, it should be understood that you will need to use reflection to restore the state of the fragment to the value returned by getCanonicalName (). The State class implements DTO for these purposes, being sufficient to restore the state identical to the saved 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<State> CREATOR = new Creator<State>() { @Override public State createFromParcel(Parcel in) { return new State(in); } @Override public State[] newArray(int size) { return new State[0]; } }; } 


In order to enforce the state of the fragment, the system will use the method courtesy of the system FragmentManager.saveFragmentInstanceState(Fragment)

All the most boring is over, it remains only to think over the work of our decorator on the FragmentManager and implement the necessary methods, keeping the state in Activity.onSaveInstanceState(Bundle) and restoring according to orientation - 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<State> 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<MultipaneFragmentManager> CREATOR = new Creator<MultipaneFragmentManager>() { @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 the fragments from containers, the FragmentManager.executePendingTransactions() method is called, this is required in order to avoid collisions. It can occur due to the fact that transactions occur asynchronously, respectively, when moving a fragment to landscape in another container, a problem may arise due to the fact that it has not yet been untied from the previous one. Thus, the animation in this solution cannot be implemented qualitatively, only workaround will be possible with the addition of animations to the input of fragments in the corresponding containers, but not to the output. Also, the use of this method can slightly slow down the UI on weak devices, but for the most part, the friezes will be invisible during transitions.


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


I will be glad to display constructive criticism, as well as to offer alternative solutions.


UPD : I was asked to describe why I didn’t like alternative methods.


So, the first of the options presented is the use of ViewPager . Its main drawbacks, in my opinion, are the complexity of maintaining the state of the fragments (both the state of the fragments and the state of the ViewPager itself are ViewPager ), plus my personal reluctance to use the View component as a controller.


Also, since I’m not using the most reliable mechanism — the Loader — to save a presenter between configuration changes, using ViewPager can negatively affect its operation.


Further, the use of an additional Activity for displaying detailed information described in the Master / Detail flow of the concept in the official documentation somewhat confused me. Suppose that the user went to the detailed information section, then turned the screen. In this case, processing should occur within the new activity, which will transfer data on 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 the transfer of data through arguments has its 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 such a decision, let alone its implementation. In fact, if you need to display only a two-level hierarchy, this solution can be considered a competitor of the proposed one, but only because of its availability “out of the box”.

Source: https://habr.com/ru/post/316746/


All Articles