Эффективная работа с BottomSheet
В данной заметке пойдёт речь об организации эффективной работы с BottomSheet
компонентами на уровне Activity
.
Заметка представляет собой квинтэссенцию личного опыта, боли и пройденных шагов для поиска наиболее эффективного решения различных задач.
Sheets: Bottom
Согласно Material Design, BottomSheet
представляет собой компоненты с дополнительным контентом, привязанные к нижней части экрана.
В общем случае работа с BottomSheet
не представляет собой ничего сложного. Этой теме посвящено огромное количество статей. Ознакомиться с тем, как подключить и использовать данные компоненты, вы можете, например, тут.
Проблема
Во время работы над проектом передо мной возникли следующие вопросы:
- Как эффективно организовать работу с различными
BottomSheet
фрагментами для всехActivity
проекта; - Инкапсулировать шаблонный код работы с
BottomSheet
в одном месте; - Упростить отслеживание состояний
BottomSheet
.
Это является проблемой, когда имеется множество Activity
, которые должны одинаково работать с компонентами BottomSheet
, обрабатывать различные их состояния, реагировать на нажатие кнопки Back, показывать/скрывать дополнительный контент, иметь некоторое начальное состояние для всех BottomSheet
, объявленных в Activity
.
BottomSheetActivity
Первым делом было принято решение наследовать все Activity
, которые будут иметь BottomSheetBehaviour
, от специального класса, который инкапсулирует весь обслуживающий код для BottomSheet
в себе.
public abstract class BottomSheetActivity extends NavigateActivity implements Dependence, BottomSheet { private BottomSheetBehavior bottomSheetBehavior; //Активный, открытый в текущий момент bottomSheet fragment private Fragment activeFragment; //Список bottomSheet fragments, которые должны быть заблокироаны для смахивания жестом private HashMap<Integer, Boolean> lockedViewsForDragAndDrop; }
Класс BottomSheetActivity
является базовым для Activity
, который содержит в BottomSheet
компоненты.
Пояснения extends классов и интерфейсов:
NavigateActivity - класс, инкапсулирующий навигационный код для всех Activity и некоторые общие для всех Activity компоненты.
Dependence - интерфейс для работы с подписками/отписками на события различных компонентов.
BottomSheet - интерфейс с методами регистрации BottomSheet фрагментов.
Интерфейс Dependence
обязывает класс реализовать следующие методы:
@Override @CallSuper public void createDependencies() { bottomSheetBehavior.addBottomSheetCallback(onBottomSheetCallback); registerFragments(); } @Override @CallSuper public void deleteDependencies() { bottomSheetBehavior.removeBottomSheetCallback(onBottomSheetCallback); }
onBottomSheetCallback
является реализацией BottomSheetBehavior.BottomSheetCallback()
и выполняет некоторые действия по отображению/сокрытию, а также работе с swipe по BottomSheet
фрагменту. Реализация onBottomSheetCallback
приведена ниже.
protected final BottomSheetBehavior.BottomSheetCallback onBottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { @Override public void onStateChanged(@NonNull View view, int i) { switch (i) { case BottomSheetBehavior.STATE_COLLAPSED: case BottomSheetBehavior.STATE_HIDDEN: hideFragment(activeFragment); break; case BottomSheetBehavior.STATE_EXPANDED: break; case BottomSheetBehavior.STATE_DRAGGING: Boolean value = lockedViewsForDragAndDrop.get(view.getId()); if (value != null && value) { bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); } break; case BottomSheetBehavior.STATE_SETTLING: break; } }
Чтобы в момент загрузки Activity
, BottomSheet
компонент был скрыт и не отображался на экране, в методе onCreate у Activity
, которая будет наследовать данный класс, вызывается метод hideBottomSheetView
, которому передается View
с BottomSheetBehavior
.
protected void hideBottomSheetView(View bottomSheetView) { bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetView); bottomSheetBehavior.setHideable(true); bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); }
Главная и единственная задача этого метода - спрятать bottomSheetView
. Если их несколько, этот метод вызывается для каждого bottomSheetView
.
Регистрация фрагментов в FragmentManager
Используемые в Activity
фрагменты необходимо зарегистрировать в FragmentManager
для их скрытия и отображения. Интерфейс BottomSheet
, который упоминался выше, обязывает Activity
реализовать метод registerFragments
. Реализация метода приведена ниже.
@Override public void registerFragments() { fragment = new ImplFragment(); addFragmentToFragmentManager(binder.activityFragment.getRoot(), fragment, false); }
Метод addFragmentToFragmentManager
реализован в классе BottomSheetActivity
:
protected void addFragmentToFragmentManager(View rootView, Fragment fragment, boolean isLockedForDragAndDrop) { getSupportFragmentManager().beginTransaction() .add(rootView.getId(), fragment) .hide(fragment) .commit(); lockedViewsForDragAndDrop.put(rootView.getId(), isLockedForDragAndDrop); }
HashMap<Integer, Boolean> lockedViewsForDragAndDrop
выполняет функцию регистрации фрагментов, которым разрешены/запрещены операции swipe
.
Сокрытие и отображение BottomSheet фрагментов
Для управления отображением BottomSheet
фрагментов в BottomSheetActivity
имеется вспомогательный метод showHideFragment
.
Входными параметрами являются Fragment
и View
, которые требуется показать или скрыть. Решение о том, что требуется сделать, принимается внутри.
protected void showHideFragment(Fragment target, View view) { if (bottomSheetBehavior.getState() != BottomSheetBehavior.STATE_HIDDEN) { bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); } else { showFragment(target, view); } }
Если Fragment target (и его view)
открыт в данный момент, его необходимо закрыть. Если закрыт - отобразить.
Реализация метода showFragment
:
private void showFragment(Fragment target, View view) { getSupportFragmentManager().beginTransaction() .show(target) .commit(); bottomSheetBehavior = BottomSheetBehavior.from(view); bottomSheetBehavior.setHideable(true); bottomSheetBehavior.setSkipCollapsed(true); bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); activeFragment = target; //Хак для решения проблем изменяющегося размера контента внутри bottomSheetBehavior (например listView загружает данные). activeFragment.getView().getViewTreeObserver().removeOnGlobalLayoutListener(onGlobalLayoutListener); activeFragment.getView().getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener); }
Метод hideFragment
, который вызывается при событии onBottomSheetCallback.onStateChanged - BottomSheetBehavior.STATE_HIDDEN
, которое описано выше.
private void hideFragment(Fragment target) { getSupportFragmentManager().beginTransaction() .hide(activeFragment) .commit(); if (activeFragment == target) { activeFragment = null; } }
Решение проблемы для случая, когда BottomSheet имеет динамический размер (данные)
Вы могли заметить выше комментарий, указывающий на решение данной проблемы. Она состоит в следующем:
В момент вызова onCreateView у Fragment, он может изменить размер (требование данных), что приведет к тому, что Fragment не будет полностью развернут на экране.
Решение состоит в дополнительной установке высоты Fragment
. Эту задачу решает onGlobalLayoutListener
, на который Fragment
подписывается в момент вызова showFragment
, исполняется и затем сразу же отписывается от него.
private ViewTreeObserver.OnGlobalLayoutListener onGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { if(activeFragment == null || activeFragment.getView() == null) { return; } int h = activeFragment.getView().getHeight(); bottomSheetBehavior.setPeekHeight(h); bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); activeFragment.getView().getViewTreeObserver().removeOnGlobalLayoutListener(this); } };
Обработка закрытия BottomSheet Fragment
при событии Back
.
Когда пользователь нажимает кнопку Back
, и BottomSheet Fragment
открыт, он будет закрыт, при этом событие Back
не должно привести к выходу из экрана. Событие Back
проверяет, открыт ли Fragment
(если открыт, закрывает его) и если Fragment
не открыт, то позволяет уйти из Activity
.
Метод backClickHideBottomSheetView
, определенный в BottomSheetActivity
и вызываемый по callback onBackPressed
, выполняет эту функцию.
protected boolean backClickHideBottomSheetView() { if(bottomSheetBehavior.getState() != BottomSheetBehavior.STATE_HIDDEN) { bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); return true; } return false; }
Вызов метода backClickHideBottomSheetView
в onBackPressed Activity
.
@Override public void onBackPressed() { if(backClickHideBottomSheetView()) return; getNavigationManager().back(this); }
Заключение
Реализация класса BottomSheetActivity и инкапсуляция логики работы с BottomSheetBehavior и множеством фрагментов позволила переместить шаблонный код в одно место и использовать его на множестве Activity.
При дальнейшем усложнении логики работы с BottomSheet фрагментами необходимо модифицировать только этот класс.
В Activity, которая будет наследоваться от BottomSheetActivity, требуется только несколько действий:
- Первичное сокрытие BottomSheetView's, которые присутствуют на Activity;
- Реализация метода registerFragments для регистрации фрагментов в FragmentManager;
- Проверка необходимости закрыть BottomSheet фрагмент перед выходом с Activity.
- Вызов метода showHideFragment, когда требуется отобразить или скрыть Fragment.
Полный код класса BottomSheetActivity приведён ниже.
public abstract class BottomSheetActivity extends NavigateActivity implements Dependence, BottomSheet { private BottomSheetBehavior bottomSheetBehavior; private Fragment activeFragment; private HashMap<Integer, Boolean> lockedViewsForDragAndDrop; public BottomSheetActivity() { lockedViewsForDragAndDrop = new HashMap<>(); } @Override @CallSuper public void createDependencies() { bottomSheetBehavior.addBottomSheetCallback(onBottomSheetCallback); registerFragments(); } @Override @CallSuper public void deleteDependencies() { bottomSheetBehavior.removeBottomSheetCallback(onBottomSheetCallback); } protected void hideBottomSheetView(View bottomSheetView) { bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetView); bottomSheetBehavior.setHideable(true); bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); } protected void addFragmentToFragmentManager(View rootView, Fragment fragment, boolean isLockedForDragAndDrop) { getSupportFragmentManager().beginTransaction() .add(rootView.getId(), fragment) .hide(fragment) .commit(); lockedViewsForDragAndDrop.put(rootView.getId(), isLockedForDragAndDrop); } protected void showHideFragment(Fragment target, View view) { if (bottomSheetBehavior.getState() != BottomSheetBehavior.STATE_HIDDEN) { bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); } else { showFragment(target, view); } } protected boolean backClickHideBottomSheetView() { if(bottomSheetBehavior.getState() != BottomSheetBehavior.STATE_HIDDEN) { bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); return true; } return false; } protected final BottomSheetBehavior.BottomSheetCallback onBottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { @Override public void onStateChanged(@NonNull View view, int i) { switch (i) { case BottomSheetBehavior.STATE_COLLAPSED: case BottomSheetBehavior.STATE_HIDDEN: hideFragment(activeFragment); break; case BottomSheetBehavior.STATE_EXPANDED: break; case BottomSheetBehavior.STATE_DRAGGING: Boolean value = lockedViewsForDragAndDrop.get(view.getId()); if (value != null && value) { bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); } break; case BottomSheetBehavior.STATE_SETTLING: break; } } @Override public void onSlide(@NonNull View view, float v) { } }; private void hideFragment(Fragment target) { getSupportFragmentManager().beginTransaction() .hide(activeFragment) .commit(); if (activeFragment == target) { activeFragment = null; } } private void showFragment(Fragment target, View view) { getSupportFragmentManager().beginTransaction() .show(target) .commit(); bottomSheetBehavior = BottomSheetBehavior.from(view); bottomSheetBehavior.setHideable(true); bottomSheetBehavior.setSkipCollapsed(true); bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); activeFragment = target; //Хак для решения проблем изменяющегося размера контента внутри bottomSheetBehavior (например listView загружает данные). activeFragment.getView().getViewTreeObserver().removeOnGlobalLayoutListener(onGlobalLayoutListener); activeFragment.getView().getViewTreeObserver().addOnGlobalLayoutListener(onGlobalLayoutListener); } private ViewTreeObserver.OnGlobalLayoutListener onGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { if(activeFragment == null || activeFragment.getView() == null) { return; } int h = activeFragment.getView().getHeight(); bottomSheetBehavior.setPeekHeight(h); bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); activeFragment.getView().getViewTreeObserver().removeOnGlobalLayoutListener(this); } }; }