自定义ViewGroup之侧滑菜单

最近PM2.5对侧滑菜单比较感兴趣,很多页面上都用到了侧滑菜单,之前也在网上看到了很多关于侧滑,有自定义RecyclerView,也有自定义Item的,但是当自己真正去用的时候,发现有很多问题,所以打算自己参考网上的思路自己写一个,果然,看花容易绣花难,写的很艰辛,不过最后还是实现了,下面看看效果图:

侧滑菜单

下面简单分享下实现的思路:

自定义ViewGroup

这个其实没什么太多要说的,主要是有几点需要注意下:

  1. 需要复写三个LayoutParams方法

generateDefaultLayoutParams

当动态向ViewGroup中添加没有参数的child的时候,会自动调用这个方法,将其设置成为默认的参数

generateLayoutParams(AttributeSet attrs)

根据布局中的属性来生成LayoutParams

generateLayoutParams(LayoutParams layoutParams)

代码中动态添加参数

2.在复写onMeasure方法的时候,需要对WrapContent这种情况进行特殊处理,因为很多时候item是包裹child的,高度并没有固定死,所以需要特殊处理,不然会导致菜单栏的内容高度显示不正确处理的方式就是以第一个child也就是内容区域为标准重新测量,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (child.getVisibility() == GONE) {
continue;
}
if (i == 0) {
measureChild(child, widthMeasureSpec, heightMeasureSpec);
childHeight = child.getMeasuredHeight();
} else {
int heightSpec = MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY);
measureChild(child, widthMeasureSpec, heightSpec);
}
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
if (i > 0) {
mMaxDistance += child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
}
}

3.onLayout

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int mLeftOffset = getPaddingLeft();
int topOffset = getPaddingTop();
for (int i = 0; i < childCount; i++) {
View mChild = getChildAt(i);
if (mChild.getVisibility() == GONE) {
continue;
}
MarginLayoutParams lp = (MarginLayoutParams) mChild.getLayoutParams();
mLeftOffset += lp.leftMargin;
topOffset += lp.topMargin;
int measuredWidth = mChild.getMeasuredWidth();
int measuredHeight = mChild.getMeasuredHeight();
mChild.layout(mLeftOffset, topOffset, mLeftOffset + measuredWidth, topOffset + measuredHeight);
mLeftOffset += (measuredWidth + lp.rightMargin);
topOffset = getPaddingTop();
}
}

截止到这里,基本的measure跟layout就结束了,这个不是重点,重点在于解决滑动冲突。

View的滑动冲突

三个方法:

事件分发:public boolean dispatchTouchEvent(MotionEvent ev)

Touch 事件发生时 Activity 的 dispatchTouchEvent(MotionEvent ev) 方法会以隧道方式(从根元素依次往下传递直到最内层子元素或在中间某一元素中由于某一条件停止传递)将事件传递给最外层 View 的 dispatchTouchEvent(MotionEvent ev) 方法,并由该 View 的 dispatchTouchEvent(MotionEvent ev)方法对事件进行分发。dispatchTouchEvent 的事件分发逻辑如下:

  • 如果 return true,事件会分发给当前 View 并由 dispatchTouchEvent 方法进行消费,同时事件会停止向下传递;
  • 如果 return false,事件分发分为两种情况:
  1. 如果当前 View 获取的事件直接来自 Activity,则会将事件返回给 Activity 的 onTouchEvent 进行消费;
  2. 如果当前 View 获取的事件来自外层父控件,则会将事件返回给父 View 的 onTouchEvent 进行消费。
  • 如果返回super.dispatchTouchEvent(ev),事件会自动的分发给当前 View 的 onInterceptTouchEvent 方法。
事件拦截:public boolean onInterceptTouchEvent(MotionEvent ev)

在外层 View 的 dispatchTouchEvent(MotionEvent ev) 方法返回系统默认的 super.dispatchTouchEvent(ev) 情况下,事件会自动的分发给当前 View 的 onInterceptTouchEvent 方法。onInterceptTouchEvent 的事件拦截逻辑如下:

  • 如果返回 true,则表示将事件进行拦截,并将拦截到的事件交由当前 View 的 onTouchEvent 进行处理;
  • 如果返回 false,则表示将事件放行,当前 View 上的事件会被传递到子 View 上,再由子 View 的 dispatchTouchEvent 来开始这个事件的分发;
  • 如果返回 super.onInterceptTouchEvent(ev),事件默认会被拦截,并将拦截到的事件交由当前 View 的 onTouchEvent 进行处理。
事件响应:public boolean onTouchEvent(MotionEvent ev)

