自定义控件专题之三:侧滑菜单

比较出名的侧滑菜单库有两个,一个是在github上的slidingmenu库,另一个是位于android v4包下的android.support.v4.widget.DrawerLayout控件,两者都支持低版本的sdk。但两者又有本质上的区别,且看下面两张图:

自定义控件专题之三:侧滑菜单_第1张图片            自定义控件专题之三:侧滑菜单_第2张图片

左图是用slidingmenu做出来的效果,可以看到menu视图是处于下方;右边是用drawerlayout做出来的效果,menu视图处于上方。


当然,本篇文章目的不在讨论他们的差异,意在提醒大家注意选择,还是紧跟google脚步为好。


本文使用到了自定义控件专题-基础篇 提到的知识。接下来看一下我们本次要实现的效果图:

              自定义控件专题之三:侧滑菜单_第3张图片                     


1、初步布局

首先,我们来完成通过java代码完成控件的初步布局,示意图如下:

自定义控件专题之三:侧滑菜单_第4张图片

从图中可以看出,在初次进入该控件的activity时,菜单是位于手机外的,在本文章中,menu布局将位于左侧。android中规定,屏幕的左上角为原点(即坐标为(0.0)),往左或者往下的坐标是增长的。


我们的布局文件:

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"
    tools:context=".MainActivity" >

    <cn.hugo.android.slidemenu.widget.SlideMenu
        android:id="@+id/slidemenu"
        android:layout_width="fill_parent"
        android:layout_height="fill_parent" >

        <include layout="@layout/slidemenu_menu" />
        <include layout="@layout/slidemenu_main" />
    </cn.hugo.android.slidemenu.widget.SlideMenu>

</RelativeLayout>

slidemenu_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical" >

    <LinearLayout
        android:layout_width="fill_parent"
        android:layout_height="wrap_content"
        android:background="@drawable/top_bar_bg"
        android:orientation="horizontal" >

        <ImageButton
            android:id="@+id/ib_back"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@drawable/main_back" />

        <View
            android:layout_width="1dip"
            android:layout_height="fill_parent"
            android:layout_margin="5dip"
            android:background="@drawable/top_bar_divider" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:layout_marginLeft="10dip"
            android:text="标题"
            android:textColor="@android:color/white"
            android:textSize="28sp" />
    </LinearLayout>

    <TextView
        android:layout_width="fill_parent"
        android:layout_height="fill_parent"
        android:gravity="center"
        android:text="我是正文"
        android:textColor="#FF0000"
        android:textSize="28sp" />

</LinearLayout>

slidemenu_menu.xml

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="240dip"
    android:layout_height="match_parent" >

    <LinearLayout
        android:layout_width="240dip"
        android:layout_height="match_parent"
        android:background="@drawable/menu_bg"
        android:orientation="vertical" >

        <TextView
            style="@style/tab_style"
            android:background="#33663300"
            android:drawableLeft="@drawable/tab_news"
            android:text="新闻" />

        <TextView
            style="@style/tab_style"
            android:drawableLeft="@drawable/tab_read"
            android:text="订阅" />

        <TextView
            style="@style/tab_style"
            android:drawableLeft="@drawable/tab_local"
            android:text="本地" />

        <TextView
            style="@style/tab_style"
            android:drawableLeft="@drawable/tab_ties"
            android:text="跟帖" />

        <TextView
            style="@style/tab_style"
            android:drawableLeft="@drawable/tab_pics"
            android:text="图片" />

        <TextView
            style="@style/tab_style"
            android:drawableLeft="@drawable/tab_focus"
            android:text="话题" />

        <TextView
            style="@style/tab_style"
            android:drawableLeft="@drawable/tab_vote"
            android:text="投票" />

        <TextView
            style="@style/tab_style"
            android:drawableLeft="@drawable/tab_ugc"
            android:text="聚合阅读" />
    </LinearLayout>

</ScrollView>


在activity中,只是简单的把activity_main.xml调用setContentView进去。

自定义控件类SlideMenu继承了viewgroup类,当系统new一个该控件的时候,将会依次调用onMeasure、onLayout方法,由于viewgroup一般不需要自己绘制图形,所以这里并没有重写onDraw方法。运行该app后,会发现界面只看到content部分,看不到menu部分,那是因为menu那部分界面现在偷偷处在屏幕左侧。


