Dual-pane с использованием фрагментов

от автора

Небольшое введение, или зачем все это нужно

Не так давно мне потребовалось реализовать переключение между 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, видимыми для пользователя.
Так же потребуется учесть, что:

  1. Нужно предусмотреть смену основного фрагмента (пользователь перешел на другую вкладку Drawer-а, наприме)
  2. Нужно сохранять последнее состояние фрагмента, видимого для пользователя только в момент того, когда он перестает быть видимым, то есть при вымещении старого фрагмента новым

Приступим к реализации

Для начала создадим несколько util-классов, которые сделают итоговый компонент более читабельным:

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];         }     }; } 

Следует отметить отдельно, что все, что связано с состоянием самого решения должно реализовывать интерфейс Parcelable, дабы иметь возможность пережить изменения конфигурации девайса.

Добавим для полного удовлетворения callback для отлавливания момента изменения глубины backstack-а:

OnBackStackChangeListener

public interface OnBackStackChangeListener {     void onBackStackChanged(); } 

Основная часть компонента

Первое, что нужно понять, приступая к реализации данного компонента — так это то, что всю работу по сохранению состояния фрагментов придется осуществлять вручную, более того, следует понимать, что необходимо будет воспользоваться рефлексией для восстановления состояния фрагмента по возвращенному им getCanonicalName() значению. Класс State реализует DTO для данных целей, будучи достаточным для восстановления идентичного сохраненному состояния.

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];         }     }; } 

В целях принудительного сохранения состояния фрагмента будет использован любезно предоставляемый системой метод FragmentManager.saveFragmentInstanceState(Fragment)

Все самое скучное позади, остается лишь продумать работу нашего декоратора над FragmentManager-ом и реализовать необходимые методы, сохраняя состояние в Activity.onSaveInstanceState(Bundle) и восстанавливая согласно оринтации — в 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];         }     }; } 

Следует отметить отдельно, что после открепления фрагментов от контейнеров вызывается метод FragmentManager.executePendingTransactions(), это требуется для того, чтобы не возникало коллизии. Она может произойти из-за того, что транзакции происходят асинхронно, соответственно при перемещении фрагмента в landscape в другой контейнер, может возникнуть проблема из-за того, что он еще не был отвязан от предыдущего. Таким образом, анимации в данное решение качественно внедрить не получится, возможен будет лишь workaround с добавлением анимаций на вход фрагментов в соответствующие контейнеры, но не на выход. Также использование данного метода может несколько подтормаживать UI на слабых девайсах, но по большей части, фризы при переходах будут незаметными.

На этом все, ссылка на реализацию + пример:
gitlab.com/i.komarov/multipane-fragmentmanager

Буду рад проявлениям конструктивной критики, а так же предложению альтернативных решений.

ссылка на оригинал статьи https://habrahabr.ru/post/316746/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *