В последнее время среди паттернов проектирования мобильных приложений наблюдается устойчивая тенденция к упрощению взаимодействия пользователя с конечным приложением. В частности, особый упор начал делаться на распознавание жестов. Жесты интуитивно понятны и естественны, они удобны и позволяют избавиться от лишних элементов интерфейса, упрощая приложение.
Хороший пример правильного использования жестов — набирающая популярность боковая навигация. На Хабре ранее публиковалась статья о боковой навигации как паттерне, но в ней ничего не было сказано о реализации.
К сожалению, проектов, реализующих боковую навигацию, крайне мало, да и большая часть из них работает медленно и неудобно. Мне повезло: спустя некоторое время после начала поиска я наткнулся на проект 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>