package cn.hugo.android.slidemenu.widget;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;
import android.view.ViewGroup;

public class SlideMenu extends ViewGroup{

	private final String TAG = SlideMenu.class.getSimpleName();

	private View mMenuView;
	private View mContentView;

	public SlideMenu(Context context, AttributeSet attrs) {
		super(context, attrs);

	}

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		Log.i(TAG, "onMeasure");

		super.onMeasure(widthMeasureSpec, heightMeasureSpec);

		int measureWidth = measureWidth(widthMeasureSpec); // 获取测量的宽度

		int measureHeight = measureHeight(heightMeasureSpec);// 获取测量的高度

		// 计算自定义的ViewGroup中所有子控件的大小
		measureChildren(widthMeasureSpec, heightMeasureSpec);

		// 设置自定义的控件的大小
		setMeasuredDimension(measureWidth, measureHeight);

	}

	private int measureWidth(int widthMeasureSpec) {
		return getSize(widthMeasureSpec);
	}

	private int getSize(int measureSpec) {
		int result = 0;
		int mode = MeasureSpec.getMode(measureSpec);// 得到模式
		int size = MeasureSpec.getSize(measureSpec);// 得到尺寸

		switch (mode) {
		/**
		 * mode共有三种情况,取值分别为MeasureSpec.UNSPECIFIED, MeasureSpec.EXACTLY,
		 * MeasureSpec.AT_MOST。
		 * 
		 * 
		 * MeasureSpec.EXACTLY是精确尺寸,
		 * 当我们将控件的layout_width或layout_height指定为具体数值时如andorid
		 * :layout_width="50dip",或者为FILL_PARENT是,都是控件大小已经确定的情况,都是精确尺寸。
		 * 
		 * 
		 * MeasureSpec.AT_MOST是最大尺寸,
		 * 当控件的layout_width或layout_height指定为WRAP_CONTENT时
		 * ,控件大小一般随着控件的子空间或内容进行变化,此时控件尺寸只要不超过父控件允许的最大尺寸即可
		 * 。因此,此时的mode是AT_MOST,size给出了父控件允许的最大尺寸。
		 * 
		 * 
		 * MeasureSpec.UNSPECIFIED是未指定尺寸,这种情况不多,一般都是父控件是AdapterView,
		 * 通过measure方法传入的模式。
		 */
			case MeasureSpec.AT_MOST:
			case MeasureSpec.EXACTLY:
				result = size;
				break;
		}

		return result;
	}

	private int measureHeight(int heightMeasureSpec) {
		return getSize(heightMeasureSpec);
	}

	/**
	 * 获取子控件
	 */
	private void findChildView() {

		mMenuView = getChildAt(0); // 需要根据子控件的布局顺序确定序号

		mContentView = getChildAt(1);
	}

	@Override
	protected void onLayout(boolean changed, int l, int t, int r, int b) {

		Log.i(TAG, "onLayout");

		findChildView();

		mMenuView.layout(-mMenuView.getMeasuredWidth(), 0, 0, b); // 把menu放在屏幕外的左侧

		mContentView.layout(l, t, r, b); // 占满整个屏幕

	}

}


2、菜单的滑动

为了实现控件的滑动,重写了ViewGroup的onTouch方法,我们在该方法里,根据滑动的距离更新控件的显示位置。

这里用到了scrollTo和scrollBy方法,两者的区别在于,scrollTo是把控件移动到哪个绝对坐标位置,而scrollBy是相对前一个位置的相对位置,两者在API中都写到

This will cause a call to onScrollChanged(int, int, int, int) and the view will be invalidated.

也就是说,我们不需要调用invalidate方法去更新控件。scrollBy需要注意的地方是,当x的数值为正时,控件是往左移动的。

当我们处理完边界问题后,会发现滑动listView组件时,控件不能正常侧滑,这就需要用到前面文章所述的事件消耗顺序的知识了。


package cn.hugo.android.slidemenu.widget;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;

public class SlideMenu extends ViewGroup {

	private final String TAG = SlideMenu.class.getSimpleName();

