Эффективная работа с 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);
}
};
}