November 3, 2021

Эффективная работа с BottomSheet

В данной заметке пойдёт речь об организации эффективной работы с BottomSheet компонентами на уровне Activity.

Заметка представляет собой квинтэссенцию личного опыта, боли и пройденных шагов для поиска наиболее эффективного решения различных задач.

Sheets: Bottom

Согласно Material Design, BottomSheet представляет собой компоненты с дополнительным контентом, привязанные к нижней части экрана.

Источник: https://material.io/components/sheets-bottom

В общем случае работа с 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);
        }
    };
}