Issue #152
This is the part 2 of the tutorial. If you forget, here is the link to part 1.
Link to Github
In the first part, we learn about the idea, the structure of the project and how MainActivity uses the MainLayout. Now we learn how to actually implement the MainLayout
DISPLAY MENU AND CONTENT VIEW
First we have MainLayout as a subclass of LinearLayout
public class MainLayout extends LinearLayout
We then need declare the constructors
public MainLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
public MainLayout(Context context) {
super(context);
}
and override some useful methods
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
// Get our 2 child View
menu = this.getChildAt(0);
content = this.getChildAt(1);
// Initially hide the menu
menu.setVisibility(View.GONE);
}
onAttachedToWindow() is called when MainLayout is attached to window. At this point it has a Surface and will start drawing. Note that this function is guaranteed to be called before onDraw. Here we set child views to our view and content variable
menu = this.getChildAt(0);
content = this.getChildAt(1);
and initially hide the menu. Note that View.GONE tells the view to not take up space in the layout
menu.setVisibility(View.GONE);
In onMeasure(), we compute menuRightMargin, this variable is the amount of right space the menu should not occupy. In this case, we want the menu to take up 90% amount of the screen width. onMeasure() is called to ask all children to measure themselves and compute the measurement of this layout based on the children
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
menuRightMargin = mainLayoutWidth * 10 / 100;
}
Finally, we need to override onLayout(), this is called from layout when this view should assign a size and position to each of its children. This is where we position the menu and content view.
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
// True if MainLayout 's size and position has changed
// If true, calculate child views size
if(changed) {
// Note: LayoutParams are used by views to tell their parents how they want to be laid out
// content View occupies the full height and width
LayoutParams contentLayoutParams = (LayoutParams)content.getLayoutParams();
contentLayoutParams.height = this.getHeight();
contentLayoutParams.width = this.getWidth();
// menu View occupies the full height, but certain width
LayoutParams menuLayoutParams = (LayoutParams)menu.getLayoutParams();
menuLayoutParams.height = this.getHeight();
menuLayoutParams.width = this.getWidth() - menuRightMargin;
}
// Layout the child views
menu.layout(left, top, right - menuRightMargin, bottom);
content.layout(left + contentXOffset, top, right + contentXOffset, bottom);
}
Note for the use of the contentXOffset variable. It is the content that is moving, not the menu. So contentXOffset is used to translate the content horizontally when it is moving
ADDING ANIMATION
So the main idea of sliding menu is to change contentXOffset and call offsetLeftAndRight for the content to move the content. But for the content ’s new position to survive, we need to actually layout it on onLayout(), as shown in previous code snippet For more information, see Flyin menu using offsetLeftAndRight not preserving after a layout
To better control sliding state, we declare MenuState enumeration
private enum MenuState {
HIDING,
HIDDEN,
SHOWING,
SHOWN,
};
HIDDEN state is when menu is fully hidden, and SHOWN state is when menu is fully shown. HIDDING state is when menu is about to hide, and SHOWING state is when menu is about to show. Initially currentMenuState is set to HIDDEN so that the menu won’t show up on first launch.
The main method of our MainLayout is toggleMenu, which, as it name implied, allow us to toggle menu
public void toggleMenu() {
// Do nothing if sliding is in progress
if(currentMenuState == MenuState.HIDING || currentMenuState == MenuState.SHOWING)
return;
switch(currentMenuState) {
case HIDDEN:
currentMenuState = MenuState.SHOWING;
menu.setVisibility(View.VISIBLE);
menuScroller.startScroll(0, 0, menu.getLayoutParams().width,
0, SLIDING_DURATION);
break;
case SHOWN:
currentMenuState = MenuState.HIDING;
menuScroller.startScroll(contentXOffset, 0, -contentXOffset,
0, SLIDING_DURATION);
break;
default:
break;
}
// Begin querying
menuHandler.postDelayed(menuRunnable, QUERY_INTERVAL);
// Invalite this whole MainLayout, causing onLayout() to be called
this.invalidate();
}
Here we use a Scroller to faciliate sliding animation. Note that Scroller does not perform any visual effect, it is just a base for us to track animation by querying the Scroller’s methods. Bills has a good answer on SO Android: Scroller Animation?
The Scroller uses a custom Interpolator to make the sliding more natural. It moves faster in the end. The formula is here
interpolator(t) = (t-1)5 + 1
If the menu is in HIDDEN state, we set its visibility to VISIBLE and start scrolling. Here the content is moving horizontally, so we scroll from left edge to menu width. Note that menu takes up only 90% of the screen width.
If the menu is in SHOWN state, we start scrolling from the content ’s current x position to the left edge.
The 3rd parameter to the startScroll() method is the distance we want to scroll, a negative sign indicates that we want to scroll from right to left.
You can tweak SLIDING_DURATION and QUERY_INTERVAL to your desire. SLIDING_DURATION is the duration of the scrolling. QUERY_INTERVAL is how often we perform querying the Scroller for information. I set it to 16ms so that we have an fps of about 60, which is too high :D
Here the querying is achieved via calling adjustContentPosition() in MenuRunnable
// Begin querying
menuHandler.postDelayed(menuRunnable, QUERY_INTERVAL);
// Query Scroller
protected class MenuRunnable implements Runnable {
@Override
public void run() {
boolean isScrolling = menuScroller.computeScrollOffset();
adjustContentPosition(isScrolling);
}
}
Here we call computeScrollOffset to check if the scrolling is finished or not
private void adjustContentPosition(boolean isScrolling) {
int scrollerXOffset = menuScroller.getCurrX();
//Log.d("MainLayout.java adjustContentPosition()", "scrollerOffset " + scrollerOffset);
// Translate content View accordingly
content.offsetLeftAndRight(scrollerXOffset - contentXOffset);
contentXOffset = scrollerXOffset;
// Invalite this whole MainLayout, causing onLayout() to be called
this.invalidate();
// Check if animation is in progress
if (isScrolling)
menuHandler.postDelayed(menuRunnable, QUERY_INTERVAL);
else
this.onMenuSlidingComplete();
}
We base on getCurrX to update out contentXOffset and translate content view. Remember to call invalidate() everytime the content position is changed. We continue moving the content view until scrolling is finished
Finally, in onMenuSlidingComplete(), we set the currentMenuState accordingly
private void onMenuSlidingComplete() {
switch (currentMenuState) {
case SHOWING:
currentMenuState = MenuState.SHOWN;
break;
case HIDING:
currentMenuState = MenuState.HIDDEN;
menu.setVisibility(View.GONE);
break;
default:
return;
}
}
HANDLING GESTURE
To support gesture, we first attach OnTouchListener to the content view. We do this in onMeasure()
content.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
return MainLayout.this.onContentTouch(v, event);
}
});
And in onContentTouch() we handle the ACTION_DOWN, ACTION_MOVE and ACTION_UP to allow dragging the content view
Please note that we use getRawX() instead of getX() for consistent behavior. More information see PeyloW ’s answer here How do I know if a MotionEvent is relative or absolute?
Here I use curX and diffX to track previous position and how the difference in distance. When user is dragging, we continuously update the content view ’s position. Please also prevent user from dragging beyond the left edge and right margin border
When the user release his/her finger, we base on lastDiffX to decide if the menu should show or hide.
public boolean onContentTouch(View v, MotionEvent event) {
// Do nothing if sliding is in progress
if(currentMenuState == MenuState.HIDING || currentMenuState == MenuState.SHOWING)
return false;
// getRawX returns X touch point corresponding to screen
// getX sometimes returns screen X, sometimes returns content View X
int curX = (int)event.getRawX();
int diffX = 0;
switch(event.getAction()) {
case MotionEvent.ACTION_DOWN:
//Log.d("MainLayout.java onContentTouch()", "Down x " + curX);
prevX = curX;
return true;
case MotionEvent.ACTION_MOVE:
//Log.d("MainLayout.java onContentTouch()", "Move x " + curX);
// Set menu to Visible when user start dragging the content View
if(!isDragging) {
isDragging = true;
menu.setVisibility(View.VISIBLE);
}
// How far we have moved since the last position
diffX = curX - prevX;
// Prevent user from dragging beyond border
if(contentXOffset + diffX <= 0) { // Don't allow dragging beyond left border // Use diffX will make content cross the border, so only translate by -contentXOffset diffX = -contentXOffset; } else if(contentXOffset + diffX > mainLayoutWidth - menuRightMargin) {
// Don't allow dragging beyond menu width
diffX = mainLayoutWidth - menuRightMargin - contentXOffset;
}
// Translate content View accordingly
content.offsetLeftAndRight(diffX);
contentXOffset += diffX;
// Invalite this whole MainLayout, causing onLayout() to be called
this.invalidate();
prevX = curX;
lastDiffX = diffX;
return true;
case MotionEvent.ACTION_UP:
//Log.d("MainLayout.java onContentTouch()", "Up x " + curX);
Log.d("MainLayout.java onContentTouch()", "Up lastDiffX " + lastDiffX);
// Start scrolling
// Remember that when content has a chance to cross left border, lastDiffX is set to 0
if(lastDiffX > 0) {
// User wants to show menu
currentMenuState = MenuState.SHOWING;
// No need to set to Visible, because we have set to Visible in ACTION_MOVE
//menu.setVisibility(View.VISIBLE);
//Log.d("MainLayout.java onContentTouch()", "Up contentXOffset " + contentXOffset);
// Start scrolling from contentXOffset
menuScroller.startScroll(contentXOffset, 0, menu.getLayoutParams().width - contentXOffset,
0, SLIDING_DURATION);
} else if(lastDiffX < 0) {
// User wants to hide menu
currentMenuState = MenuState.HIDING;
menuScroller.startScroll(contentXOffset, 0, -contentXOffset,
0, SLIDING_DURATION);
}
// Begin querying
menuHandler.postDelayed(menuRunnable, QUERY_INTERVAL);
// Invalite this whole MainLayout, causing onLayout() to be called
this.invalidate();
// Done dragging
isDragging = false;
prevX = 0;
lastDiffX = 0;
return true;
default:
break;
}
return false;
}