在 dispatchTouchEvent 返回 super.dispatchTouchEvent(ev) 并且 onInterceptTouchEvent 返回 true 或返回super.onInterceptTouchEvent(ev) 的情况下 onTouchEvent 会被调用。onTouchEvent 的事件响应逻辑如下:
● 如果事件传递到当前 View 的 onTouchEvent 方法,而该方法返回了 false,那么这个事件会从当前 View 向上传递,并且都是由上层 View 的 onTouchEvent 来接收,如果传递到上面的 onTouchEvent 也返回 false,这个事件就会“消失”,而且接收不到下一次事件。
● 如果返回了 true 则会接收并消费该事件。
● 如果返回 super.onTouchEvent(ev) 默认处理事件的逻辑和返回 false 时相同。

需要注意的是view是没有onInterceptTouchEvent这个方法,只能分发,不存在拦截,只能分发,就跟view没有layout方法是一样的道理。

通过上面的分析,我们需要在onInterceptTouchEvent中进行拦截,然后在onToucheEvent中进行处理

onInterceptTouchEvent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean consume = false;
acquireVelocityTracker(ev);
if (mInterPoint == null)
mInterPoint = new PointF();
if (mTouchPoint == null)
mTouchPoint = new PointF();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
consume = false;
mInterPoint.set(ev.getRawX(), ev.getRawY());
mTouchPoint.set(ev.getRawX(), ev.getRawY());
mPointerId = ev.getPointerId(0);
break;
case MotionEvent.ACTION_MOVE:
float abs = Math.abs(mInterPoint.x - ev.getRawX());
if (Math.abs(abs) > mTouchSlop) {
consume = true;
} else {
consume = isOpened;
}
break;
case MotionEvent.ACTION_UP:
if (isOpened && ev.getX() < getWidth() - getScrollX()) {
closeMenu();
consume = true;
}
break;
}
mInterPoint.set(ev.getRawX(), ev.getRawY());
mTouchPoint.set(ev.getRawX(), ev.getRawY());
return consume;
}

mInterPoint跟mTouchPoint是两个PointF,用来记录onInterceptTouchEvent跟onTouchEvent中的点坐标,isOpened是一个布尔值来记录菜单是否打开,当菜单关闭的时候,点击内容区域是不能进行拦截的,此时需要把点击事件传给child,当菜单打开的时候,此时需要group自己进行处理,需要关闭菜单,所以需要拦截此事件,自己进行处理,onInterceptTouchEvent事件的处理比较简单,就是根据滑动的距离与当前菜单的显示状态比较来判断是否拦截。

onTouchEvent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
if (!mScroller.isFinished())
mScroller.abortAnimation();
int variationX = (int) (mTouchPoint.x - ev.getRawX());
int variationY = (int) (mTouchPoint.y - ev.getRawY());
if (Math.abs(variationX) < Math.abs(variationY)) {
getParent().requestDisallowInterceptTouchEvent(false);
} else {
getParent().requestDisallowInterceptTouchEvent(true);
scrollBy(variationX, 0);
int scrollX = getScrollX();
if (scrollX > mMaxDistance)
scrollTo(mMaxDistance, 0);
if (scrollX < 0)
scrollTo(0, 0);
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
float velocityX = mVelocityTracker.getXVelocity(mPointerId);
if (Math.abs(velocityX) > 1000) {
if (velocityX < -1000)
openMenu();
else
closeMenu();
} else {
if (getScrollX() > mLimit)
openMenu();
else
closeMenu();
}
releaseVelocityTracker();
break;
}
mTouchPoint.set(ev.getRawX(), ev.getRawY());
return true;
}

onTouchEvent就显得有些麻烦

  • ACTION_DOWN

这里不需要记录点坐标,只需要请求父容器不要拦截事件

  • ACTION_DOWN

首先需要判断此时的滑动方向,如果水平方向上的位移小于垂直方向上的位移,那么就把事件交给父容器处理,否则就自己进行处理

  • ACTION_UP

通过两种方式来确定菜单最终是打开还是关闭,一个是根据速度,一个是根据移动的距离,比较好理解

移动的方式

开启菜单

1
2
3
4
5
6
7
private void openMenu() {
isOpened = true;
if (getScrollX() == mMaxDistance)
return;
mScroller.startScroll(getScrollX(), 0, mMaxDistance - getScrollX(), 0, 1000);
invalidate();
}

关闭菜单

1
2
3
4
5
6
7
8
private void closeMenu() {
isOpened = false;
if (getScrollX() == 0)
return;
mScroller.startScroll(getScrollX(), 0, -getScrollX(), 0, 1000);
invalidate();

}

基本上实现了一个菜单的功能,上面只贴出了核心代码,更多代码可以下载Demo下来查看。

项目下载地址