超简自定义ViewGroup
自定义ViewGroup需至少实现:onMeasure方法测量子View的宽高且保存自身宽高,onLayout方法布局子View。
代码如下,会存在一个bug,能看出来吗?代码动态添加3个子View, 子View显示不了。我们的目的是:子View串行显示。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
measureChildren(widthMeasureSpec, heightMeasureSpec);
setMeasuredDimension(width, height);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
int childWidth = 0;
mChildCount = getChildCount();
for (int i = 0; i < mChildCount; i++) {
View child = getChildAt(i);
if (child.getVisibility() != View.GONE) {
mChildWidth = child.getMeasuredWidth();
child.layout(childWidth + getPaddingLeft(), getPaddingTop(), childWidth + mChildWidth - getPaddingRight(), bottom - getPaddingBottom() - top);
childWidth += child.getMeasuredWidth();
}
}
}
xml父控件:
<com.docwei.myviewdemo.scroll.MyOriginalView
android:id="@+id/myview"
android:layout_width="wrap_content"
android:layout_height="240dp" />
//在代码中动态的添加子控件的代码,添加3次。
TextView tv = new TextView(this);
tv.setBackgroundColor(getResources().getColor(R.color.colorAccent2));
tv.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH__PARENT);
containerView.addView(tv);
子View显示不了bug分析:
重写了测量、布局方法, 却不能依次串行显示子View,现在页面显示空白,why ? 从debug日志看,我们发现:setMeasuredDimension(width, height)里面的width是屏幕宽度, 父控件的宽度是屏幕宽度,子View却为0? 翻看源码: measureChildren(widthMeasureSpec, heightMeasureSpec); 这里进去看:spec是父容器的MeasureSpec。
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
int specMode = MeasureSpec.getMode(spec);
int specSize = MeasureSpec.getSize(spec);
int size = Math.max(0, specSize - padding);
int resultSize = 0;
int resultMode = 0;
switch (specMode) {
// Parent has imposed an exact size on us
case MeasureSpec.EXACTLY:
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size. So be it.
resultSize = size;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// Parent has imposed a maximum size on us
case MeasureSpec.AT_MOST:
if (childDimension >= 0) {
// Child wants a specific size... so be it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size, but our size is not fixed.
// Constrain child to not be bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
......
}
//noinspection ResourceType
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
对照源码看这里的父ViewGroup是MeasureSpec.AT_MOST,子View是MATCH_PARENT ,决定了我们的子View的宽度是0到屏幕宽度的范围,实际宽度跟子View内容有关。这里子View内容是空,width是0,height为0,显示不出来没毛病。 但是咱们自定义的ViewGroup又是谁的子View呢,当然是mContentFrameLayout的子View,由于看不到系统的xml,只能假定mContentFrameLayout就是Match_Parent,加上我们给父控件保存宽度时传入的是绝对size, setMeasuredDimension(width, height) 那就能保证父ViewGroup是屏幕宽度。
左右滑动
scrollTo:滑动的是ViewGroup的内容物,移动子View到绝对位置。
scrollBy:滑动的是ViewGroup的内容物,移动子View到相对位置。
getScrollX( ): 右滑负数,左滑正数,以控件的left为原点(0,0),左滑,控件的内容会向左移动,此时原点和内容左缘的dx > 0;右滑,控件的内容会向右移动,此时原点和内容左缘的dx < 0。
当down事件按下时,记录x点,move事件时,二者相减获取scrollBy的值,up事件里需要处理好滑动的最终位置,如果滑动不到一半就回去,超过一半就滑出来,注意边界值判断。
public boolean onTouchEvent(MotionEvent event) {
float x = event.getX();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//最初down时要保存到
break;
case MotionEvent.ACTION_POINTER_DOWN:
break;
case MotionEvent.ACTION_MOVE:
//*0.85f是为了增加阻力f
scrollBy(-(int) ((x - mLastX)*0.85f), 0);
break;
case MotionEvent.ACTION_UP:
//处理最后定位的位置
//getScrollX距离View的left滑动了多远
//>0是左滑 <0是右滑*/
int scrollX = getScrollX();
//默认过了中点就算滑动一个子View
int index = (scrollX + mChildWidth / 2) / mChildWidth;
if (index > mChildCount - 1) {
index = mChildCount - 1;
}
if (index < 0) {
index = 0;
}
scrollBy(index * mChildWidth - scrollX,0);
break;
default:
break;
}
mLastX = x;
return true;
}
支持弹性滑动,模拟惯性
弹性滑动我们使用Scroller(下面替换成OverScroller也可以),模拟惯性使用VelocityTracker
Scroller的使用
Scroller mScroller = new Scroller(context);
public void startSmoothScrool(int start, int x) {
mScroller.startScroll(start, 0, x, 0, 500);
invalidate();
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidate();
}
}
VelocityTracker的使用 (RecylcerView的onTouchEvent有使用VelocityTracker,可以参考)
VelocityTracker mVelocityTracker = VelocityTracker.obtain();
mVelocityTracker.addMovement(event); //添加事件
//up事件
mVelocityTracker.computeCurrentVelocity(1000);
float xVelocity = mVelocityTracker.getXVelocity();
//依据速度去处理逻辑
//左滑(xVelocity<0) 当速度达到200,此时如果index的float值在1.1——1.5那么给他2
if (Math.abs(xVelocity) >= 200 && left == index && xVelocity < 0) {
index = (scrollX + mChildWidth) / mChildWidth;
}
//右滑(xVelocity>0) 当速度达到200,此时如果index的float值在3.1——3.4那么给他2
if (Math.abs(xVelocity) >= 200 && right == index && xVelocity > 0) {
index = index - 1;
}
//清空速度
mVelocityTracker.clear();
//回收
@Override
protected void onDetachedFromWindow() {
mVelocityTracker.recycle();
super.onDetachedFromWindow();
}
多点触控
多点触控的必须掌握的要点
-
使用event.getActionMasked( ) 用于多点触控获取Action。
-
以下api要牢记了:
final int actionIndex = event.getActionIndex();
final int pointerId = event.getPointerId(actionIndex);
final int pointerIndex = event.findPointerIndex(pointerId);
final float x= event.getX(pointerIndex);
我们需要pointerIndex去获取事件内容。所有的多点触控的处理逻辑都是基于以上api
-
event.getActionIndex() 获取到的actionIndex仅仅在事件序列中的down和up事件中是准确的。move事件不要用。
-
PointerId在一次事件序列中是不会变的。
-
actionIndex和PointerId都会存在补位的情况。
-
action_pointer_down以及action_pointer_up触发时,一定至少还有一个手指在屏幕上的。
多点触控的场景:
-
接力型(微信朋友圈下拉) 其本质是只追踪一根手指
每一次按下新的手指,那么调整它为活动手指,只追踪这个活动手指, 如果是活动的手指pointer_up了,就需要指定还存在屏幕上的手指为活动的手指。
重新指定新的活动手指,这里提供两种方式:
方式一:
Pointer_UP事件:
if (event.getPointerId(actionIndex) == activePointer && event.getPointerCount() > 1) {
//actionIndex存在补位机制
int newIndex;
if (actionIndex == event.getPointerCount() - 1) {
newIndex = event.getPointerCount() - 2;
} else {
newIndex = event.getPointerCount() - 1;
}
activePointer = event.getPointerId( newIndex);
final int newPointerIndex = event.findPointerIndex(activePointer);
//pointerIndex out of range
mLastX = event.getX(newPointerIndex);
}
方式二:
//不建议使用如下的方式,可能出现pointerIndex out of range异常
if (event.getPointerId(actionIndex) == activePointer && event.getPointerCount() > 1) {
//如果当前的点是0,就选择1,因为至少有一个手指在View上 ,actionIndex存在补位机制
activePointer = event.getPointerId((actionIndex == 0) ? 1 : 0);
final int newPointerIndex = event.findPointerIndex(activePointer);
//pointerIndex out of range
mLastX = event.getX(newPointerIndex);
}
move事件要添加判断,谨防pointerIndex out of range
for (int i = 0; i < event.getPointerCount(); i++) {
if (event.getPointerId(i) == activePointer) {
float x = event.getX(event.findPointerIndex(activePointer));
scrollBy(-(int) (x - mLastX) / 3, 0);
mLastX = x;
}
}
-
互不干扰型(多指同时涂鸦) 其本质是追踪多根手指
-
所有的down事件发生时,需要记录所有的pointerId,因为一个事件序列中pointerId不会变。
-
在move事件基于pointerId对应的pointerIndex获取历史轨迹点。不用怕,系统提供了对应的api 如:getHistoricalX( )等。
-
由于每次up事件都有一个手指抬起,pointerId被回收,出现补位的情况, 要想保留之前绘制的内容,需要在up事件保存已有的path,
-
case MotionEvent.ACTION_MOVE:
//拿到记录的PointerId的path
for(Integer index:paths.keySet()) {
for (int i = 0; i < event.getPointerCount(); i++) {
int pointerId = event.getPointerId(i);
if(index==pointerId){
//History历史记录是最近一次move产生的,要记录完整的path,需要将每一次lineTo连接
for(int j=0;j<event.getHistorySize();j++){
float x= event.getHistoricalX(event.findPointerIndex(pointerId),j);
float y= event.getHistoricalY(event.findPointerIndex(pointerId),j);
paths.get(index).lineTo(x,y);
}
//也要加入最新的点,跟下一次可能有重复
paths.get(index).lineTo(event.getX(event.findPointerIndex(index))
,event.getY(event.findPointerIndex(index)));
}
}
}
滑动冲突
一、方向垂直的事件冲突
比如:我们写的自定义ViewGroup支持左右滑动,其3个子view是3个RecyclerView,recyclerView都是只支持上下滑动。 当我们出现这种场景的时候,我们发现左右滑动RecyclerView,竟然不支持左右滑动,需要处理冲突。
为什么会产生冲突?在哪里处理方便?
之前我们在自定义ViewGroup的时候,里面添加3个子View都是TextView,父容器完美的左右侧滑,现在加上RecyclerView为啥就不行了?
先来看下我们自定义的ViewGroup
//拦截的方法默认是false,不拦截
public boolean onInterceptTouchEvent(MotionEvent ev) {
....处理鼠标来源事件可忽略...
return false;
}
其onTouchEvent方法是默认所有事件都返回true,能消费所有的事件。
- a. 当子View全部是TextView的时候,TextView走了View的onTouchEvent方法,那View的onTouchEvent方法里面,根据clickable来判断能否消费事件, 如果当前控件没设置setOnClickListener,那么clickable是false,也就是说这几个子View不能消费事件。事件往上传递,父ViewGroup重写了onTouchEvent方法。 可以消费事件,那父控件就把事件消费了。(当然你给TextView设置了点击监听,这个自定义ViewGroup也会出现左右滑不动的情况)
- b. 换成RecyclerView,RecyclerView的onTouchEvent,无任何判断,默认返回true,表示不管怎样都要消费事件,父ViewGroup就没法拿到这个事件,滑不动,正常啊。
既然找到原因了,那处理就很简单:
方式1 . 事件先是父ViewGroup持有,那何不在ViewGroup的拦截事件里面去对左右滑动事件截胡呢,这样的确很方便。
注意一个关键点: 父ViewGroup只能消费左右move事件,其onTouchEvent事件里面的down事件是不走的,但是每一次事件都会经过父ViewGroup的onInterceptTouchEvent判断,所以只能在onInterceptTouchEvent获取down事件,保存按下的点的位置给父ViewGroup的onTouchEvent中的move用。
private float lastDownX;
private float lastDownY;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final int actionIndex = ev.getActionIndex();
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
case MotionEvent.ACTION_POINTER_DOWN:
//第一次按下或者后续按下,都将其作为活动的手指
activePointer = ev.getPointerId(actionIndex);
final int pointerIndex = ev.findPointerIndex(activePointer);
lastDownX = ev.getX(pointerIndex);
lastDownY = ev.getY(pointerIndex);
break;
case MotionEvent.ACTION_MOVE:
for (int i = 0; i < ev.getPointerCount(); i++) {
if (ev.getPointerId(i) == activePointer) {
float x = ev.getX(ev.findPointerIndex(activePointer));
float y = ev.getY(ev.findPointerIndex(activePointer));
float deltaX = x - lastDownX;
float deltaY = y - lastDownY;
//左右滑动
if (Math.abs(deltaX) > Math.abs(deltaY)) {
return true;
}
}
}
default:
break;
}
return false;
}
}
方式2(不可行). 重写RecyclerView的onTouchEvent事件,对左右滑动不消费。貌似也可以。但实操时仅仅对RecyclerView重写onTouchEvent方法是不成功。
二、方向平行的事件冲突
ScrollView嵌套RecyclerView:都是上下滑动,嵌套多个RecyclerView,第二个、第三个RecyclerView滑动卡卡的,事件全部被ScrollView吞了。
能修改ScrollView吗,可以。但是处理很麻烦。
只能让RecyclerView在想要上下滑动事件时老老实实跟Parent说我要这个上下滑动(申请不拦截),但是又有一个问题,RecyclerView把所有的上下滑动都申请不拦截了,那第一个RecylerView滑到底部了, 要滑动第二个RecyclerView,怎么办?只能让第一个RecyclerView滑到底部,将上下滑动事件还给Parent,让他消费。这样才能显示后面的RecyclerView。
在处理前,先看下ViewGroup的dispatchTouchEvent源码
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
.....
if (actionMasked == MotionEvent.ACTION_DOWN
|| mFirstTouchTarget != null) {
final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
if (!disallowIntercept) {
intercepted = onInterceptTouchEvent(ev);
ev.setAction(action); // restore action in case it was changed
} else {
intercepted = false;
}
} else {
// There are no touch targets and this action is not an initial down
// so this view group continues to intercept touches.
intercepted = true;
}
.....
}
这个源码部分要记住,到时候面试可以拿出来说,这个点,决定处理冲突时为啥子View要在down事件里面一定要返回true;
mFirstTouchTarget:不为null,表示有子View处理事件序列中的某个action了。
当down事件传过来时,如果子View没有申请不拦截,那么就走父容器的 onInterceptTouchEvent(ev),由于ViewGroup默认不拦截,自定义的ViewGroup除外,down事件继续传到子View,子View在onTouchEvent 的down返回false,表示不消费down事件,那这个事件序列中的move up 事件通通都不会传给子View了,因为mFirstTouchTarget=null,直接走(intercepted = true;)逻辑。
总结默认情况下的ViewGroup的2个论点:
父ViewGroup拦截down事件,子View是不可能拿到任何事件的。因为事件序列以down事件开始。
父ViewGroup不拦截down事件,如果子View不消费down事件,那后续move、 up事件都不经过父容器的onInterceptTouchEvent(ev)判断直接就不分发给子View了;子View消费down事件,后续的事件都会走父容器的onInterceptTouchEvent(ev),如果此时父容器拦截move事件,那子View是没法拿到move事件的,此时子View会触发其cancel事件。
解决冲突的代码:
private float lastDownX;
private float lastDownY;
//scrollView嵌套多个子RecyclerView,
//不处理冲突,那么滑到第二个RecyclerView就会卡卡的
//处理:OnTouchEvent事件里面先设置消费down事件
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()){
case MotionEvent.ACTION_DOWN:
lastDownX = ev.getX();
lastDownY = ev.getY();
//down事件必须消费
return true;
case MotionEvent.ACTION_MOVE:
float moveX = ev.getX();
float moveY = ev.getY();
float deltaX = moveX - lastDownX;
float deltaY = moveY - lastDownY;
//上下滑动
Log.e("scrollView ", "dispatchTouchEvent: " + Math.abs(deltaX) + "-----lllll" +Math.abs(deltaY));
if (Math.abs(deltaX) < Math.abs(deltaY)) {
getParent().requestDisallowInterceptTouchEvent(true);
}
//滑动最后一个Item的时候要置为false,不然没法滑到下一个RecyclerView
LinearLayoutManager linearLayoutManager= (LinearLayoutManager) getLayoutManager();
if(linearLayoutManager.findLastVisibleItemPosition()==getAdapter().getItemCount()-1){
getParent().requestDisallowInterceptTouchEvent(false);
}
lastDownX = moveX;
lastDownY = moveY;
break;
}
return super.onTouchEvent(ev);
}