比较出名的侧滑菜单库有两个,一个是在github上的slidingmenu库,另一个是位于android v4包下的android.support.v4.widget.DrawerLayout控件,两者都支持低版本的sdk。但两者又有本质上的区别,且看下面两张图:
左图是用slidingmenu做出来的效果,可以看到menu视图是处于下方;右边是用drawerlayout做出来的效果,menu视图处于上方。
当然,本篇文章目的不在讨论他们的差异,意在提醒大家注意选择,还是紧跟google脚步为好。
本文使用到了自定义控件专题-基础篇 提到的知识。接下来看一下我们本次要实现的效果图:
首先,我们来完成通过java代码完成控件的初步布局,示意图如下:
从图中可以看出,在初次进入该控件的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>
<?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); // 占满整个屏幕 } }
为了实现控件的滑动,重写了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); } }
这里所要解决的问题是,当我们滑动到某个位置时,松开手指,控件需要自行滑动慢慢滑动到相应位置。
实现方案:
当我们滑动过程中的手指离开屏幕时,判断屏幕应该慢慢滑动到那个界面上,然后使用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触发控件的切换屏幕等。大家可以举一反三,根据业务要求今晚完善。
我是源码