存在的问题
在上文的下拉刷新控件中,有两个问题
- 在下拉到ScrollView顶部时候,继续往下拉时,并不会直接把头布局拉下来,而是需要把手松开后,再次下拉才会拉下头布局,为什么?
上文说过,onInterceptTouchEvent方法虽然不是每次都被调用,但是如果子view在处理事件的时候,onInterceptTouchEvent是一直会调用的,因为他要等待子View不想消费事件的时机出现时,交给自己处理触摸事件,按理说这个问题里,子View不想处理事件的时机已经到了,为什么父View没马上接受呢?
- 当刷新头已经出现了,手指上滑 ,当把刷新头完全隐藏了,继续上滑,此时由于外层布局拦截了事件,会导致把整个外层布局往上滑,而我们想要的是此时让内部的scrollview响应事件,滑动的是scrollview。
我们知道事件分发机制,一旦父view拦截了事件后,就不会把他交给子View了,所以从此之后,scrollview是不会再次有处理事件的机会的,那有什么办法,改良一下呢?
其实这些问题都指向了一个方法onInterceptTouchEvent,他的调用时机到底是什么时候。我们只看伪代码,因为伪代码是思路,是看完源码后的精华,其实这个问题在 之前博客 说过了,但是他太重要了,所以还要总结一次。
事件分发精华伪代码
先看事件分发的入口 dispatchTouchEvent伪代码:
public boolean dispatchTouchEvent(MotionEvent ev) {
Boolean consume = false;
if (【当前ViewGroup要不要拦截事件(ev)】) {
//如果本ViewGroup拦截事件,那么调用本ViewGroup的onTouchEvent
consume = onTouchEvent(ev);
} else {
//否则,调用子View的dispatchTouchEvent,
//如果子View还是一个ViewGroup的话,dispatchTouchEvent逻辑是一样的,会迭代此逻辑
//如果子View是一个View,那他的dispatchTouchEvent是不同的(稍后给出)
consume = child.dispatchTouchEvent(ev);
return consume;//返回true,表示事件被消耗了,
// 如果是最里层级的view或ViewGroup的dispatchTouchEvent返回true,表示由本ViewGroup来处理以后的事件
}
注意【当前ViewGroup要不要拦截事件(ev)】是一个方法,方法名我写成了中文名字,这个方法内部的逻辑,才是重点。这个方法可不等同于直接调用onInterceptTouchEvent哟,而是围绕onInterceptTouchEvent展开的一系列逻辑
【当前ViewGroup要不要拦截事件(ev)】伪代码★相当重要:
public boolean 【当前ViewGroup要不要拦截事件(ev)】{
boolean intercepted;
if(DOWN事件 |或| 他的子View正在消费触摸事件) {
if (子View请求父容器拦截) {
//子View请求父容器拦截就是指,子view通过parent.requestDisallowInterceptTouchEvent(false)
//★★★注意只有此种情况父容器的onInterceptTouchEvent才会被调用
intercepted = onInterceptTouchEvent(ev);
} else {
//如果子View请求不拦截,那么直接返回false,根本不需要走父容器的onInterceptTouchEvent方法
intercepted = false;
}
} else {
//当这个事件不是ACTION_DOWN,并且当前的ViewGroup也没有子ViewGroup(view)可以处理事件,那么就由本ViewGroup直接拦截这个事件,也不需要走父容器的onInterceptTouchEvent方法
intercepted = true;
}
return intercepted;
}
简单说下requestDisallowInterceptTouchEvent(boolean)
注意是在子view里通过,getParent().requestDisallowInterceptTouchEvent(boolean)来使用
- requestDisallowInterceptTouchEvent(false):
子view请求拦截,让父view去询问下自己的onInterceptTouchEvent方法,看看要不要拦截
- requestDisallowInterceptTouchEvent(true):
子view请求不要拦截,那就直接返回false,不去拦截
总结:
关键问题在于:【当前ViewGroup要不要拦截事件(ev)】的逻辑,虽然每次触摸事件都会通过dispatchTouchEvent来调用到【当前ViewGroup要不要拦截事件(ev)】这个方法,但是这并不意味着onInterceptTouchEvent每次都会被调用。
这个关键逻辑,我们能得出以下重要结论
onInterceptTouchEvent被调用的前提条件是:
- Down事件&&子View请求父View不要拦截
- 他的子View正在消费触摸事件&&子View请求父View不要拦截
现在看看最上面抛出的问题:
- 在下拉到ScrollView顶部时候,继续往下拉时,并不会直接把头布局拉下来,而是需要把手松开后,再次下拉才会拉下头布局,为什么?
上文说过,onInterceptTouchEvent方法虽然不是每次都被调用,但是如果子view在处理事件的时候,onInterceptTouchEvent是一直会调用的,因为他要等待子View不想消费事件的时机出现时,交给自己处理触摸事件,按理说这个问题里,子View不想处理事件的时机已经到了,为什么父View没马上接受呢?
为什么父View没马上接受呢?因为在本例子里,内部的ScrollView在某一个时机,调用了 requestDisallowInterceptTouchEvent(false)方法,即请求父view不要拦截,那么根本不去走onInterceptTouchEvent方法,直接intercepted=false了,所以此时下拉不会落下刷新头,而是在第二次下拉时,才会访问自己的onInterceptTouchEvent,发现满足条件,就把刷新头拉下来了(此时ScrollView还没来得及调用requestDisallowInterceptTouchEvent(false)方法)
所以解决方法很简单
在父容器里重写requestDisallowInterceptTouchEvent,让
@Override
public void requestDisallowInterceptTouchEvent(boolean b) {
// 去掉默认行为,使得每个事件都会经过走一下这个布局
}
- 当刷新头已经出现了,手指上滑 ,当把刷新头完全隐藏了,继续上滑,此时由于外层布局拦截了事件,会导致把整个外层布局往上滑,而我们想要的是此时让内部的scrollview响应事件,滑动的是scrollview。
我们知道事件分发机制,一旦父view拦截了事件后,就不会把他交给子View了,所以从此之后,scrollview是不会再次有处理事件的机会的,那有什么办法,改良一下呢?
解决方法,就是在合适的时机,手动代码造一个Down事件,并且分发此事件,因为,这样就突破了在一系列触摸事件中,父容器拦截事件后,子View就没机会再次处理事件的问题,因为我们手动造出了第二次触摸事件,一切从Down开始
if (mLp.topMargin <= -mHeaderHeight && deltaY <0) {
// 重新dispatch一次down事件,使得ScrollView可以继续滚动
int oldAction = event.getAction();
event.setAction(MotionEvent.ACTION_DOWN);
dispatchTouchEvent(event);
event.setAction(oldAction);
}
改良后的源码
package com.view.custom.dosometest.view;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Color;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.ScrollView;
/**
* 描述当前版本功能
*
* @Project: DoSomeTest
* @author: cjx
* @date: 2019-12-01 10:06 星期日
*/
public class RefreshView extends LinearLayout {
private ScrollView mScrollView;
private View mHeader;
private int mHeaderHeight;
private MarginLayoutParams mLp;
public RefreshView(Context context) {
super(context);
init(context);
}
public RefreshView(Context context, AttributeSet attrs) {
super(context, attrs);
init(context);
}
public RefreshView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
setBackgroundColor(Color.GRAY);
post(new Runnable() {
@Override
public void run() {
initView();// 因为涉及到获取控件宽高的问题,所以写到post里
}
});
}
private void initView() {
if (getChildCount() > 2) {
// 给刷新头设置负高度的margin,让他隐藏
mHeader = getChildAt(0);
mHeaderHeight = mHeader.getMeasuredHeight();
mLp = (MarginLayoutParams) mHeader.getLayoutParams();
mLp.topMargin = -mHeaderHeight;
mHeader.setLayoutParams(mLp);
// 得到第二个view,scrollView
View child1 = getChildAt(1);
if (child1 instanceof ScrollView) {
mScrollView = (ScrollView) child1;
}
}
}
float mLastY;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
intercept = false;
break;
case MotionEvent.ACTION_MOVE:
int deltaY = (int) (y - mLastY);
if (needIntercept(deltaY)) {//外部拦截的模板代码,只要重写needIntercept方法逻辑就行
//注意当前ViewGroup一旦拦截,一次事件序列中就再也不会调用onInterceptTouchEvent了,
// 所以子View再也不会得到事件处理的机会了
// 为了解决这个问题,就引出了《嵌套滑动》这个新的事物,见下文
intercept = true;
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
intercept = false;
break;
default:
break;
}
mLastY = y;
return intercept;
}
private boolean needIntercept(int deltaInteceptY) {
// mScrollView已经下拉到最顶部&&你还在下来,那么父容器拦截
if (!mScrollView.canScrollVertically(-1) && deltaInteceptY > 0) {
Log.e("ccc", "不能再往下拉了&&你还在往下拉,父布局拦截,开始拉出刷新头");
return true;
}
if (mLp.topMargin>-mHeaderHeight) {
Log.e("ccc", "只要顶部刷新头,显示着,就让父布局拦截");
return true;
}
return false;
}
@Override
public void requestDisallowInterceptTouchEvent(boolean b) {
// 去掉默认行为,使得每个事件都会经过这个Layout
}
@Override
public boolean onTouchEvent(MotionEvent event) {
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
float deltaY = y - mLastY;
// 防止刷新头被无限制下拉,限定个高度
if (mLp.topMargin + deltaY > mHeaderHeight) {
deltaY = mHeaderHeight - mLp.topMargin;
}
// 动态改变刷新头的topMargin
mLp.topMargin += (int) deltaY;
Log.e("ccc", "y:" + y + "mLastY:" + mLastY + "deltaY:" + deltaY + "mLp.topMargin:" + mLp.topMargin);
mHeader.setLayoutParams(mLp);
if (mLp.topMargin <= -mHeaderHeight && deltaY <0) {
// 重新dispatch一次down事件,使得列表可以继续滚动
int oldAction = event.getAction();
event.setAction(MotionEvent.ACTION_DOWN);
dispatchTouchEvent(event);
event.setAction(oldAction);
}
break;
case MotionEvent.ACTION_UP:
//松手后,看位置,如果过半,刷新头全部显示,没过半,刷新头全部隐藏
if (mLp.topMargin > -mHeaderHeight / 2) {
smoothChangeTopMargin(mLp.topMargin, 0);
} else {
smoothChangeTopMargin(mLp.topMargin, -mHeaderHeight);
}
break;
}
mLastY = y;
return true;
}
/**
* 使用属性动画平滑地过度topMargin
*
* @param start
* @param end
*/
private void smoothChangeTopMargin(int start, int end) {
ValueAnimator valueAnimator = ValueAnimator.ofInt(start, end);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
mLp.topMargin = (int) animation.getAnimatedValue();
mHeader.setLayoutParams(mLp);
}
});
valueAnimator.setDuration(300);
valueAnimator.start();
}
}