Пожалуйста, опишите ошибку

Нашли баг? Помогите нам его исправить, заполнив эту форму

Создаем вложенное боковое меню в Android-приложении

Сергей Чуприн
Android-разработчик

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

Многие сперва попытаются найти похожие решения в интернете во избежание написания велосипедов и дальнейшего переписывания кода. Так же поступил и я, когда при работе над проектом настала очередь реализации навигационного меню. Но не простого, а двойного (или вложенного). Т.е. при выдвижении основного есть возможность выдвинуть дополнительное меню. Также должна быть FAB, которая динамически пропадает и появляется при движении меню. Результат должен выглядеть примерно таким образом:

image

menu

Совершенно не представляя, как такое сделать, я начал поиски, которые не увенчались успехом. Совсем. Интернет был бессилен, ни одного похожего решения, ни одной библиотеки. Отчаявшись, но не сдавшись, я начал пробовать все возможные варианты разметки, но ничего не сработало. Потратив несколько дней на эксперименты с жестами и перехватыванием событий нажатий, чтобы оба меню корректно выдвигались и задвигались, я все-же нашел способ, который работает. Что примечательно, для реализации не потребовалось сторонних компонентов и виджетов, а само решение предельно простое.

Реализация

Контейнером для меню служит стандартный андроидовский DrawerLayout, но вместо привычного NavigationView он содержит фрагмент. В этом фрагменте находится SlidingPanelLayout с двумя View внутри. Первая — разметка со списком аккаунтов. Вторая — разметка с основной информацией профиля, навигацией. Именно эта часть меню будет сдвигаться по свайпу. Но всего этого в чистом виде недостаточно, поэтому используется маленький трюк — подробности ниже по тексту.

Перейдем непосредственно к разметке. Пусть читателя не смущает тег <layout>, он нужен для Data Binding. Разметка activity_main.xml:

<layout xmlns:app="http://schemas.android.com/apk/res-auto">

    <android.support.v4.widget.DrawerLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/drawerLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fitsSystemWindows="true">

        <RelativeLayout
            android:id="@+id/relativeLayout"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                android:background="@color/colorPrimary"
                android:elevation="4dp"
                android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light"/>

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_centerInParent="true"
                android:text="Content"
                android:textSize="18sp"/>
        </Relative Layout>

        <!--Отрицательный margin нужен для того, чтобы меню полностью закрыло собой экран.-->

        <fragment
            android:id="@+id/navigationViewFragment" 
            android:name="chuprin.serg.nestednavigationview.NavigationViewFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_gravity="start"
            android:layout_marginStart="-66dp"/>
    </android.support.v4.widget.DrawerLayout>
</layout>

Пока ничего необычного, простая разметка с DrawerLayout, знакомая всем, кто работал с навигацией. Идем дальше.

Разметка фрагмента-меню fragment_navigation_view.xml:

<layout>

    <android.support.design.internal.ScrimInsetsFrameLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/rootLayout"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:fitsSystemWindows="true">

        <android.support.v4.widget.SlidingPaneLayout
            android:id="@+id/slidingPanel"
            android:layout_width="match_parent"
            android:layout_height="match_parent">

            <include
                android:id="@+id/primaryNavView"
                layout="@layout/primary_navigation_view"/>

            <!-- CardView для создания тени. Также здесь регулируется отступ вложенного меню,
            чтобы было заметно, что оно находится выше основоного-->

            <android.support.v7.widget.CardView
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:layout_marginStart="86dp"
                android:elevation="4dp"
                app:cardElevation="4dp">

            <!-- В примере используется стандартный NavigationView, который удобно заполнять
            с помощью xml.
            В зависимости от требований, здесь также можно использовать свою разметку-->

                <android.support.design.widget.NavigationView
                    android:id="@+id/nestedNavigationView"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:paddingTop="24dp"
                    app:headerLayout="@layout/navigation_view_header"
                    app:menu="@menu/menu_nested_navigation_view"/>
            </android.support.v7.widget.CardView>

        </android.support.v4.widget.SlidingPaneLayout>
    
     <android.support.design.widget.FloatingActionButton
            android:id="@+id/editFab"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom"
            android:layout_marginBottom="16dp"
            android:clickable="true"
            android:src="@drawable/ic_pen"
            app:backgroundTint="@color/colorGreen"/>
    </android.support.design.internal.ScrimInsetsFrameLayout>
</layout>

Как я уже говорил, для создания вложенного слайда нам нужна SlidingPanelLayout.