	private View mMenuView; // 菜单

	private View mContentView;// 内容

	private int mPreX; // 之前的x轴坐标

	private int mTouchSlop; // 认为是用户是滑动的最小间隔,由系统定

	public SlideMenu(Context context, AttributeSet attrs) {
		super(context, attrs);

		mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
	}

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		Log.i(TAG, "onMeasure");

		super.onMeasure(widthMeasureSpec, heightMeasureSpec);

		int measureWidth = measureWidth(widthMeasureSpec); // 获取测量的宽度

		int measureHeight = measureHeight(heightMeasureSpec);// 获取测量的高度

		// 计算自定义的ViewGroup中所有子控件的大小
		measureChildren(widthMeasureSpec, heightMeasureSpec);

		// 设置自定义的控件的大小
		setMeasuredDimension(measureWidth, measureHeight);

	}

	private int measureWidth(int widthMeasureSpec) {
		return getSize(widthMeasureSpec);
	}

	private int getSize(int measureSpec) {
		int result = 0;
		int mode = MeasureSpec.getMode(measureSpec);// 得到模式
		int size = MeasureSpec.getSize(measureSpec);// 得到尺寸

		switch (mode) {
		/**
		 * mode共有三种情况,取值分别为MeasureSpec.UNSPECIFIED, MeasureSpec.EXACTLY,
		 * MeasureSpec.AT_MOST。
		 * 
		 * 
		 * MeasureSpec.EXACTLY是精确尺寸,
		 * 当我们将控件的layout_width或layout_height指定为具体数值时如andorid
		 * :layout_width="50dip",或者为FILL_PARENT是,都是控件大小已经确定的情况,都是精确尺寸。
		 * 
		 * 
		 * MeasureSpec.AT_MOST是最大尺寸,
		 * 当控件的layout_width或layout_height指定为WRAP_CONTENT时
		 * ,控件大小一般随着控件的子空间或内容进行变化,此时控件尺寸只要不超过父控件允许的最大尺寸即可
		 * 。因此,此时的mode是AT_MOST,size给出了父控件允许的最大尺寸。
		 * 
		 * 
		 * MeasureSpec.UNSPECIFIED是未指定尺寸,这种情况不多,一般都是父控件是AdapterView,
		 * 通过measure方法传入的模式。
		 */
			case MeasureSpec.AT_MOST:
			case MeasureSpec.EXACTLY:
				result = size;
				break;
		}

		return result;
	}

	private int measureHeight(int heightMeasureSpec) {
		return getSize(heightMeasureSpec);
	}

	/**
	 * 获取子控件
	 */
	private void findChildView() {

		mMenuView = getChildAt(0); // 需要根据子控件的布局顺序确定序号

		mContentView = getChildAt(1);
	}

	@Override
	protected void onLayout(boolean changed, int l, int t, int r, int b) {

		Log.i(TAG, "l" + l + "  t:" + t);

		findChildView();

		mMenuView.layout(-mMenuView.getMeasuredWidth(), 0, 0, b); // 把menu放在屏幕外的左侧

		mContentView.layout(l, t, r, b); // 占满整个屏幕

	}

	@Override
	public boolean onTouchEvent(MotionEvent event) {

		switch (event.getAction()) {
			case MotionEvent.ACTION_DOWN: {
				mPreX = (int) event.getX(); // 记录按下时的x轴坐标
				break;
			}
			case MotionEvent.ACTION_UP: {

				break;
			}
			case MotionEvent.ACTION_MOVE: {

				// 获取移动中的坐标
				int newX = (int) event.getX();
				int dx = mPreX - newX;

				// 判断是否移动后是否超过边界
				// Log.i(TAG, "getScrollX=" + getScrollX() + "   dx=" + dx
				// + "  content width=" + mContentView.getWidth());
				int scrollX = getScrollX() + (int) dx; // getScrollX()为控件x轴方向已经滑动的距离
				if (scrollX > 0) { // 超过右边距
					scrollTo(0, 0);
				}
				else if (scrollX < -mMenuView.getWidth()) { // 超过左边距
					scrollTo(-mMenuView.getWidth(), 0);
				}
				else {
					scrollBy(dx, 0); // 使组件位移dx个单位,正数为左移,负数为右移
				}

				mPreX = newX;

				break;
			}
			default:
				break;
		}

		// invalidate(); // 刷新界面

		return true;
	}

	@Override
	public boolean onInterceptTouchEvent(MotionEvent ev) {
		// 处理listview左右滑动时,滑动菜单不能滑动的情况

		switch (ev.getAction()) {
			case MotionEvent.ACTION_DOWN: {

				mPreX = (int) ev.getX();

				break;
			}
			case MotionEvent.ACTION_UP: {

				break;
			}
			case MotionEvent.ACTION_MOVE: {

				int moveX = (int) ev.getX();

				int dx = moveX - mPreX;
				if (Math.abs(dx) > mTouchSlop) { // 认为是横向移动的消耗掉此事件,避免传递到Listview的点击事件
					return true;
				}

				break;
			}
			default:
				break;
		}

		return super.onInterceptTouchEvent(ev);
	}

}

