转载请注明出处:http://blog.csdn.net/footballclub/
介绍
如果项目中遇到了侧滑的需求,主流的做法有两个用android自带的drawerlayout和slidingmenu,
因为之前项目中有播放视频的时候滑动菜单就会出现滑动卡顿和闪白的问题,所以个人不推荐slidingmenu;drawerlayout效果可以,但是不一定能符合产品UI设计的要求,因此能自己写侧滑就显得至关重要了。
效果图
从效果图上看,左边的是菜单,右边的是内容区域。菜单是一个包含listview的fragment,内容区域从上往下分别是toolbar,IntegrateFolderTitleStrip,viewpager。当菜单显示的时候,可以看到在内容区域的左边有个阴影的效果。以上就是今天要实现的效果。
原理图解
1.在初始状态菜单是完全隐藏的,所以这时候的布局就是这样
先说明下,黑色框是整个屏幕显示的区域,橙色的框是菜单区域,红色的框是内容区域。
此时,内容区域是把菜单区域完全盖着的,所以我们看不到菜单。
2.当菜单完全打开的时候,内容区域往右边滑动,滑至菜单的右侧,此时内容区域只是部分可见
由上面两幅图,我们能够想到的是,菜单是一个view,内容区域也是view,然后还需要有个容器来放置菜单和内容区域,那么这个容器肯定是viewgroup了,但是如果直接继承自viewgroup的话,我们还得重写它的onLayout方法,所以为了简单就直接继承RelativeLayout了。把view的测量和布局都交给RelativeLayout来做,我们只关注侧滑实现就行了。
开始动手
上面说了我们需要一个RelativeLayout来盛放菜单和内容。
XMenu.java
public class XMenu extends RelativeLayout { private MenuView mMenuView; private ContentView mContentView; public XMenu(Context context) { this(context, null); } public XMenu(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } public XMenu(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(context, attrs); } private void init(Context context, AttributeSet attrs) { LayoutParams behindParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); mMenuView = new MenuView(context); addView(mMenuView, behindParams); LayoutParams aboveParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); mContentView = new ContentView(context); addView(mContentView, aboveParams); } public void setMenu(int layoutId) { setMenu(LayoutInflater.from(getContext()).inflate(layoutId, null)); } public void setMenu(View v) { mMenuView.setView(v); } public void setContent(int layoutId) { setContent(LayoutInflater.from(getContext()).inflate(layoutId, null)); } public void setContent(View v) { mContentView.setView(v); }}
很简单的代码,我们先创建了MenuView和ContentView,分别是为了盛放menu和content的,然后分别加入XMenu中,再对外界提供设置menu和content的方法。接下来再分别实现MenuView和ContentView。
MenuView.java
public class MenuView extends ViewGroup { private int menuWidth = 400; public MenuView(Context context) { super(context); } public MenuView(Context context, AttributeSet attrs) { super(context, attrs); } public MenuView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width = getDefaultSize(0, widthMeasureSpec); int height = getDefaultSize(0, heightMeasureSpec); setMeasuredDimension(width, height); final int menuWidthSpec = getChildMeasureSpec(widthMeasureSpec, 0, this.menuWidth); final int menuHeightSpec = getChildMeasureSpec(heightMeasureSpec, 0, height); final int count = getChildCount(); for (int i = 0; i < count; i++) { View child = getChildAt(i); if(child.getVisibility()!=GONE){ child.measure(menuWidthSpec, menuHeightSpec); } } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { final int count = getChildCount(); for (int i = 0; i < count; i++) { View child = getChildAt(i); if(child.getVisibility()!=GONE){ child.layout(l,t,r,b); } } } public void setMenuWidth(int menuWidth) { this.menuWidth = menuWidth; } public void setView(View v) { if (getChildCount() > 0) { removeAllViews(); } addView(v); }}
MenuView中我们是继承自ViewGroup的,所以我们需要自己去实现测量和布局的步骤,通过重写onMeasure来测量菜单的大小和onLayout来设置菜单的位置。在menuView中,通过setMenuWidth设置菜单的宽度和setView设置菜单view,而setView()中的addView()方法会依次触发onMeasure ,onLayout,从而完成了菜单的测量和布局。
ContentView.java
public class ContentView extends ViewGroup { /** * 菜单宽度 */ public int menuWidth = 400; private Scroller mScroller; /** * 系统所能识别的最小滑动距离 */ private int mTouchSlop; private View mContentView; public ContentView(Context context) { super(context); init(); } public ContentView(Context context, AttributeSet attrs) { super(context, attrs); init(); } public ContentView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); init(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); if (mContentView.getVisibility() != GONE) { mContentView.measure(widthMeasureSpec, heightMeasureSpec); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { if (mContentView.getVisibility() != GONE) { l = getPaddingLeft(); t = getPaddingTop(); r = l + mContentView.getMeasuredWidth(); b = l + mContentView.getMeasuredHeight(); mContentView.layout(l, t, r, b); } } private void init() { setWillNotDraw(false); mScroller = new Scroller(getContext()); mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); } public void setView(View v) { if (getChildCount() > 0) { removeAllViews(); } if (v.getParent() != null) { throw new RuntimeException( "the view has parent,please detach this view first"); } mContentView = v; addView(mContentView); }}
以上代码都很简单,所以做的工作也很简单,只是做好了布局工作,现在还是不能滑动的,所以我们还得再继续往下敲。由易到难一步一步来:
1.先做滑动contentView,打开和关闭菜单的工作,
2.添加侧滑的阴影部分
3.然后再添加手势,让菜单跟随手指动起来
打开和关闭菜单
public void toggle() { int oldScrollX = getScrollX(); if (oldScrollX == 0) {//菜单关闭,打开菜单 smoothScrollTo(-menuWidth); } else if (oldScrollX == -menuWidth) {//菜单打开,关闭菜单 smoothScrollTo(menuWidth); } } private void smoothScrollTo(int dx) { int duration = 500; int oldScrollX = getScrollX(); mScroller.startScroll(oldScrollX, getScrollY(), dx, getScrollY(), duration); invalidate(); } @Override public void computeScroll() { if (!mScroller.isFinished()) { //如果条件成立,则滑动没有结束 if (mScroller.computeScrollOffset()) { int oldX = getScrollX(); int oldY = getScrollY(); int x = mScroller.getCurrX(); int y = mScroller.getCurrY(); if (oldX != x || oldY != y) { //真正触发滑动的操作 scrollTo(x, y); } // Keep on drawing until the animation has finished. postInvalidate(); } } }
首先我们是写了一个toggle方法,在方法里面我们是通过Scroller来移动contentview从而达到打开和关闭菜单的效果。流程就是scroller.startScroll以后,调用invalidate()来触发computeScroll(),在computeScroll中最终通过scrollTo(x,y)来完成真正的滑动操作,这里涉及到了Scroller的操作,如果你还没有接触过Scroller,推荐你看下我写的这篇博客Scroller详解。
这里有一点需要注意的是,Scroller移动的是当前view的内容。也就是说,如果当前view是viewGroup,那么移动的就是他的所有子view也包括背景;如果是view,那么移动的则是视图内容。
所以我这种写法没有移动ContentView,只不过移动了他的child,也就是我们通过
addView(mContentView);
加进来的mContentView。虽然最外层的viewGroup没有被移动,但是他是透明的,所以给我们的感觉就是viewGroup移动了的错觉,我这样说并不是说这样写不好,只是说出原理,以避免出现不必要的错误。接下来添加上阴影部分。
添加侧滑的阴影
阴影部分实现应该是这样,首先写一个gradient drawable,然后在ondraw函数里面画在指定的位置。还有就是阴影是挂在ContentView的左边的,所以我们应该在ContentView的onDraw中来绘制阴影部分,但这里有个问题,ContentView是一个ViewGroup,默认不会回调onDraw(),这时候我们就得调用setWillNotDraw(false);这个方法一看就知道是什么意思,我就不多说了。
ContentView.java
private void init() { setWillNotDraw(false); .... } public void setLeftShadowDrawable(Drawable drawable) { mShadowDrawable = drawable; } /** * 设置阴影的宽度 * @param width */ public void setLeftShadowWidth(int width) { mShadowWidth = width; } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); if (null != mShadowDrawable) { int left = -mShadowWidth; int right = left+mShadowWidth; int top = 0; int bottom = top +getHeight(); mShadowDrawable.setBounds(left, top, right, bottom); mShadowDrawable.draw(canvas); } }
在ContentView中我们只是把注意力放到把阴影绘制到正确的位置上,至于他怎么实现的则通过setLeftShadowDrawable()来交给外界来处理。这里有个坑,我们是通过
mShadowDrawable.setBounds(left, top, right, bottom);
来确定阴影位置的,在确定left的时候一开始我是这么写的
int left = -mShadowWidth - getScrollX();
一开始想着ContentView移动了,阴影部分得绘制在他的左边,所以left也得跟着动,而-getScrollX()得到的就是他左边移动的距离,所以left = -mShadowWidth - getScrollX();这样写了以后,怎么都看不见阴影部分,当时怎么都想不通,后来躺在床上思考的时候灵光一闪,ContentView在移动的时候移动的是他的内容,所以他的坐标系也跟着移动了,这样写就不行了,ContentView的左边的坐标永远是(0,y)。所以阴影部分的left应该是-mShadowWidth。
以上完成了阴影位置的绘制,具体阴影是怎么生成的还没有写。
XMenu.java
private void init(Context context, AttributeSet attrs) { ...... TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.XMenu); try { Drawable leftShadowDrawable = a .getDrawable(R.styleable.XMenu_LeftShadowDrawable); if (null == leftShadowDrawable) { leftShadowDrawable = new GradientDrawable( GradientDrawable.Orientation.LEFT_RIGHT, new int[]{Color.TRANSPARENT, Color.argb(99, 0, 0, 0)}); } mContentView.setLeftShadowDrawable(leftShadowDrawable); } catch (Exception e) { e.printStackTrace(); } finally { a.recycle(); } }public void setLeftShadowWidth(int width) { mContentView.setLeftShadowWidth(width); }
leftShadowDrawable 是一个GradientDrawable,他定义了阴影的样式。
自定义view的属性如下,都很简单
attrs.xml
<resources> <declare-styleable name="XMenu"> <attr name="LeftShadowDrawable" format="reference" /> <attr name="touchModeAbove" format="integer" /> <attr name="edgeWidth" format="integer" /> </declare-styleable></resources>
现在阴影效果也完成了,接着就是添加手势了
添加手势,让菜单跟随手指动起来
如果我们想让菜单跟随手指动起来,那我们首先应该拦截并处理手指滑动的事件,所以正常的思维应该是onInterceptTouchEvent(MotionEvent ev)来拦截手指滑动的事件,并在onTouchEvent(MotionEvent ev)方法中来处理它。除此之外,还有另一种方法,就是直接在dispatchTouchEvent(MotionEvent ev)方法中拦截并处理它。上面两种方法各有优劣,第一种更好理解,但是代码会多一些;而第二种代码量会少一些,至于好不好理解,我认为关键在个人吧,这里我用的是第二种。在上代码之前,先说下怎么使用dispatchTouchEvent来拦截并处理事件:
1.首先事件有down, move ,up , cancel这几种基本的类型;
2.dispatchTouchEvent方法是处理事件分发的,return true代表事件不往下分发,return super.dispatchTouchEvent(ev)则代表向下分发这个事件;
3.在手指动起来也就是move的时候来判断需不需要滑动菜单,如果需要就滑动菜单,然后再return true并发一个cancel的事件给child,后续的事件就用来滑动菜单而不会发给child了。
简单说来就这么多,接下来上代码
ContentView.java
private MotionEvent mLastMoveEvent; private boolean isCloseMenu = false; private boolean isIntercept = true; private boolean mHasSendCancelEvent = false; private int oldScrollX = 0; /** * 是否应该滑动菜单 */ private boolean mShouldMove = true; @Override public boolean dispatchTouchEvent(MotionEvent ev) { final int action = ev.getAction(); final int x = (int) ev.getX(); final int y = (int) ev.getY(); switch (action) { case MotionEvent.ACTION_DOWN: mHasSendCancelEvent = false; mShouldMove = false; if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } mVelocityTracker.addMovement(ev); mLastMotionX = x; mLastMotionY = y; if (isMenuShowing() && mLastMotionX >= menuWidth) { //当菜单显示并且点击的位置处于contentView上,那么在手指抬起时关闭菜单 isCloseMenu = true; } break; case MotionEvent.ACTION_MOVE: mLastMoveEvent = ev; oldScrollX = getScrollX(); if (isIntercept && oldScrollX == 0 && !thisTouchAllowed(ev)) { //当菜单关闭时,不允许打开菜单则返回默认值不拦截 return super.dispatchTouchEvent(ev); } else { //如果已经达到打开菜单的条件了,则不再进行判断了,直接拦截接着进行菜单滑动的操作 isIntercept = false; } final int xDiff = (int) Math.abs(x - mLastMotionX); final int yDiff = (int) Math.abs(y - mLastMotionY); //避免与竖直方向的滑动产生冲突 if (xDiff > yDiff && xDiff > mTouchSlop) { mShouldMove = true; } //滑动菜单 if (mShouldMove) { sendCancelEvent(); final int deltaX = mLastMotionX - x; mLastMotionX = x; int scrollX = oldScrollX + deltaX; final int leftBound = 0; final int rightBound = -menuWidth; //边界控制,防止越界 if (scrollX > leftBound) {//到达左边 scrollX = leftBound; } else if (scrollX < rightBound) {//到达右边 scrollX = rightBound; } scrollTo(scrollX, getScrollY()); return true; } break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: if (isCloseMenu && isMenuShowing()) { showContent(); } else { oldScrollX = getScrollX(); if (oldScrollX < 0 && oldScrollX > -menuWidth) { int dx = 0; if (oldScrollX < -menuWidth / 2) { dx = -menuWidth - oldScrollX; } else { dx = -oldScrollX; } smoothScrollTo(dx); } } isCloseMenu = false; isIntercept = true; if (mVelocityTracker != null) { mVelocityTracker.recycle(); mVelocityTracker = null; } break; } return super.dispatchTouchEvent(ev); } private void sendCancelEvent() { if (!mHasSendCancelEvent) { mHasSendCancelEvent = true; MotionEvent last = mLastMoveEvent; MotionEvent e = MotionEvent.obtain( last.getDownTime(), last.getEventTime() + ViewConfiguration.getLongPressTimeout(), MotionEvent.ACTION_CANCEL, last.getX(), last.getY(), last.getMetaState()); dispatchTouchEventSupper(e); } } public boolean dispatchTouchEventSupper(MotionEvent e) { return super.dispatchTouchEvent(e); } @Override public boolean onTouchEvent(MotionEvent ev) { switch (ev.getAction()) { case MotionEvent.ACTION_DOWN: if (getScrollX() == 0) { // 当菜单隐藏时,消费此事件解决点击穿透的问题 return true; } } return false; }public void setTouchMode(int touchMode) { mTouchMode = touchMode; } public int getTouchMode() { return mTouchMode; } /** * 是否允许滑动菜单,可通过设置TouchMode来避免与viewpager的冲突 * @param ev * @return */ private boolean thisTouchAllowed(MotionEvent ev) { int x = (int) (ev.getX()); if (isMenuShowing()) { return true; } switch (mTouchMode) { case XMenu.TOUCHMODE_FULLSCREEN: return !isInIgnoredView(ev); case XMenu.TOUCHMODE_NONE: return false; case XMenu.TOUCHMODE_MARGIN: return x <= mEdgeWith; } return false; } /** * 当TouchMode为TOUCHMODE_MARGIN时,设定屏幕左边缘可滑动菜单的距离 */ private int mEdgeWith; public void setEdgeWith(int width) { mEdgeWith = width; }
XMenu.java
/** * Constant value for use with setTouchModeAbove(). Allows the SlidingMenu to be opened with a swipe * gesture on the screen's margin */ public static final int TOUCHMODE_MARGIN = 0; /** * Constant value for use with setTouchModeAbove(). Allows the SlidingMenu to be opened with a swipe * gesture anywhere on the screen */ public static final int TOUCHMODE_FULLSCREEN = 1; /** * Constant value for use with setTouchModeAbove(). Denies the SlidingMenu to be opened with a swipe * gesture */ public static final int TOUCHMODE_NONE = 2;private void init(Context context, AttributeSet attrs) { ...... TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.XMenu); try { Drawable leftShadowDrawable = a .getDrawable(R.styleable.XMenu_LeftShadowDrawable); if (null == leftShadowDrawable) { leftShadowDrawable = new GradientDrawable( GradientDrawable.Orientation.LEFT_RIGHT, new int[]{Color.TRANSPARENT, Color.argb(99, 0, 0, 0)}); } mContentView.setLeftShadowDrawable(leftShadowDrawable); int touchModeAbove = a.getInt(R.styleable.XMenu_touchModeAbove, TOUCHMODE_MARGIN); setTouchModeAbove(touchModeAbove); int edgeWidth = a.getInt(R.styleable.XMenu_edgeWidth, DisplayUtil.dip2px(getContext(),10)); mContentView.setEdgeWith(edgeWidth); } catch (Exception e) { e.printStackTrace(); } finally { a.recycle(); }/** * Controls whether the SlidingMenu can be opened with a swipe gesture. * Options are [email protected] #TOUCHMODE_MARGIN TOUCHMODE_MARGIN}, [email protected] #TOUCHMODE_FULLSCREEN TOUCHMODE_FULLSCREEN}, * or [email protected] #TOUCHMODE_NONE TOUCHMODE_NONE} * * @param i the new touch mode */ public void setTouchModeAbove(int i) { if (i != XMenu.TOUCHMODE_FULLSCREEN && i != XMenu.TOUCHMODE_MARGIN && i != XMenu.TOUCHMODE_NONE) { throw new IllegalStateException("TouchMode must be set to either" + "TOUCHMODE_FULLSCREEN or TOUCHMODE_MARGIN or TOUCHMODE_NONE."); } mContentView.setTouchMode(i); } }
这里之所以重写了onTouchEvent方法,是因为初始状态下,ContentView是覆盖在menuView上面的,所以这时候点击ContentView的空白区域会直接作用到MenuView上,从而点击了MenuView上可点击的控件。至于为什么在onTouchEvent的down事件return true就可以解决这个问题,那就需要来分析一下事件的传递机制了:
1.首先,当down事件被屏幕接受以后,先发给activity,然后activity再从上往下分发,因为ContentView在上面,所以先被ContentView接收到;
2.ContentView接受到以后会通过dispatchTouchEvent方法从上往下分发,在分发的过程中如果事件没有被onInterceptTouchEvent方法拦截的话,事件会发到最底层的view;
3.然后最底层的view可以通过onTouchEvent方法来消费此事件,如果没有消费,那么事件将会往上冒泡,挨个遍历view的onTouchEvent来看看有没有需要消费的,如果都没有的话,事件就会被发到MenuView。
点击ContentView的空白区域事件是不会被消费的,所以我们在ContentView的onTouchEvent方法中通过return true来消费down事件以后,后续的事件将都被ContentView的onTouchEvent方法接收,就不会传给MenuView了。
使用
public class MainActivity extends BaseActivity implements MenuFragment.OnFragmentInteractionListener{ private XMenu xMenu; private MenuFragment mMenuFragment; private ContentFragment mContentFragment; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); xMenu = new XMenu(this); setContentView(xMenu); configMenu(); configContent(); } private void configMenu() { mMenuFragment = new MenuFragment(); getSupportFragmentManager().beginTransaction() .replace(R.id.menu_frame, mMenuFragment).commit(); xMenu.setMenu(R.layout.menu_container); int width = Math.min(Constants.sWidth, Constants.sHeight); xMenu.setMenuWidth(3 * width / 4); } private void configContent() { mContentFragment = new ContentFragment(); getSupportFragmentManager().beginTransaction() .replace(R.id.content_frame, mContentFragment).commit(); xMenu.setContent(R.layout.activity_main); } public void onFragmentInteraction(String id){ } public void toggle() { xMenu.toggle(); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()){ case android.R.id.home: toggle(); break; } return super.onOptionsItemSelected(item); }}
很简单的代码,没什么需要解释的。
到现在就已经基本完成了,虽说只有左侧菜单没有右侧菜单,但这也不难,只要再添加个右侧的布局就行了,以后会加上。以上所有的代码都托管在github,欢迎star or follow。如果连不上github,也可以直接这里下载
最后
如果,你认为这篇博客让你有些收获,不妨点击一下【顶】。
如果,你希望更容易地发现我的新博客,不妨点击一下【关注】。
因为,我的热情需要你的肯定和支持。
感谢你的阅读,如果文章中有错误或者你有什么好的建议,也欢迎你直接留言批评指教。Thanks!
版权声明:本文为博主原创文章,未经博主允许不得转载。
- 4楼qq_30411631昨天 21:49
- 太棒了
- Re: footballclub昨天 22:39
- 回复qq_30411631n谢谢支持!
- 3楼ylqhust昨天 21:51
- good,学习了
- 2楼baidu_24470217昨天 21:49
- 不错哦
- Re: footballclub昨天 21:50
- 回复baidu_24470217n谢谢支持!
- 1楼zhangziki昨天 17:14
- 哟,不错
- Re: footballclub昨天 17:51
- 回复zhangzikin谢谢支持!
- Re: footballclub昨天 19:40
- 回复zhangzikin谢谢支持!