存在的問題
在上文的下拉刷新控件中,有兩個問題
- 在下拉到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();
}
}