В последнее время среди паттернов проектирования мобильных приложений наблюдается устойчивая тенденция к упрощению взаимодействия пользователя с конечным приложением. В частности, особый упор начал делаться на распознавание жестов. Жесты интуитивно понятны и естественны, они удобны и позволяют избавиться от лишних элементов интерфейса, упрощая приложение.
Хороший пример правильного использования жестов — набирающая популярность боковая навигация. На Хабре ранее публиковалась статья о боковой навигации как паттерне, но в ней ничего не было сказано о реализации.
К сожалению, проектов, реализующих боковую навигацию, крайне мало, да и большая часть из них работает медленно и неудобно. Мне повезло: спустя некоторое время после начала поиска я наткнулся на проект ActionsContentView, который, на мой взгляд, работал хорошо и быстро. В проекте были решены все те проблемы, с которым я столкнулся когда-то сам. После внимательного изучения проекта он был немного переписан мною под собственные нужды.
Изначально я хотел в этой статье расписать как и способ открытия бокового меню по клику, так и способ открытия меню жестом. Однако ближе к концу статьи стало очевидно, что обработка жестов и открытие навигации по ним достаточно объемный вопрос, в котором также следует учесть множество особенностей. Статья в таком случаем получается настолько огромная, что читать ее просто неудобно.
Поэтому я решил описать пока лишь реализацию бокового меню по клику.

В качестве слоя с контентом мы будем использовать Fragment, меню же у нас будет располагаться в Activity на заднем плане.
Преимущество фрагментов очевидно: фактически, мы сможем использовать внутри них все преимущества Activity, плюс из Activity слой с фрагментом видится как View, что позволит нам использовать стандартные и привычные методы работы с ним как со слоем.
Activity мы сделаем статичным, при переходах внутри фрагмента у нас должен меняться только сам фрагмент. Также необходимо предусмотреть во фрагменте метод старта нового фрагмента в этом же окне, а также методы открытия/закрытия меню.
Для реализации этого создадим интерфейс, описывающий методы взаимодействия фрагмента и Activity:
Реализуем его в Activity:
Так как нам необходимо иметь доступ вышеперечисленным методам из любого фрагмента с контентом, расширим класс Fragment и добавим их:
В дальнейшем все наши фрагменты мы будем наследовать от него.
Теперь нам потребуется создать список, имитирующий само меню, и заполнить его. Также нам потребуется сам фрагмент с контентом.
Файл разметки Activity невероятно прост:
Так же, как и его заполнение:
Создадим и добавим теперь наш фрагмент:
По этой кнопке, как вы уже догадались, мы будем открывать или закрывать меню.
Реализуем пока механизм смены фрагментов:
Добавим получившийся фрагмент поверх Activity:
Каркас готов, теперь можно реализовывать саму боковую навигацию.
Метод toggleMenu() автоматически в зависимости от состояния меню открывает или закрывает его. Соответственно, нам необходимо хранить состояние.
Также нам необходимо иметь значение координаты, до которой меню будет «доезжать» в случае открытия. Так как дисплеи мобильных телефонов имеют разную ширину, хранить необходимо коэффициент, а само значение вычислять исходя из разрешения телефона.
Желательно также указать и продолжительность действия анимации открытия и закрытия в миллисекундах.
Итак:
Теперь немного о реализации класса, который будет прокручивать меню. Для наших целей мы будем использовать класс Scroller, инкапсулирующий прокрутку. Фактически этот класс принимает в себя начальную точку и значение смещения, а затем в течении некоторого времени генерирует некое число.
Чаще всего Scroller используют внутри потока, рекурсивно вызывающего себя. Во всех примерах, которые я встречал, Scroller используется именно так.
Возможно его можно использовать и совместно с бесконечным циклом в отдельном потоке, однако я решил использовать именно такую реализацию.
За открытие/закрытие меню у нас отвечают методы openMenu() и closeMenu(). Этим методы реинициализируют переменные начала скроллинга и запускают метод fling(), занимающийся, собственно, сдвигом.
В методе fling() после ряда проверок запускается отсчет Scroller'a, после же запускается поток.
Метод run() потока выполняет два действия:
Собственно, сам класс, сделан внутренним:
Теперь нам осталось лишь инициализировать такое поле в классе и заполнить toggleMenu():
Готово. Мы имеем быстрое боковое меню, открывающееся по кнопке. Единственный баг — меню прокручивается во время скроллинга по фрагменту. Для устранения этого бага необходимо проверять, входят ли координаты нажатия пальца в область фрагмента и в зависимости от этого определять, используется ли событие или нет.
Вот теперь все работает.
Полученное боковое меню очень быстро работает на самых разных телефонах, при этом у нас имеется готовое архитектурное решение организации смены экранов.
Буду рад любым замечаниям.
Хороший пример правильного использования жестов — набирающая популярность боковая навигация. На Хабре ранее публиковалась статья о боковой навигации как паттерне, но в ней ничего не было сказано о реализации.
К сожалению, проектов, реализующих боковую навигацию, крайне мало, да и большая часть из них работает медленно и неудобно. Мне повезло: спустя некоторое время после начала поиска я наткнулся на проект ActionsContentView, который, на мой взгляд, работал хорошо и быстро. В проекте были решены все те проблемы, с которым я столкнулся когда-то сам. После внимательного изучения проекта он был немного переписан мною под собственные нужды.
Изначально я хотел в этой статье расписать как и способ открытия бокового меню по клику, так и способ открытия меню жестом. Однако ближе к концу статьи стало очевидно, что обработка жестов и открытие навигации по ним достаточно объемный вопрос, в котором также следует учесть множество особенностей. Статья в таком случаем получается настолько огромная, что читать ее просто неудобно.
Поэтому я решил описать пока лишь реализацию бокового меню по клику.