3、加入阻尼动画

这里所要解决的问题是,当我们滑动到某个位置时,松开手指,控件需要自行滑动慢慢滑动到相应位置。

实现方案:

当我们滑动过程中的手指离开屏幕时,判断屏幕应该慢慢滑动到那个界面上,然后使用scroller对滑动过程进行模拟,然后再重写computeScroll方法,由我们控制滑动过程。我们调用mScroller.startScroll(startX, 0, dx, 0, mDuration);后,父类会一直触发computeScroll方法。


package cn.hugo.android.slidemenu.widget;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.Scroller;

public class SlideMenu extends ViewGroup {

	private final String TAG = SlideMenu.class.getSimpleName();

	private View mMenuView; // 菜单

	private View mContentView;// 内容

	private int mPreX; // 之前的x轴坐标

	private int mTouchSlop; // 认为是用户是滑动的最小间隔,由系统定

	private Scroller mScroller; // 模拟滑动时使用

	private final int LOCATION_CONTENT = 0;
	private final int LOCATION_MENU = 1;

	private int mLocation = LOCATION_CONTENT;

	private int mDuration = 300; // 滚动的时长

	public SlideMenu(Context context, AttributeSet attrs) {
		super(context, attrs);

		mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();

		mScroller = new Scroller(context);
	}

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		Log.i(TAG, "onMeasure");

		super.onMeasure(widthMeasureSpec, heightMeasureSpec);

		int measureWidth = measureWidth(widthMeasureSpec); // 获取测量的宽度

		int measureHeight = measureHeight(heightMeasureSpec);// 获取测量的高度

		// 计算自定义的ViewGroup中所有子控件的大小
		measureChildren(widthMeasureSpec, heightMeasureSpec);

