
嵌套系列導航
- 1.淺析NestedScrolling嵌套滑動機制之基礎篇
- 2.淺析NestedScrolling嵌套滑動機制之實踐篇-仿寫?zhàn)I了么商家詳情頁
- 3.淺析NestedScrolling嵌套滑動機制之CoordinatorLayout.Behavior
- 4.淺析NestedScrolling嵌套滑動機制之實踐篇-自定義Behavior實現(xiàn)小米音樂歌手詳情
本文已在公眾號鴻洋原創(chuàng)發(fā)布。未經(jīng)許可,不得以任何形式轉載!
概述
NestedScrolling是Android5.0推出的嵌套滑動機制,能夠讓父View和子View在滑動時相互協(xié)調配合可以實現(xiàn)連貫的嵌套滑動,它基于原有的觸摸事件分發(fā)機制上為ViewGroup和View增加處理滑動的方法提供調用,后來為了向前兼容到Android1.6,在Revision 22.1.0的android.support.v4兼容包中提供了從View、ViewGroup抽取出NestedScrollingChild、NestedScrollingParent兩個接口和NestedScrollingChildHelper、NestedScrollingParentHelper兩個輔助類來幫助控件實現(xiàn)嵌套滑動,CoordinatorLayout便是基于這個機制實現(xiàn)各種神奇的滑動效果。
處理同向滑動事件沖突
如果兩個可滑動的容器嵌套,外部View攔截了內部View的滑動,可能造成滑動沖突,通?;趥鹘y(tǒng)的觸摸事件分發(fā)機制來解決:
1.外部攔截法
public class MyScrollView extends ScrollView {
private int mLastY = 0;
//此處省略構造方法
...
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercepted = false;
int y = (int) ev.getY();
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
intercepted = false;
//調用ScrollView的onInterceptTouchEvent()初始化mActivePointerId
super.onInterceptTouchEvent(ev);
break;
case MotionEvent.ACTION_MOVE:
int detY = y - mLastY;
//這里要找到子ScrollView
View contentView = findViewById(R.id.my_scroll_inner);
if (contentView == null) {
return true;
}
//判斷子ScrollView是否滑動到頂部或者頂部
boolean isChildScrolledTop = detY > 0 && !contentView.canScrollVertically(-1);
boolean isChildScrolledBottom = detY < 0 && !contentView.canScrollVertically(1);
if (isChildScrolledTop || isChildScrolledBottom) {
intercepted = true;
} else {
intercepted = false;
}
break;
case MotionEvent.ACTION_UP:
intercepted = false;
break;
}
mLastY = y;
return intercepted;
}
}
2.內部攔截法
public class MyScrollView extends ScrollView {
private int mLastY = 0;
//此處省略構造方法
...
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
int y = (int) ev.getY();
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
getParent().requestDisallowInterceptTouchEvent(true);
break;
case MotionEvent.ACTION_MOVE:
int detY = y - mLastY;
boolean isScrolledTop = detY > 0 && !canScrollVertically(-1);
boolean isScrolledBottom = detY < 0 && !canScrollVertically(1);
//根據(jù)自身是否滑動到頂部或者頂部來判斷讓父View攔截觸摸事件
if (isScrolledTop || isScrolledBottom) {
getParent().requestDisallowInterceptTouchEvent(false);
}
break;
}
mLastY = y;
return super.dispatchTouchEvent(ev);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
super.onInterceptTouchEvent(ev);
return false;
}
return true;
}
}
3.小結
上面通過兩種經(jīng)典的解決方案,在內部View可以滑動時,外部View不攔截,當內部View滑動到底部或者頂部時,讓外部消費滑動事件進行滑動。一般而言,外部攔截法和內部攔截法不能公用。 否則內部容器可能并沒有機會調用 requestDisallowInterceptTouchEvent方法。在傳統(tǒng)的觸摸事件分發(fā)中,如果不手動調用分發(fā)事件或者去發(fā)出事件,外部View最先拿到觸摸事件,一旦它被外部View攔截消費了,內部View無法接收到觸摸事件,同理,內部View消費了觸摸事件,外部View也沒有機會響應觸摸事件。 而接下介紹的NestedScrolling機制,在一次滑動事件中外部View和內部View都有機會對滑動進行響應,這樣處理滑動沖突就相對方便許多。
NestedScrolling機制原理
NestedScrollingChild(下圖簡稱nc)、NestedScrollingParent(下圖簡稱np)邏輯上分別對應之前內部View和外部View的角色,之所以稱之為邏輯上是因為View可以同時扮演NestedScrollingChild和NestedScrollingParent,下面圖片就是NestedScrolling的交互流程。
接下來詳細說明一下上圖的交互流程:
1.當NestedScrollingChild接收到觸摸事件MotionEvent.ACTION_DOWN時,它會往外層布局遍歷尋找最近的NestedScrollingParent請求配合處理滑動。所以它們之間層級不一定是直接上下級關系。
2.如果NestedScrollingParent不配合NestedScrollingChild處理滑動就沒有接下來的流程,否則就會配合處理滑動。
3.NestedScrollingChild要滑動之前,它先拿到MotionEvent.ACTION_MOVE滑動的dx,dy并將一個有兩個元素的數(shù)組(分別代表NestedScrollingParent要滑動的水平和垂直方向的距離)作為輸出參數(shù)一同傳給NestedScrollingParent。
4.NestedScrollingParent拿到上面【3】NestedScrollingChild傳來的數(shù)據(jù),將要消費的水平和垂直方向的距離傳進數(shù)組,這樣NestedScrollingChild就知道NestedScrollingParent要消費滑動值是多少了。
5.NestedScrollingChild將【2】里拿到的dx、dy減去【4】NestedScrollingParent消費滑動值,計算出剩余的滑動值;如果剩余的滑動值為0說明NestedScrollingParent全部消費了NestedScrollingChild不應進行滑動;否則NestedScrollingChild根據(jù)剩余的滑動值進行消費,然后將自己消費了多少、還剩余多少匯報傳遞給NestedScrollingParent。
6.如果NestedScrollingChild在滑動期間發(fā)生的慣性滑動,它會將velocityX,velocityY傳給NestedScrollingParent,并詢問NestedScrollingParent是否要全部消費。
7.NestedScrollingParent收到【6】NestedScrollingChild傳來的數(shù)據(jù),告訴NestedScrollingChild是否全部消費慣性滑動。
8.如果在【7】NestedScrollingParent沒有全部消費慣性滑動,NestedScrollingChild會將velocityX,velocityY、自身是否需要消費全部慣性滑動傳給NestedScrollingParent,并詢問NestedScrollingParent是否要全部消費。
9.NestedScrollingParent收到【8】NestedScrollingChild傳來的數(shù)據(jù),告訴NestedScrollingChild是否全部消費慣性滑動。
10.NestedScrollingChild停止滑動時通知NestedScrollingParent。
PS:
- A.上面的【消費】是指可滑動View調用自身的滑動方法進行滑動來消耗滑動數(shù)值,比如scrollBy()、scrollTo()、fling()、offsetLeftAndRight()、offsetTopAndBottom()、layout()、Scroller、LayoutParams等,View實現(xiàn)NestedScrollingParent、NestedScrollingChild只僅僅是能將數(shù)值進行傳遞,需要配合Touch事件根據(jù)需求去調用NestScrolling的接口和輔助類,而本身不支持滑動的View即使有嵌套滑動的相關方法也不能進行嵌套滑動。
- B.在【1】中外層實現(xiàn)NestedScrollingParent的View不該攔截NestedScrollingChild的MotionEvent.ACTION_DOWN;在【2】中如果NestedScrollingParent配合處理滑動時,實現(xiàn)NestedScrollingChild的View應該通過getParent().requestDisallowInterceptTouchEvent(true)往上遞歸關閉外層View的事件攔截機制,這樣確?!?】中NestedScrollingChild先拿到MotionEvent.ACTION_MOVE。具體可以參考RecyclerView和NestedScrollView源碼的觸摸事件處理。
類與接口
前面提到Android 5.0及以上的View、ViewGroup自身分別就有NestedScrollingChild和NestedScrollingParent的方法,而方法邏輯就是對應的NestedScrollingChildHelper和NestedScrollingParentHelper的具體方法實現(xiàn),所以本小節(jié)不講解View、ViewGroup的NestedScrolling機制相關內容,請自行查看源碼。
1.NestedScrollingChild
public interface NestedScrollingChild {
/**
* @param enabled 開啟或關閉嵌套滑動
*/
void setNestedScrollingEnabled(boolean enabled);
/**
* @return 返回是否開啟嵌套滑動
*/
boolean isNestedScrollingEnabled();
/**
* 沿著指定的方向開始滑動嵌套滑動
* @param axes 滑動方向
* @return 返回是否找到NestedScrollingParent配合滑動
*/
boolean startNestedScroll(@ScrollAxis int axes);
/**
* 停止嵌套滑動
*/
void stopNestedScroll();
/**
* @return 返回是否有配合滑動NestedScrollingParent
*/
boolean hasNestedScrollingParent();
/**
* 滑動完成后,將已經(jīng)消費、剩余的滑動值分發(fā)給NestedScrollingParent
* @param dxConsumed 水平方向消費的距離
* @param dyConsumed 垂直方向消費的距離
* @param dxUnconsumed 水平方向剩余的距離
* @param dyUnconsumed 垂直方向剩余的距離
* @param offsetInWindow 含有View從此方法調用之前到調用完成后的屏幕坐標偏移量,
* 可以使用這個偏移量來調整預期的輸入坐標(即上面4個消費、剩余的距離)跟蹤,此參數(shù)可空。
* @return 返回該事件是否被成功分發(fā)
*/
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
/**
* 在滑動之前,將滑動值分發(fā)給NestedScrollingParent
* @param dx 水平方向消費的距離
* @param dy 垂直方向消費的距離
* @param consumed 輸出坐標數(shù)組,consumed[0]為NestedScrollingParent消耗的水平距離、
* consumed[1]為NestedScrollingParent消耗的垂直距離,此參數(shù)可空。
* @param offsetInWindow 同上dispatchNestedScroll
* @return 返回NestedScrollingParent是否消費部分或全部滑動值
*/
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow);
/**
* 將慣性滑動的速度和NestedScrollingChild自身是否需要消費此慣性滑動分發(fā)給NestedScrollingParent
* @param velocityX 水平方向的速度
* @param velocityY 垂直方向的速度
* @param consumed NestedScrollingChild自身是否需要消費此慣性滑動
* @return 返回NestedScrollingParent是否消費全部慣性滑動
*/
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
/**
* 在慣性滑動之前,將慣性滑動值分發(fā)給NestedScrollingParent
* @param velocityX 水平方向的速度
* @param velocityY 垂直方向的速度
* @return 返回NestedScrollingParent是否消費全部慣性滑動
*/
boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
2.NestedScrollingParent
public interface NestedScrollingParent {
/**
* 對NestedScrollingChild發(fā)起嵌套滑動作出應答
* @param child 布局中包含下面target的直接父View
* @param target 發(fā)起嵌套滑動的NestedScrollingChild的View
* @param axes 滑動方向
* @return 返回NestedScrollingParent是否配合處理嵌套滑動
*/
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
/**
* NestedScrollingParent配合處理嵌套滑動回調此方法
* @param child 同上
* @param target 同上
* @param axes 同上
*/
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes);
/**
* 嵌套滑動結束
* @param target 同上
*/
void onStopNestedScroll(@NonNull View target);
/**
* NestedScrollingChild滑動完成后將滑動值分發(fā)給NestedScrollingParent回調此方法
* @param target 同上
* @param dxConsumed 水平方向消費的距離
* @param dyConsumed 垂直方向消費的距離
* @param dxUnconsumed 水平方向剩余的距離
* @param dyUnconsumed 垂直方向剩余的距離
*/
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed);
/**
* NestedScrollingChild滑動完之前將滑動值分發(fā)給NestedScrollingParent回調此方法
* @param target 同上
* @param dx 水平方向的距離
* @param dy 水平方向的距離
* @param consumed 返回NestedScrollingParent是否消費部分或全部滑動值
*/
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
/**
* NestedScrollingChild在慣性滑動之前,將慣性滑動的速度和NestedScrollingChild自身是否需要消費此慣性滑動分
* 發(fā)給NestedScrollingParent回調此方法
* @param target 同上
* @param velocityX 水平方向的速度
* @param velocityY 垂直方向的速度
* @param consumed NestedScrollingChild自身是否需要消費此慣性滑動
* @return 返回NestedScrollingParent是否消費全部慣性滑動
*/
boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
/**
* NestedScrollingChild在慣性滑動之前,將慣性滑動的速度分發(fā)給NestedScrollingParent
* @param target 同上
* @param velocityX 同上
* @param velocityY 同上
* @return 返回NestedScrollingParent是否消費全部慣性滑動
*/
boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
/**
* @return 返回當前嵌套滑動的方向
*/
int getNestedScrollAxes();
}
3.方法調用流程圖:
4.NestedScrollingChildHepler
NestedScrollingChildHepler對NestedScrollingChild的接口方法做了代理,您可以結合實際情況借助它來實現(xiàn),如:
public class MyScrollView extends View implements NestedScrollingChild{
...
@Override
public boolean startNestedScroll(int axes) {
return mChildHelper.startNestedScroll(axes);
}
}
這里只分析關鍵的方法,具體代碼請參考源碼。
4.1 startNestedScroll()
public boolean startNestedScroll(int axes) {
//判斷是否找到配合處理滑動的NestedScrollingParent
if (hasNestedScrollingParent()) {
// Already in progress
return true;
}
if (isNestedScrollingEnabled()) {//判斷是否開啟滑動嵌套
ViewParent p = mView.getParent();
View child = mView;
//循環(huán)往上層尋找配合處理滑動的NestedScrollingParent
while (p != null) {
//ViewParentCompat.onStartNestedScroll()會判斷p是否實現(xiàn)NestedScrollingParent,
//若是則將p轉為NestedScrollingParent類型調用onStartNestedScroll()方法
if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
mNestedScrollingParent = p;
//通過ViewParentCompat調用p的onNestedScrollAccepted()方法
ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
return true;
}
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
}
return false;
}
這個方法首先會判斷是否已經(jīng)找到了配合處理滑動的NestedScrollingParent、若找到了則返回true,否則會判斷是否開啟嵌套滑動,若開啟了則通過構造函數(shù)注入的View來循環(huán)往上層尋找配合處理滑動的NestedScrollingParent,循環(huán)條件是通過ViewParentCompat這個兼容類判斷p是否實現(xiàn)NestedScrollingParent,若是則將p轉為NestedScrollingParent類型調用onStartNestedScroll()方法如果返回true則證明找配合處理滑動的NestedScrollingParent,所以接下來同樣借助ViewParentCompat調用NestedScrollingParent的onNestedScrollAccepted()。
4.2 dispatchNestedPreScroll()
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {//如果開啟嵌套滑動并找到配合處理滑動的NestedScrollingParent
if (dx != 0 || dy != 0) {//如果有水平或垂直方向滑動
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
//先記錄View當前的在Window上的x、y坐標值
mView.getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
//初始化輸出數(shù)組consumed
if (consumed == null) {
if (mTempNestedScrollConsumed == null) {
mTempNestedScrollConsumed = new int[2];
}
consumed = mTempNestedScrollConsumed;
}
consumed[0] = 0;
consumed[1] = 0;
//通過ViewParentCompat調用NestedScrollingParent的onNestedPreScroll()方法
ViewParentCompat.onNestedPreScroll(mNestedScrollingParent, mView, dx, dy, consumed);
if (offsetInWindow != null) {
//將之前記錄好的x、y坐標減去調用NestedScrollingParent的onNestedPreScroll()后View的x、y坐標,計算得出偏移量并賦值進offsetInWindow數(shù)組
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
//consumed數(shù)組的兩個元素的值有其中一個不為0則說明NestedScrollingParent消耗的部分或者全部滑動值
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
這個方法首先會判斷是否開啟嵌套滑動并找到配合處理滑動的NestedScrollingParent,若符合這兩個條件則會根據(jù)參數(shù)dx、dy滑動值判斷是否有水平或垂直方向滑動,若有滑動調用mView.getLocationInWindow()將View當前的在Window上的x、y坐標值賦值進offsetInWindow數(shù)組并以startX、startY記錄,接下來初始化輸出數(shù)組consumed、并通過ViewParentCompat調用NestedScrollingParent的onNestedPreScroll(),再次調用mView.getLocationInWindow()將調用NestedScrollingParent的onNestedPreScroll()后的View在Window上的x、y坐標值賦值進offsetInWindow數(shù)組并與之前記錄好的startX、startY相減計算得出偏移量,接著以consumed數(shù)組的兩個元素的值有其中一個不為0作為boolean值返回,若條件為true說明NestedScrollingParent消耗的部分或者全部滑動值。
4.3 dispatchNestedScroll()
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {//如果開啟嵌套滑動并找到配合處理滑動的NestedScrollingParent
if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {//如果有消費滑動值或者有剩余滑動值
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
//先記錄View當前的在Window上的x、y坐標值
mView.getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
//通過ViewParentCompat調用NestedScrollingParent的onNestedScroll()方法
ViewParentCompat.onNestedScroll(mNestedScrollingParent, mView, dxConsumed,
dyConsumed, dxUnconsumed, dyUnconsumed);
if (offsetInWindow != null) {
//將之前記錄好的x、y坐標減去調用NestedScrollingParent的onNestedScroll()后View的x、y坐標,計算得出偏移量并賦值進offsetInWindow數(shù)組
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
//返回true表明NestedScrollingChild的dispatchNestedScroll事件成功分發(fā)NestedScrollingParent
return true;
} else if (offsetInWindow != null) {
// No motion, no dispatch. Keep offsetInWindow up to date.
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
這個方法與上面的dispatchNestedPreScroll()方法十分類似,這里就不細說了。
4.3 dispatchNestedPreFling()、dispatchNestedFling()
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
//通過ViewParentCompat調用NestedScrollingParent的onNestedPreFling()方法,返回值表示NestedScrollingParent是否消費全部慣性滑動
return ViewParentCompat.onNestedPreFling(mNestedScrollingParent, mView, velocityX,
velocityY);
}
return false;
}
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
//通過ViewParentCompat調用NestedScrollingParent的onNestedFling()方法,返回值表示NestedScrollingParent是否消費全部慣性滑動
return ViewParentCompat.onNestedFling(mNestedScrollingParent, mView, velocityX,
velocityY, consumed);
}
return false;
}
這兩方法都是通過ViewParentCompat調用NestedScrollingParent對應的fling方法來返回NestedScrollingParent是否消費全部慣性滑動。
4.NestedScrollingParentHelper
public class NestedScrollingParentHelper {
private final ViewGroup mViewGroup;
private int mNestedScrollAxes;
public NestedScrollingParentHelper(ViewGroup viewGroup) {
mViewGroup = viewGroup;
}
public void onNestedScrollAccepted(View child, View target, int axes) {
mNestedScrollAxes = axes;
}
public int getNestedScrollAxes() {
return mNestedScrollAxes;
}
public void onStopNestedScroll(View target) {
mNestedScrollAxes = 0;
}
}
NestedScrollingParentHelper只提供對應NestedScrollingParent相關的onNestedScrollAccepted()和onStopNestedScroll()方法,主要維護mNestedScrollAxes管理滑動的方向字段。
NestedScrolling機制的改進
慣性滑動不連續(xù)問題
在使用之前NestedScrolling機制的 系統(tǒng)控件 嵌套滑動,當內部View快速滑動產(chǎn)生慣性滑動到邊緣就停止,而不將慣性滑動傳遞給外部View繼續(xù)消費慣性滑動,就會出現(xiàn)下圖兩個NestedScrollView嵌套滑動這種 慣性滑動不連續(xù) 的情況:
這里以com.android.support:appcompat-v7:22.1.0的NestedScrollView源碼作為分析問題例子:
@Override
public boolean onTouchEvent(MotionEvent ev) {
...
switch (actionMasked) {
...
case MotionEvent.ACTION_UP:
if (mIsBeingDragged) {
final VelocityTracker velocityTracker = mVelocityTracker;
velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int initialVelocity = (int) VelocityTrackerCompat.getYVelocity(velocityTracker,
mActivePointerId);
if ((Math.abs(initialVelocity) > mMinimumVelocity)) {
//分發(fā)慣性滑動
flingWithNestedDispatch(-initialVelocity);
}
mActivePointerId = INVALID_POINTER;
endDrag();
}
break;
}
...
}
private void flingWithNestedDispatch(int velocityY) {
final int scrollY = getScrollY();
final boolean canFling = (scrollY > 0 || velocityY > 0) &&
(scrollY < getScrollRange() || velocityY < 0);
if (!dispatchNestedPreFling(0, velocityY)) {//將慣性滑動分發(fā)給NestedScrollingParent,讓它先對慣性滑動進行處理
dispatchNestedFling(0, velocityY, canFling);//若慣性滑動沒被消費,再次將慣性滑動分發(fā)給NestedScrollingParent,并帶上自身是否能消費fling的canFling參數(shù)讓NestedScrollingParent根據(jù)情況處理決定canFling是true還是false
if (canFling) {
//執(zhí)行fling()消費慣性滑動
fling(velocityY);
}
}
}
public void fling(int velocityY) {
if (getChildCount() > 0) {
int height = getHeight() - getPaddingBottom() - getPaddingTop();
int bottom = getChildAt(0).getHeight();
//初始化fling的參數(shù)
mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0,
Math.max(0, bottom - height), 0, height/2);
//重繪會觸發(fā)computeScroll()進行滾動
ViewCompat.postInvalidateOnAnimation(this);
}
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
int oldX = getScrollX();
int oldY = getScrollY();
int x = mScroller.getCurrX();
int y = mScroller.getCurrY();
if (oldX != x || oldY != y) {
final int range = getScrollRange();
final int overscrollMode = ViewCompat.getOverScrollMode(this);
final boolean canOverscroll = overscrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
(overscrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
overScrollByCompat(x - oldX, y - oldY, oldX, oldY, 0, range,
0, 0, false);
if (canOverscroll) {
ensureGlows();
if (y <= 0 && oldY > 0) {
mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
} else if (y >= range && oldY < range) {
mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
}
}
}
}
}
上面代碼執(zhí)行如下:
1.當快速滑動并抬起手指時onTouchEvent()方法會命中MotionEvent.ACTION_UP,執(zhí)行關鍵flingWithNestedDispatch()方法將垂直方向的慣性滑動值分發(fā)。
2.flingWithNestedDispatch()方法先調用dispatchNestedPreFling()將慣性滑動分發(fā)給NestedScrollingParent,若NestedScrollingParent沒有消費則調用dispatchNestedFling()并帶上自身是否能消費fling的canFling參數(shù)讓NestedScrollingParent可以根據(jù)情況處理決定canFling是true還是false,若canFling值為true,執(zhí)行fling()方法。
3.fling()方法執(zhí)行mScroller.fling()初始化fling參數(shù),然后 調用ViewCompat.postInvalidateOnAnimation()重繪觸發(fā)computeScroll()方法進行滾動。
4.computeScroll()方法里面只讓自身進行fling,并沒有在自身fling到邊緣時將慣性滑動分發(fā)給NestedScrollingParent。
NestedScrollingChild2、NestedScrollingParent2
在Revision 26.1.0的android.support.v4兼容包添加了NestedScrollingChild2、NestedScrollingParent2兩個接口:
public interface NestedScrollingChild2 extends NestedScrollingChild {
boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type);
void stopNestedScroll(@NestedScrollType int type);
boolean hasNestedScrollingParent(@NestedScrollType int type);
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
@NestedScrollType int type);
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
@Nullable int[] offsetInWindow, @NestedScrollType int type);
}
public interface NestedScrollingParent2 extends NestedScrollingParent {
boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes,
@NestedScrollType int type);
void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes,
@NestedScrollType int type);
void onStopNestedScroll(@NonNull View target, @NestedScrollType int type);
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type);
void onNestedPreScroll(@NonNull View target, int dx, int dy, @Nullable int[] consumed,
@NestedScrollType int type);
}
它們分別繼承NestedScrollingChild、NestedScrollingParent,都為滑動相關的方法添加了int類型參數(shù)type,這個參數(shù)有兩個值:TYPE_TOUCH值為0表示滑動由用戶手勢滑動屏幕觸發(fā);TYPE_NON_TOUCH值為1表示滑動不是由用戶手勢滑動屏幕觸發(fā);同時View、ViewGroup、NestedScrollingChildHelper、NestedScrollingParentHelper同樣根據(jù)參數(shù)type做了調整。
前面說到因為系統(tǒng)控件在computeScroll()方法里面只讓自身進行fling,并沒有在自身fling到邊緣時將慣性滑動分發(fā)給NestedScrollingParent導致慣性滑動不連貫,所以這里以com.android.support:appcompat-v7:26.1.0的NestedScrollView源碼看看如何使用改進后的NestedScrolling機制:
public void fling(int velocityY) {
if (getChildCount() > 0) {
//發(fā)起滑動嵌套,注意ViewCompat.TYPE_NON_TOUCH參數(shù)表示不是由用戶手勢滑動屏幕觸發(fā)
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL,ViewCompat.TYPE_NON_TOUCH);
mScroller.fling(getScrollX(), getScrollY(),
0, velocityY, 0, 0,Integer.MIN_VALUE, Integer.MAX_VALUE,0, 0);
mLastScrollerY = getScrollY();
ViewCompat.postInvalidateOnAnimation(this);
}
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
final int x = mScroller.getCurrX();
final int y = mScroller.getCurrY();
int dy = y - mLastScrollerY;
// Dispatch up to parent(將滑動值分發(fā)給NestedScrollingParent2)
if (dispatchNestedPreScroll(0, dy, mScrollConsumed, null,ViewCompat.TYPE_NON_TOUCH)) {
//計算NestedScrollingParent2消費后剩余的滑動值
dy -= mScrollConsumed[1];
}
if (dy != 0) {//若滑動值沒有NestedScrollingParent2全部消費掉,則自身進行消費滾動
final int range = getScrollRange();
final int oldScrollY = getScrollY();
overScrollByCompat(0, dy, getScrollX(), oldScrollY, 0, range, 0, 0, false);
final int scrolledDeltaY = getScrollY() - oldScrollY;
final int unconsumedY = dy - scrolledDeltaY;
if (!dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, null,ViewCompat.TYPE_NON_TOUCH)) {//若滾動值沒有分發(fā)成功給NestedScrollingParent2,則自己用EdgeEffect消費
final int mode = getOverScrollMode();
final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
|| (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
if (canOverscroll) {
ensureGlows();
if (y <= 0 && oldScrollY > 0) {
mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
} else if (y >= range && oldScrollY < range) {
mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
}
}
}
}
// Finally update the scroll positions and post an invalidation
mLastScrollerY = y;
ViewCompat.postInvalidateOnAnimation(this);
} else {
// We can't scroll any more, so stop any indirect scrolling
if (hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
}
// and reset the scroller y
mLastScrollerY = 0;
}
}
代碼分析如下:
1.與之前的NestedScrollView相比,fling()方法里面用到了NestedScrollingChild2的startNestedScroll方法發(fā)起滑動嵌套。
2.computeScroll()方法首先調用dispatchNestedPreScroll()將滑動值分發(fā)給NestedScrollingParent2,若滑動值沒有被NestedScrollingParent2全部消費掉,則自身進行消費滾動,然后再調用dispatchNestedScroll()將自身消費、剩余的滑動值分發(fā)給NestedScrollingParent2,若分發(fā)失敗則用EdgeEffect(這個用來滑動到頂部或者底部時會出現(xiàn)一個波浪形的邊緣效果)消費掉,當mScroller滾動完成后調用stopNestedScroll()方法結束嵌套滑動。
OverScroller未終止?jié)L動動畫
在使用之前NestedScrolling機制的 系統(tǒng)控件 嵌套滑動,當子、父View都在頂部時,首先快速下滑子View并抬起手指制造慣性滑動,然后馬上滑動父View,這時就會出現(xiàn)上圖的兩個NestedScrollView嵌套滑動現(xiàn)象,你手指往上滑視圖內容往下滾一段距離,視圖內容立刻就會自動往上回滾。
這里還是以com.android.support:appcompat-v7:26.1.0的NestedScrollView源碼作為分析問題例子:
private void flingWithNestedDispatch(int velocityY) {
final int scrollY = getScrollY();
final boolean canFling = (scrollY > 0 || velocityY > 0)
&& (scrollY < getScrollRange() || velocityY < 0);
if (!dispatchNestedPreFling(0, velocityY)) {
dispatchNestedFling(0, velocityY, canFling);
fling(velocityY);
}
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (actionMasked) {
...
case MotionEvent.ACTION_DOWN: {
...
//停止mScroller滾動
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
}
...
}
...
}
代碼執(zhí)行如下:
1.這里分析場景是兩個NestedScrollView嵌套滑動,所以dispatchNestedPreFling()返回值為false,子View執(zhí)行就會fling()方法,前面分析過fling()方法調用mScroller.fling()觸發(fā)computeScroll()進行實際的滾動。
2.在子View調用computeScroll()方法期間,如果此時子View不命中MotionEvent.ACTION_DOWN,mScroller是不會停止?jié)L動,只能等待它完成,于是就子View就不停調用dispatchNestedPreScroll()和dispatchNestedScroll()分發(fā)滑動值給父View,就出現(xiàn)了上圖的場景。
NestedScrollingChild3、NestedScrollingParent3
在androidx.core 1.1.0-alpha01開始引入NestedScrollingChild3、NestedScrollingParent3,它們在androidx.core:core:1.1.0正式被添加:
public interface NestedScrollingChild3 extends NestedScrollingChild2 {
void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed,
@Nullable int[] offsetInWindow, @ViewCompat.NestedScrollType int type,
@NonNull int[] consumed);
}
public interface NestedScrollingParent3 extends NestedScrollingParent2 {
void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
int dyUnconsumed, @ViewCompat.NestedScrollType int type, @NonNull int[] consumed);
}
NestedScrollingChild3繼承NestedScrollingChild2重載dispatchNestedScroll()方法,從返回值類型boolean改為void類型,添加了一個int數(shù)組consumed參數(shù)作為輸出參數(shù)記錄NestedScrollingParent3消費的滑動值,同理,NestedScrollingParent3繼承NestedScrollingParent2重載onNestedScroll添加了一個int數(shù)組consumed參數(shù)來對應NestedScrollingChild3,NestedScrollingChildHepler、NestedScrollingParentHelper同樣根據(jù)變化做了適配調整。
下面是androidx.appcompat:appcompat:1.1.0的NestedScrollView源碼看看如何使用改進后的NestedScrolling機制:
@Override
public void computeScroll() {
if (mScroller.isFinished()) {
return;
}
mScroller.computeScrollOffset();
final int y = mScroller.getCurrY();
int unconsumed = y - mLastScrollerY;
mLastScrollerY = y;
// Nested Scrolling Pre Pass(分發(fā)滑動值給NestedScrollingParent3)
mScrollConsumed[1] = 0;
dispatchNestedPreScroll(0, unconsumed, mScrollConsumed, null,
ViewCompat.TYPE_NON_TOUCH);
//計算剩余的滑動值
unconsumed -= mScrollConsumed[1];
final int range = getScrollRange();
if (unconsumed != 0) {
// Internal Scroll(自身滾動消費滑動值)
final int oldScrollY = getScrollY();
overScrollByCompat(0, unconsumed, getScrollX(), oldScrollY, 0, range, 0, 0, false);
final int scrolledByMe = getScrollY() - oldScrollY;
//計算剩余的滑動值
unconsumed -= scrolledByMe;
// Nested Scrolling Post Pass(分發(fā)滑動值給NestedScrollingParent3)
mScrollConsumed[1] = 0;
dispatchNestedScroll(0, scrolledByMe, 0, unconsumed, mScrollOffset,
ViewCompat.TYPE_NON_TOUCH, mScrollConsumed);
//計算剩余的滑動值
unconsumed -= mScrollConsumed[1];
}
if (unconsumed != 0) {
//EdgeEffect消費剩余滑動值
final int mode = getOverScrollMode();
final boolean canOverscroll = mode == OVER_SCROLL_ALWAYS
|| (mode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0);
if (canOverscroll) {
ensureGlows();
if (unconsumed < 0) {
if (mEdgeGlowTop.isFinished()) {
mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity());
}
} else {
if (mEdgeGlowBottom.isFinished()) {
mEdgeGlowBottom.onAbsorb((int) mScroller.getCurrVelocity());
}
}
}
//停止mScroller滾動動畫并結束滑動嵌套
abortAnimatedScroll();
}
if (!mScroller.isFinished()) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
private void abortAnimatedScroll() {
mScroller.abortAnimation();
stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
}
代碼分析如下:
1.首先調用dispatchNestedPreScroll()將滑動值分發(fā)給NestedScrollingParent3并附帶mScrollConsumed數(shù)組作為輸出參數(shù)記錄其具體消費多少滑動值,變量unconsumed表示剩余的滑動值,在調用dispatchNestedPreScroll()后,unconsumed減去之前的mScrollConsumed數(shù)組的元素重新賦值;
2.此時unconsumed值不為0,說明NestedScrollingParent3沒有消費掉全部滑動值,則自身掉用overScrollByCompat()進行滾動消費滑動值,unconsumed減去記錄本次消費的滑動值scrolledByMe重新賦值;然后調用dispatchNestedScroll()類似于【1】將滑動值分發(fā)給NestedScrollingParent3的操作然后計算unconsumed;
3.若unconsumed值還不為0,說明滑動值沒有完全消費掉,此時實現(xiàn)NestedScrollingParent3、NestedScrollingChild3對應的父View、子View在同一方向都滑動到了邊緣盡頭,此時自身用EdgeEffect消費剩余滑動值并調用abortAnimatedScroll()來 停止mScroller滾動并結束嵌套滑動;
NestedScrolling機制的使用
如果你最低支持android版本是5.0及其以上,你可以使用View、ViewGroup本身對應的NestedScrollingChild、NestedScrollingParent接口;如果你使用AndroidX那么你就需要使用NestedScrollingChild3、NestedScrollingParent3;如果你兼容Android5.0之前版本請使用NestedScrollingChild2、NestedScrollingParent2。下面的例子是偽代碼,因為下面的自定義View沒有實現(xiàn)類似Scroller的方式來消費滑動值,因此它運行也不能實現(xiàn)嵌套滑動進行滑動,只是提供給大家處理觸摸事件調用NestedScrolling機制的思路。
使用NestedScrollingParent2
如果要兼容NestedScrollingParent則覆寫其接口即可,可以借助NestedScrollingParentHelper結合需求作方法代理,你可以根據(jù)具體業(yè)務在onStartNestedScroll()選擇在嵌套滑動的方向、在onNestedPreScroll()要不要消費NestedScrollingChild2的滑動值等等。
使用NestedScrollingChild2
如果要兼容NestedScrollingChild則覆寫其接口即可,可以借助NestedScrollingChildHelper結合需求作方法代理。
public class NSChildView extends FrameLayout implements NestedScrollingChild2 {
private int mLastMotionY;
private final int[] mScrollOffset = new int[2];
private final int[] mScrollConsumed = new int[2];
...
@Override
public boolean onTouchEvent(MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN: {
//關閉外層觸摸事件攔截,確保能拿到MotionEvent.ACTION_MOVE
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
mLastMotionY = (int) ev.getY();
//開始嵌套滑動
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
break;
}
case MotionEvent.ACTION_MOVE:
final int y = (int) ev.getY();
int deltaY = mLastMotionY - y;
//開始滑動之前,分發(fā)滑動值給NestedScrollingParent2
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset,
ViewCompat.TYPE_TOUCH)) {
deltaY -= mScrollConsumed[1];
}
//模擬Scroller消費剩余滑動值
final int oldY = getScrollY();
scrollBy(0,deltaY);
//計算自身消費的滑動值,匯報給NestedScrollingParent2
final int scrolledDeltaY = getScrollY() - oldY;
final int unconsumedY = deltaY - scrolledDeltaY;
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset,
ViewCompat.TYPE_TOUCH)) {
mLastMotionY -= mScrollOffset[1];
}else {
//可以選擇EdgeEffectCompat消費剩余的滑動值
}
break;
case MotionEvent.ACTION_UP:
//可以用VelocityTracker計算velocityY
int velocityY=0;
//根據(jù)需求判斷是否能Fling
boolean canFling=true;
if (!dispatchNestedPreFling(0, velocityY)) {
dispatchNestedFling(0, velocityY, canFling);
//模擬執(zhí)行慣性滑動,如果你希望慣性滑動也能傳遞給NestedScrollingParent2,對于每次消費滑動距離,
// 與MOVE事件中處理滑動一樣,按照dispatchNestedPreScroll() -> 自己消費 -> dispatchNestedScroll() -> 自己消費的順序進行消費滑動值
fling(velocityY);
}
//停止嵌套滑動
stopNestedScroll(ViewCompat.TYPE_TOUCH);
break;
case MotionEvent.ACTION_CANCEL:
//停止嵌套滑動
stopNestedScroll(ViewCompat.TYPE_TOUCH);
break;
}
return true;
}
同時使用NestedScrollingChild2、NestedScrollingParent2
這種情況通常是ViewGroup支持布局嵌套如:
<android.support.v4.widget.NestedScrollView
android:tag="我是爺爺">
<android.support.v4.widget.NestedScrollView
android:tag="我是爸爸">
<android.support.v4.widget.NestedScrollView
android:tag="我是兒子">
</android.support.v4.widget.NestedScrollView >
</android.support.v4.widget.NestedScrollView >
</android.support.v4.widget.NestedScrollView >
舉個例子:當兒子NestedScrollView調用stopNestedScroll()停止嵌套滑動時,就會回調爸爸NestedScrollView的onStopNestedScroll(),這時爸爸NestedScrollView也該停止嵌套滑動并且爺爺NestedScrollView也應該收到爸爸NestedScrollView的停止嵌套滑動,故在NestedScrollingParent2的onStopNestedScroll()應該這么寫達到嵌套滑動事件往外分發(fā)的效果:
//NestedScrollingParent2
@Override
public void onStopNestedScroll(@NonNull View target, int type) {
mParentHelper.onStopNestedScroll(target, type);
//往外分發(fā)
stopNestedScroll(type);
}
//NestedScrollingChild2
@Override
public void stopNestedScroll(int type) {
mChildHelper.stopNestedScroll(type);
}
常見交互效果
除了下面的餓了么商家詳情頁外其他的效果可以用 CoordinatorLayout+AppBarLayout+CollapsingToolbarLayout 實現(xiàn)折疊懸停效果,其實它們底層Behavior也是基于NestedScrolling機制來實現(xiàn)的,而像餓了么這樣的效果如果使用自定View的話要么用NestedScrolling機制來實現(xiàn),要能基于傳統(tǒng)的觸摸事件分發(fā)實現(xiàn)。
- 1.餓了么商家詳情頁(v8.27.6)
- 2.美團商家詳情頁(v10.6.203)
- 3.騰訊課堂首頁(v4.7.1)
- 4.騰訊課堂課程詳情頁(v4.7.1)
- 5.支付寶首頁(v10.1.82)
總結
本文偏向概念性內容,難免有些枯燥,但若遇到稍微有點挑戰(zhàn)要解決的問題,沒有現(xiàn)成的工具可以利用,只能靠自己思考和分析或者借鑒其他現(xiàn)成的工具的原理,就離不開這些看不起眼的“細節(jié)知識”;由于本人水平有限僅給各位提供參考,希望能夠拋磚引玉,如果有什么可以討論的問題可以在評論區(qū)留言或聯(lián)系本人,下篇將帶大家實戰(zhàn)基于NestedScrolling機制自定義View實現(xiàn)餓了么商家詳情頁效果。