Архитектура приложения
В качестве слоя с контентом мы будем использовать Fragment, меню же у нас будет располагаться в Activity на заднем плане.
Преимущество фрагментов очевидно: фактически, мы сможем использовать внутри них все преимущества Activity, плюс из Activity слой с фрагментом видится как View, что позволит нам использовать стандартные и привычные методы работы с ним как со слоем.
Activity мы сделаем статичным, при переходах внутри фрагмента у нас должен меняться только сам фрагмент. Также необходимо предусмотреть во фрагменте метод старта нового фрагмента в этом же окне, а также методы открытия/закрытия меню.
Для реализации этого создадим интерфейс, описывающий методы взаимодействия фрагмента и Activity:
import android.support.v4.app.Fragment; public interface SideMenuListener { public void startFragment(Fragment fragment); public boolean toggleMenu(); }
Реализуем его в Activity:
import android.os.Bundle; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentActivity; public class MainActivity extends FragmentActivity implements SideMenuListener { @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); } public void startFragment(Fragment fragment) { // TODO Auto-generated method stub } public boolean toggleMenu() { // TODO Auto-generated method stub return false; } }
Так как нам необходимо иметь доступ вышеперечисленным методам из любого фрагмента с контентом, расширим класс Fragment и добавим их:
import android.support.v4.app.Fragment; public class ContentFragment extends Fragment { protected void startFragment(Fragment fragment) { ((SideMenuListener) getActivity()).startFragment(fragment); } protected boolean toggleMenu() { return ((SideMenuListener) getActivity()).toggleMenu(); } }
В дальнейшем все наши фрагменты мы будем наследовать от него.
Создаем разметку, реализуем смену фрагментов
Теперь нам потребуется создать список, имитирующий само меню, и заполнить его. Также нам потребуется сам фрагмент с контентом.
Файл разметки Activity невероятно прост:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#888" > <ListView android:id="@+id/menu" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentLeft="true" android:layout_alignParentTop="true" /> </RelativeLayout>
Так же, как и его заполнение:
private String[] names = { "Иван", "Марья", "Петр", "Антон", "Даша", "Борис", "Костя", "Игорь", "Анна", "Денис", "Андрей", "Иван", "Марья", "Петр", "Антон", "Даша" }; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ListView menu = (ListView) findViewById(R.id.menu); ArrayAdapter<String> adapter = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, names); menu.setAdapter(adapter); }
Создадим и добавим теперь наш фрагмент:
import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.widget.Button; public class TestFragment extends ContentFragment { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.test_fragment, container, true); Button toogle = (Button) v.findViewById(R.id.toggle); toogle.setOnClickListener( new OnClickListener() { public void onClick(View arg0) { toggleMenu(); } }); return v; } }
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent" android:background="#CCC" > <Button android:id="@+id/toggle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentLeft="true" android:layout_alignParentTop="true" android:text="Toggle" /> </RelativeLayout>
По этой кнопке, как вы уже догадались, мы будем открывать или закрывать меню.
Реализуем пока механизм смены фрагментов:
public class MainActivity extends FragmentActivity implements SideMenuListener { private String[] names = { "Иван", "Марья", "Петр", "Антон", "Даша", "Борис", "Костя", "Игорь", "Анна", "Денис", "Андрей", "Иван", "Марья", "Петр", "Антон", "Даша" }; private FragmentTransaction fragmentTransaction; private View content; private int contentID = R.id.content; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); content = findViewById(contentID); // ... } public void startFragment(Fragment fragment) { fragmentTransaction = getSupportFragmentManager().beginTransaction(); fragmentTransaction.replace(contentID, fragment); fragmentTransaction.addToBackStack(null); fragmentTransaction.commit(); } // ... }
Добавим получившийся фрагмент поверх Activity:
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#888" > <ListView android:id="@+id/menu" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentLeft="true" android:layout_alignParentTop="true" /> <fragment android:id="@+id/content" android:name="com.habr.sidemenu.TestFragment" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_alignParentLeft="true" android:layout_alignParentTop="true" /> </RelativeLayout>
Каркас готов, теперь можно реализовывать саму боковую навигацию.
Боковая навигация по клику
Метод toggleMenu() автоматически в зависимости от состояния меню открывает или закрывает его. Соответственно, нам необходимо хранить состояние.
Также нам необходимо иметь значение координаты, до которой меню будет «доезжать» в случае открытия. Так как дисплеи мобильных телефонов имеют разную ширину, хранить необходимо коэффициент, а само значение вычислять исходя из разрешения телефона.
Желательно также указать и продолжительность действия анимации открытия и закрытия в миллисекундах.
Итак:
public class MainActivity extends FragmentActivity implements SideMenuListener { private final double RIGTH_BOUND_COFF = 0.75; private static int DURATION = 250; private boolean isContentShow = true; private int rightBound; // .. @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); DisplayMetrics displaymetrics = new DisplayMetrics(); getWindowManager().getDefaultDisplay().getMetrics(displaymetrics); rightBound = (int) (displaymetrics.widthPixels * RIGTH_BOUND_COFF); // .. } }
Теперь немного о реализации класса, который будет прокручивать меню. Для наших целей мы будем использовать класс Scroller, инкапсулирующий прокрутку. Фактически этот класс принимает в себя начальную точку и значение смещения, а затем в течении некоторого времени генерирует некое число.
Чаще всего Scroller используют внутри потока, рекурсивно вызывающего себя. Во всех примерах, которые я встречал, Scroller используется именно так.
Возможно его можно использовать и совместно с бесконечным циклом в отдельном потоке, однако я решил использовать именно такую реализацию.
За открытие/закрытие меню у нас отвечают методы openMenu() и closeMenu(). Этим методы реинициализируют переменные начала скроллинга и запускают метод fling(), занимающийся, собственно, сдвигом.
В методе fling() после ряда проверок запускается отсчет Scroller'a, после же запускается поток.
Метод run() потока выполняет два действия:
- Пока анимация присутствует, с помощью View.scrollTo() сдвигает элемент на значение, указываемое Scroller
- Рекурсивно запускает свой поток еще раз, для последующей анимации
Собственно, сам класс, сделан внутренним:
private class ContentScrollController implements Runnable { private final Scroller scroller; private int lastX = 0; public ContentScrollController(Scroller scroller) { this.scroller = scroller; } public void run() { if (scroller.isFinished()) return; final boolean more = scroller.computeScrollOffset(); final int x = scroller.getCurrX(); final int diff = lastX - x; if (diff != 0) { content.scrollBy(diff, 0); lastX = x; } if (more) content.post(this); } public void openMenu(int duration) { isContentShow = false; final int startX = content.getScrollX(); final int dx = rightBound + startX; fling(startX, dx, duration); } public void closeMenu(int duration) { isContentShow = true; final int startX = content.getScrollX(); final int dx = startX; fling(startX, dx, duration); } private void fling(int startX, int dx, int duration) { if (!scroller.isFinished()) scroller.forceFinished(true); if (dx == 0) return; if (duration <= 0) { content.scrollBy(-dx, 0); return; } scroller.startScroll(startX, 0, dx, 0, duration); lastX = startX; content.post(this); } }
Теперь нам осталось лишь инициализировать такое поле в классе и заполнить toggleMenu():
public class MainActivity extends FragmentActivity implements SideMenuListener { private ContentScrollController menuController; // ... @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); menuController = new ContentScrollController(new Scroller(getApplicationContext(), new DecelerateInterpolator(3))); // ... } public boolean toggleMenu() { if(isContentShow) menuController.openMenu(DURATION); else menuController.closeMenu(DURATION); return isContentShow; } }
Готово. Мы имеем быстрое боковое меню, открывающееся по кнопке. Единственный баг — меню прокручивается во время скроллинга по фрагменту. Для устранения этого бага необходимо проверять, входят ли координаты нажатия пальца в область фрагмента и в зависимости от этого определять, используется ли событие или нет.
public class MainActivity extends FragmentActivity implements SideMenuListener { private Rect contentHitRect = new Rect(); // ... @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); content.setOnTouchListener(new OnTouchListener() { public boolean onTouch(View v, MotionEvent event) { v.getHitRect(contentHitRect); contentHitRect.offset(-v.getScrollX(), v.getScrollY()); if (contentHitRect.contains((int)event.getX(), (int)event.getY())) return true; return v.onTouchEvent(event); } }); // ... } }
Вот теперь все работает.
Полученное боковое меню очень быстро работает на самых разных телефонах, при этом у нас имеется готовое архитектурное решение организации смены экранов.
Буду рад любым замечаниям.
Готовый исходный код
SideMenuListener.java
package com.habr.sidemenu; import android.support.v4.app.Fragment; public interface SideMenuListener { public void startFragment(Fragment fragment); public boolean toggleMenu(); }
ContentFragment.java
package com.habr.sidemenu; import android.support.v4.app.Fragment; public class ContentFragment extends Fragment { protected void startFragment(Fragment fragment) { ((SideMenuListener) getActivity()).startFragment(fragment); } protected boolean toggleMenu() { return ((SideMenuListener) getActivity()).toggleMenu(); } }
TestFragment.java
package com.habr.sidemenu; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; import android.view.ViewGroup; import android.widget.Button; public class TestFragment extends ContentFragment { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.test_fragment, container, true); Button toogle = (Button) v.findViewById(R.id.toggle); toogle.setOnClickListener( new OnClickListener() { public void onClick(View arg0) { toggleMenu(); } }); return v; } }
MainActivity.java
package com.habr.sidemenu; import android.graphics.Rect; import android.os.Bundle; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentActivity; import android.support.v4.app.FragmentTransaction; import android.util.DisplayMetrics; import android.view.MotionEvent; import android.view.View; import android.view.View.OnTouchListener; import android.view.animation.DecelerateInterpolator; import android.widget.ArrayAdapter; import android.widget.ListView; import android.widget.Scroller; public class MainActivity extends FragmentActivity implements SideMenuListener { private String[] names = { "Иван", "Марья", "Петр", "Антон", "Даша", "Борис", "Костя", "Игорь", "Анна", "Денис", "Андрей", "Иван", "Марья", "Петр", "Антон", "Даша" }; private FragmentTransaction fragmentTransaction; private View content; private int contentID = R.id.content; private final double RIGTH_BOUND_COFF = 0.75; private static int DURATION = 250; private boolean isContentShow = true; private int rightBound; private ContentScrollController menuController; private Rect contentHitRect = new Rect(); @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); content = findViewById(contentID); menuController = new ContentScrollController(new Scroller(getApplicationContext(), new DecelerateInterpolator(3))); DisplayMetrics displaymetrics = new DisplayMetrics(); getWindowManager().getDefaultDisplay().getMetrics(displaymetrics); rightBound = (int) (displaymetrics.widthPixels * RIGTH_BOUND_COFF); content.setOnTouchListener(new OnTouchListener() { public boolean onTouch(View v, MotionEvent event) { v.getHitRect(contentHitRect); contentHitRect.offset(-v.getScrollX(), v.getScrollY()); if (contentHitRect.contains((int)event.getX(), (int)event.getY())) return true; return v.onTouchEvent(event); } }); ListView menu = (ListView) findViewById(R.id.menu); ArrayAdapter<String> adapter = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, names); menu.setAdapter(adapter); } public void startFragment(Fragment fragment) { fragmentTransaction = getSupportFragmentManager().beginTransaction(); fragmentTransaction.replace(contentID, fragment); fragmentTransaction.addToBackStack(null); fragmentTransaction.commit(); } public boolean toggleMenu() { if(isContentShow) menuController.openMenu(DURATION); else menuController.closeMenu(DURATION); return isContentShow; } private class ContentScrollController implements Runnable { private final Scroller scroller; private int lastX = 0; public ContentScrollController(Scroller scroller) { this.scroller = scroller; } public void run() { if (scroller.isFinished()) return; final boolean more = scroller.computeScrollOffset(); final int x = scroller.getCurrX(); final int diff = lastX - x; if (diff != 0) { content.scrollBy(diff, 0); lastX = x; } if (more) content.post(this); } public void openMenu(int duration) { isContentShow = false; final int startX = content.getScrollX(); final int dx = rightBound + startX; fling(startX, dx, duration); } public void closeMenu(int duration) { isContentShow = true; final int startX = content.getScrollX(); final int dx = startX; fling(startX, dx, duration); } private void fling(int startX, int dx, int duration) { if (!scroller.isFinished()) scroller.forceFinished(true); if (dx == 0) return; if (duration <= 0) { content.scrollBy(-dx, 0); return; } scroller.startScroll(startX, 0, dx, 0, duration); lastX = startX; content.post(this); } } }
activity_main.xml
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#888" > <ListView android:id="@+id/menu" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentLeft="true" android:layout_alignParentTop="true" /> <fragment android:id="@+id/content" android:name="com.habr.sidemenu.TestFragment" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_alignParentLeft="true" android:layout_alignParentTop="true" /> </RelativeLayout>
test_fragment.xml
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent" android:background="#CCC" > <Button android:id="@+id/toggle" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentLeft="true" android:layout_alignParentTop="true" android:text="Toggle" /> </RelativeLayout>
