Обновить

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

Разработка под Android
Tutorial

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


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


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


UPD: меня попросили описать, почему альтернативные способы мне пришлись не по душе.


Итак, первый из представленных вариантов — использование ViewPager. Его основные, на мой взгляд, минусы — сложность сохранения состояния фрагментов ( требуется как сохранять состояние фрагментов, так и состояние самого ViewPager ), плюс лично мое нежелание использовать View-компонент в качестве контроллера.


Также, так как я использую не самый надежный механизм — Loader — для сохранения презентера между сменами конфигурации, использование ViewPager может негативно сказаться на его работе.


Далее — использование дополнительной Activity для отображения детальной информации, описанное в Master/Detail flow концепции в официальной документации несколько меня смутило. Предположим, что пользователь перешел на раздел детальной информации, после чего перевернул экран. В таком случае должна произойти обработка внутри новой активности, которая передаст данные о состоянии этого экрана в базовую активность, из которой, наконец, будет восстановлено состояние фрагмента с деталями. Мне показался данный механизм слишком перегруженным, ведь не стоит забывать о том, что передача данных через аргументы имеет свой, весьма небольшой, лимит на объем передаваемых данных. При большем количестве ступеней в иерархии переходов между view-компонентами может быть затруднительно даже представить себе механизм работы подобного решения, что уж говорить о его реализации. В действительности, при необходимости отображения лишь двухуровневой иерархии данное решение можно считать конкурентом предложенного, но лишь из-за его доступности «из коробки».

Теги:androidandroid developmentandroid appsmaterial design
Хабы: Разработка под Android
Рейтинг +4
Количество просмотров 3,5k Добавить в закладки 37
Комментарии
Комментировать

Похожие публикации

Android разработчик
до 150 000 ₽GarpixИвановоМожно удаленно
Senior Android Developer
от 250 000 до 350 000 ₽Scalable SolutionsМожно удаленно
программист Android
от 80 000 до 140 000 ₽НПК «Катрен»Новосибирск
Android- разработчик
до 140 000 ₽G1 SoftwareМожно удаленно
Android Developer
от 50 000 до 100 000 ₽НетологияМожно удаленно

Лучшие публикации за сутки