We implement lateral navigation in Android

Recently, among the patterns of designing mobile applications, there has been a steady tendency to simplify user interaction with the final application. In particular, special emphasis began to be placed on gesture recognition. Gestures are intuitive and natural, they are convenient and allow you to get rid of unnecessary interface elements, simplifying the application.

A good example of the proper use of gestures is the growing popularity of side navigation. An article on lateral navigation as a pattern was previously published on Habré , but nothing was said about implementation.

Unfortunately, there are very few projects implementing lateral navigation, and most of them are slow and inconvenient. I was lucky: some time after the start of the search, I came across a projectActionsContentView , which, in my opinion, worked well and quickly. The project resolved all the problems that I had once encountered myself. After a careful study of the project, it was slightly rewritten by me for my own needs.

Initially, I wanted to paint in this article both the way to open the side menu by click and the way to open the menu with a gesture. However, towards the end of the article, it became obvious that the processing of gestures and the opening of navigation on them is a rather voluminous issue, in which many features should also be taken into account. The article in such a case turns out to be so huge that reading it is simply inconvenient.
Therefore, I decided to describe so far only the implementation of the side menu by click.



Application architecture


We will use Fragment as the content layer, while the menu will be located in the Activity in the background.
The advantage of fragments is obvious: in fact, we can use all the advantages of Activity inside them, plus from the Activity a layer with a fragment is seen as View, which allows us to use standard and familiar methods of working with it as a layer.

We will make Activity static; when passing inside a fragment, only the fragment itself should change. It is also necessary to provide in the fragment the method of starting a new fragment in the same window, as well as the methods for opening / closing the menu.

To implement this, we will create an interface that describes the methods of interaction between the fragment and Activity:

import android.support.v4.app.Fragment;
public interface SideMenuListener {
	public void startFragment(Fragment fragment);
	public boolean toggleMenu();
}

We implement it in 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;
	}
}

Since we need to have access to the above methods from any fragment with content, we will expand the Fragment class and add them:
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();
	}
}

In the future, we will inherit all of our fragments from it.

Create markup, implement fragment change


Now we need to create a list that mimics the menu itself and fill it out. We also need the content fragment itself.

Activity markup file is incredibly simple:

As well as filling it out:
	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 adapter = new ArrayAdapter(this,
        		android.R.layout.simple_list_item_1, names);
        menu.setAdapter(adapter);
    }

Let's create and add our fragment:
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;
	}
}



By this button, you guessed it, we will open or close the menu.

We will implement the mechanism for changing fragments:
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();
	}
	// ...
}


Add the resulting fragment on top of the Activity:

The frame is ready, now you can implement the lateral navigation itself.

Side click navigation


The toggleMenu () method automatically, depending on the state of the menu, opens or closes it. Accordingly, we need to store the state.
We also need to have the value of the coordinate to which the menu will “reach” in case of opening. Since the displays of mobile phones have different widths, it is necessary to store the coefficient, and calculate the value itself based on the resolution of the phone.
It is also advisable to indicate the duration of the opening and closing animation in milliseconds.

So:
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);
        // ..
    }
}


Now a little about the implementation of the class, which will scroll through the menu. For our purposes, we will use the Scroller class, which encapsulates scrolling. In fact, this class takes in the starting point and the offset value, and then generates a certain number for some time.

Most often, Scroller is used inside a thread that recursively calls itself. In all the examples that I met, Scroller is used that way.
Perhaps it can be used in conjunction with an infinite loop in a separate thread, but I decided to use just such an implementation.

For opening / closing the menu, we are responsible for the openMenu () and closeMenu () methods. This method reinitializes the scrolling start variables and starts the fling () method, which is actually engaged in the shift.

In the fling () method, after a series of checks, the Scroller'a countdown starts, after which the stream starts.
The run () method of a thread performs two actions:
  • While the animation is present, using View.scrollTo () shifts the element by the value specified by Scroller
  • Recursively starts its thread again, for subsequent animation


Actually, the class itself is made internal:
	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);
	    } 
	}


Now we just need to initialize such a field in the class and fill in 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;	
	}
}


Done. We have a quick side menu that opens with a button. The only bug - the menu scrolls while scrolling through the fragment. To eliminate this bug, it is necessary to check whether the coordinates of the finger click enter the fragment area and, depending on this, determine whether the event is being used or not.

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);
			}
		});
	// ...
    }
}


Now everything works.
The resulting side menu works very quickly on a variety of phones, while we have a ready-made architectural solution for organizing screen changes.

I will be glad to any comments.

Ready source code


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 adapter = new ArrayAdapter(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


test_fragment.xml



Sources used



Also popular now: