Pull to refresh

Реализуем боковую навигацию в Android

Reading time12 min
Views11K
В последнее время среди паттернов проектирования мобильных приложений наблюдается устойчивая тенденция к упрощению взаимодействия пользователя с конечным приложением. В частности, особый упор начал делаться на распознавание жестов. Жесты интуитивно понятны и естественны, они удобны и позволяют избавиться от лишних элементов интерфейса, упрощая приложение.

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

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



Используемые источники


Tags:
Hubs:
Total votes 28: ↑24 and ↓4+20
Comments17

Articles