Теперь приступим к java-коду. Класс NavigationViewFragment.java:

public class NavigationViewFragment extends Fragment {
    //DataBinding
    private FragmentNavigationViewBinding mBinding;

    ...................

    @Override
    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);
        mBinding.slidingPanel.setPanelSlideListener(new SlidePanelListener());
        int color = ContextCompat.getColor(getActivity(), R.color.cardview_light_background);
        //Устанавливаем цвет затемнения меню при свайпе
        mBinding.slidingPanel.setSliderFadeColor(color);
        setPrimaryNavViewWidth(view);
        }
}

Стоит обратить внимание на метод setPrimaryNavViewWidth(View view), он нужен для установки размера видимой части меню в открытом состоянии. В примере этот размер равен 1/5 от всей ширины меню.

private void setPrimaryNavViewWidth(View view) {
        view.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
            @Override
            public void onLayoutChange(View view, int i, int i1, int i2, int i3, int i4, int i5, int i6, int i7) {
               view.removeOnLayoutChangeListener(this);
               int width = view.getWidth();
                SlidingPaneLayout.LayoutParams layoutParams = new SlidingPaneLayout
                    .LayoutParams(width - width / 5, ViewGroup.LayoutParams.MATCH_PARENT);
                mBinding.primaryNavView.scrimInsetLayout.setLayoutParams(layoutParams);
        }
    });

И теперь тот самый маленький трюк, о котором я говорил и без которого ничего не получится. Нужно чтобы DrawerLayout блокировался при открытии панели и разблокировался при ее закрытии. Можно поступить двумя способами: первый — фрагмент оповещает Activity об этих событиях с помощью интерфейса, а второй проще и интереснее — т.к фрагмент находится внутри DrawerLayout, т.е является дочерним по отношению к нему, то можно блокировать DrawerLayout прямо из фрагмента. Так и поступим:

public class NavigationViewFragment extends Fragment {
   
    ...............
    private class SlidePanelListener implements SlidingPaneLayout.PanelSlideListener {

        @Override
        public void onPanelSlide(View panel, float slideOffset) {
            // совершенно бесхитростно смещаем FAB вместе со смещением меню.
            
             mBinding.editFab.setX(panel.getX() - mBinding.editFab.getWidth() / 2);

             if (slideOffset > 0.9) {
                mBinding.editFab.show();
             } else {
                mBinding.editFab.hide();
             }
             // здесь можно реализовать дополнительные анимации, связанные с движением меню
        }
        @Override
        public void onPanelOpened(View panel) {
            setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_OPEN);
        }

        @Override
        public void onPanelClosed(View panel) {
            setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED);
        }
        
        private void setDrawerLockMode(int mode) {
            ViewParent parent = mBinding.rootLayout.getParent();
            if (parent instanceof DrawerLayout) {
                ((DrawerLayout) parent).setDrawerLockMode(mode);
            } else {
                throw new IllegalStateException("Fragment should be a child of DrawerLayout for proper work");
            }
        }
    }
}

Также небольшим бонусом будет изменение размера меню для планшетов. Сделаем ширину меню в ландшафтном режиме равной половине экране, в портретном 2/3 экрана. Этот метод необходимо вызывать после создания фрагмента в Activity:

private void setNavigationViewWidth() {
        if (!isTablet()) {
            return;
        }
        DisplayMetrics metrics = new DisplayMetrics();
        getWindowManager().getDefaultDisplay().getMetrics(metrics);
        int width = isLandscape() ? metrics.widthPixels / 2 : metrics.widthPixels - metrics.widthPixels / 3;
        DrawerLayout.LayoutParams params = new DrawerLayout.LayoutParams(width, ViewGroup.LayoutParams.MATCH_PARENT);
        params.gravity = GravityCompat.START;
        mNavigationFragment.getView().setLayoutParams(params);
    }

На планшете это будет смотреться так:

image-1

Завершение

Надеюсь, мой опыт создания такой необычной навигации будет полезен всем, кто решил просто добавить что-нибудь экзотическое в свой проект или же перед кем встала задача реализации подобного меню. Код из статьи доступен по ссылке.

Читать и комментировать
Как сделать простой scrollbar на Typescript
Web-components

Кирилл Торгашин

11 ноября 2016

Web-components

Возможности фреймворка Vue.js. Часть 1

Алексей Форманюк

24 ноября 2016

Возможности фреймворка Vue.js. Часть 1

Поиск пути в Unity 3D

Максим Некрасов

23 мая 2016

Поиск пути в Unity 3D