		// 设置自定义的控件的大小
		setMeasuredDimension(measureWidth, measureHeight);

	}

	private int measureWidth(int widthMeasureSpec) {
		return getSize(widthMeasureSpec);
	}

	private int getSize(int measureSpec) {
		int result = 0;
		int mode = MeasureSpec.getMode(measureSpec);// 得到模式
		int size = MeasureSpec.getSize(measureSpec);// 得到尺寸

		switch (mode) {
		/**
		 * mode共有三种情况,取值分别为MeasureSpec.UNSPECIFIED, MeasureSpec.EXACTLY,
		 * MeasureSpec.AT_MOST。
		 * 
		 * 
		 * MeasureSpec.EXACTLY是精确尺寸,
		 * 当我们将控件的layout_width或layout_height指定为具体数值时如andorid
		 * :layout_width="50dip",或者为FILL_PARENT是,都是控件大小已经确定的情况,都是精确尺寸。
		 * 
		 * 
		 * MeasureSpec.AT_MOST是最大尺寸,
		 * 当控件的layout_width或layout_height指定为WRAP_CONTENT时
		 * ,控件大小一般随着控件的子空间或内容进行变化,此时控件尺寸只要不超过父控件允许的最大尺寸即可
		 * 。因此,此时的mode是AT_MOST,size给出了父控件允许的最大尺寸。
		 * 
		 * 
		 * MeasureSpec.UNSPECIFIED是未指定尺寸,这种情况不多,一般都是父控件是AdapterView,
		 * 通过measure方法传入的模式。
		 */
			case MeasureSpec.AT_MOST:
			case MeasureSpec.EXACTLY:
				result = size;
				break;
		}

		return result;
	}

	private int measureHeight(int heightMeasureSpec) {
		return getSize(heightMeasureSpec);
	}

	/**
	 * 获取子控件
	 */
	private void findChildView() {

		mMenuView = getChildAt(0); // 需要根据子控件的布局顺序确定序号

		mContentView = getChildAt(1);
	}

	@Override
	protected void onLayout(boolean changed, int l, int t, int r, int b) {

		findChildView();

		mMenuView.layout(-mMenuView.getMeasuredWidth(), 0, 0, b); // 把menu放在屏幕外的左侧

		mContentView.layout(l, t, r, b); // 占满整个屏幕

	}

	@Override
	public boolean onTouchEvent(MotionEvent event) {

		switch (event.getAction()) {
			case MotionEvent.ACTION_DOWN: {
				mPreX = (int) event.getX(); // 记录按下时的x轴坐标
				break;
			}
			case MotionEvent.ACTION_UP: {

				// 获取控件位于哪个界面
				mLocation = getScreenLocation();

				// 滚动到该界面
				toLocation();

				break;
			}
			case MotionEvent.ACTION_MOVE: {

				// 获取移动中的坐标
				int moveX = (int) event.getX();
				int dx = mPreX - moveX;

				// 判断是否移动后是否超过边界
				// Log.i(TAG, "getScrollX=" + getScrollX() + "   dx=" + dx
				// + "  content width=" + mContentView.getWidth());
				int scrollX = getScrollX() + (int) dx; // getScrollX()为控件x轴方向已经滑动的距离
				if (scrollX > 0) { // 超过右边距
					scrollTo(0, 0);
				}
				else if (scrollX < -mMenuView.getWidth()) { // 超过左边距
					scrollTo(-mMenuView.getWidth(), 0);
				}
				else {
					scrollBy(dx, 0); // 使组件位移dx个单位,正数为左移,负数为右移
				}

				mPreX = moveX;

				break;
			}
			default:
				break;
		}

		return true;
	}

	@Override
	public void computeScroll() {

		Log.i(TAG, "computeScroll");

		// 更新x轴偏移量,与scroller配合使用
		if (mScroller.computeScrollOffset()) { // 滚动是否结束
			scrollTo(mScroller.getCurrX(), 0);
		}

		invalidate();

	}

	private void toLocation() {

		int startX = getScrollX();
		int dx = 0;
		if (mLocation == LOCATION_MENU) {
			dx = -(mMenuView.getWidth() + startX);
		}
		else {
			dx = 0 - startX;
		}

		mScroller.startScroll(startX, 0, dx, 0, mDuration);
	}

	private int getScreenLocation() {
		// 认为切换到menu界面的距离
		int locateMenuSlop = (int) (mMenuView.getWidth() / 2);

		// 获得当前控件位于哪个界面
		int scrollX = getScrollX();

		return (Math.abs(scrollX) >= locateMenuSlop ? LOCATION_MENU
				: LOCATION_CONTENT);

	}

	@Override
	public boolean onInterceptTouchEvent(MotionEvent ev) {
		// 处理listview左右滑动时,滑动菜单不能滑动的情况

		switch (ev.getAction()) {
			case MotionEvent.ACTION_DOWN: {

				mPreX = (int) ev.getX();

				break;
			}
			case MotionEvent.ACTION_UP: {

				break;
			}
			case MotionEvent.ACTION_MOVE: {

				int moveX = (int) ev.getX();

				int dx = moveX - mPreX;
				if (Math.abs(dx) > mTouchSlop) { // 认为是横向移动的消耗掉此事件,避免传递到Listview的点击事件
					return true;
				}

				break;
			}
			default:
				break;
		}

		return super.onInterceptTouchEvent(ev);
	}

}


代码还有很多可以扩展的地方,比如,activity触发控件的切换屏幕等。大家可以举一反三,根据业务要求今晚完善。


我是源码






你可能感兴趣的:(android,自定义控件,侧滑菜单)