Небольшое введение, или зачем все это нужно
Не так давно мне потребовалось реализовать переключение между single-pane и dual-pane режимами при повороте экрана. Поскольку готовые решения, которые удалось найти, меня не устроили, то пришлось изощряться и изобретать собственный велосипед.
В документации, а также в нотациях material design указывается, что при стандартной обработке поворота экрана, место может задействоваться неэффективно, а потому следует выделять два режима: single-pane (на экране присутствует один фрагмент, находящийся внизу иерархии) и dual/multi-pane (пользователю предлагается взаимодействовать с несколькими фрагментами, идущими последовательно в иерархии)
Все подходы для решения данной задачи, которые я видел, использовали либо ViewPager, либо дополнительную Activity. Я же решил данный кейс в несколько ином виде, использовав лишь FragmentManager и два контейнера.
Общее представление о внешнем виде
Первое, что нужно сделать — определиться с тем, как мы хотим, чтобы пользователь взаимодействовал с backstack-ом. Я предпочел для себя продвижение следующего рода:
portrait:
A -> A(invisible), B -> A(invisible), B(invisible), C -> (popBackStack) -> A (invisible), B
landscape:
A, B -> A(invisible), B, C -> (popBackStack) -> A, B.
То есть общий вид будет напоминать ViewPager с 1 или 2 view, видимыми для пользователя.
Так же потребуется учесть, что:
- Нужно предусмотреть смену основного фрагмента (пользователь перешел на другую вкладку Drawer-а, наприме)
- Нужно сохранять последнее состояние фрагмента, видимого для пользователя только в момент того, когда он перестает быть видимым, то есть при вымещении старого фрагмента новым
Приступим к реализации
Для начала создадим несколько util-классов, которые сделают итоговый компонент более читабельным:
public class Config { public enum Orientation { LANDSCAPE, PORTRAIT } }
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]; } }; }
Следует отметить отдельно, что все, что связано с состоянием самого решения должно реализовывать интерфейс Parcelable, дабы иметь возможность пережить изменения конфигурации девайса.
Добавим для полного удовлетворения callback для отлавливания момента изменения глубины backstack-а:
public interface OnBackStackChangeListener { void onBackStackChanged(); }
Основная часть компонента
Первое, что нужно понять, приступая к реализации данного компонента — так это то, что всю работу по сохранению состояния фрагментов придется осуществлять вручную, более того, следует понимать, что необходимо будет воспользоваться рефлексией для восстановления состояния фрагмента по возвращенному им getCanonicalName() значению. Класс State
реализует DTO для данных целей, будучи достаточным для восстановления идентичного сохраненному состояния.
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]; } }; }
В целях принудительного сохранения состояния фрагмента будет использован любезно предоставляемый системой метод FragmentManager.saveFragmentInstanceState(Fragment)
Все самое скучное позади, остается лишь продумать работу нашего декоратора над FragmentManager-ом и реализовать необходимые методы, сохраняя состояние в Activity.onSaveInstanceState(Bundle)
и восстанавливая согласно оринтации — в onCreate.
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]; } }; }
Следует отметить отдельно, что после открепления фрагментов от контейнеров вызывается метод FragmentManager.executePendingTransactions()
, это требуется для того, чтобы не возникало коллизии. Она может произойти из-за того, что транзакции происходят асинхронно, соответственно при перемещении фрагмента в landscape в другой контейнер, может возникнуть проблема из-за того, что он еще не был отвязан от предыдущего. Таким образом, анимации в данное решение качественно внедрить не получится, возможен будет лишь workaround с добавлением анимаций на вход фрагментов в соответствующие контейнеры, но не на выход. Также использование данного метода может несколько подтормаживать UI на слабых девайсах, но по большей части, фризы при переходах будут незаметными.
На этом все, ссылка на реализацию + пример:
gitlab.com/i.komarov/multipane-fragmentmanager
Буду рад проявлениям конструктивной критики, а так же предложению альтернативных решений.
ссылка на оригинал статьи https://habrahabr.ru/post/316746/
Добавить комментарий