本文已在公衆號鴻洋原創發佈。未經許可,不得以任何形式轉載!java
NestedScrolling是Android5.0推出的嵌套滑動機制,可以讓父View和子View在滑動時相互協調配合能夠實現連貫的嵌套滑動,它基於原有的觸摸事件分發機制上爲ViewGroup和View增長處理滑動的方法提供調用,後來爲了向前兼容到Android1.6,在Revision 22.1.0的android.support.v4兼容包中提供了從View、ViewGroup抽取出NestedScrollingChild、NestedScrollingParent兩個接口和NestedScrollingChildHelper、NestedScrollingParentHelper兩個輔助類來幫助控件實現嵌套滑動,CoordinatorLayout即是基於這個機制實現各類神奇的滑動效果。android
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;
}
}
複製代碼
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);
//根據自身是否滑動到頂部或者頂部來判斷讓父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;
}
}
複製代碼
上面經過兩種經典的解決方案,在內部View能夠滑動時,外部View不攔截,當內部View滑動到底部或者頂部時,讓外部消費滑動事件進行滑動。通常而言,外部攔截法和內部攔截法不能公用。 不然內部容器可能並無機會調用 requestDisallowInterceptTouchEvent方法。在傳統的觸摸事件分發中,若是不手動調用分發事件或者去發出事件,外部View最早拿到觸摸事件,一旦它被外部View攔截消費了,內部View沒法接收到觸摸事件,同理,內部View消費了觸摸事件,外部View也沒有機會響應觸摸事件。 而接下介紹的NestedScrolling機制,在一次滑動事件中外部View和內部View都有機會對滑動進行響應,這樣處理滑動衝突就相對方便許多。數組
NestedScrollingChild(下圖簡稱nc)、NestedScrollingParent(下圖簡稱np)邏輯上分別對應以前內部View和外部View的角色,之因此稱之爲邏輯上是由於View能夠同時扮演NestedScrollingChild和NestedScrollingParent,下面圖片就是NestedScrolling的交互流程。 app
接下來詳細說明一下上圖的交互流程:ide
1.當NestedScrollingChild接收到觸摸事件MotionEvent.ACTION_DOWN時,它會往外層佈局遍歷尋找最近的NestedScrollingParent請求配合處理滑動。因此它們之間層級不必定是直接上下級關係。函數
2.若是NestedScrollingParent不配合NestedScrollingChild處理滑動就沒有接下來的流程,不然就會配合處理滑動。工具
3.NestedScrollingChild要滑動以前,它先拿到MotionEvent.ACTION_MOVE滑動的dx,dy並將一個有兩個元素的數組(分別表明NestedScrollingParent要滑動的水平和垂直方向的距離)做爲輸出參數一同傳給NestedScrollingParent。佈局
4.NestedScrollingParent拿到上面【3】NestedScrollingChild傳來的數據,將要消費的水平和垂直方向的距離傳進數組,這樣NestedScrollingChild就知道NestedScrollingParent要消費滑動值是多少了。post
5.NestedScrollingChild將【2】裏拿到的dx、dy減去【4】NestedScrollingParent消費滑動值,計算出剩餘的滑動值;若是剩餘的滑動值爲0說明NestedScrollingParent所有消費了NestedScrollingChild不該進行滑動;不然NestedScrollingChild根據剩餘的滑動值進行消費,而後將本身消費了多少、還剩餘多少彙報傳遞給NestedScrollingParent。動畫
6.若是NestedScrollingChild在滑動期間發生的慣性滑動,它會將velocityX,velocityY傳給NestedScrollingParent,並詢問NestedScrollingParent是否要所有消費。
7.NestedScrollingParent收到【6】NestedScrollingChild傳來的數據,告訴NestedScrollingChild是否所有消費慣性滑動。
8.若是在【7】NestedScrollingParent沒有所有消費慣性滑動,NestedScrollingChild會將velocityX,velocityY、自身是否須要消費所有慣性滑動傳給NestedScrollingParent,並詢問NestedScrollingParent是否要所有消費。
9.NestedScrollingParent收到【8】NestedScrollingChild傳來的數據,告訴NestedScrollingChild是否所有消費慣性滑動。
10.NestedScrollingChild中止滑動時通知NestedScrollingParent。
PS:
- A.上面的【消費】是指可滑動View調用自身的滑動方法進行滑動來消耗滑動數值,好比scrollBy()、scrollTo()、fling()、offsetLeftAndRight()、offsetTopAndBottom()、layout()、Scroller、LayoutParams等,View實現NestedScrollingParent、NestedScrollingChild只僅僅是能將數值進行傳遞,須要配合Touch事件根據需求去調用NestScrolling的接口和輔助類,而自己不支持滑動的View即便有嵌套滑動的相關方法也不能進行嵌套滑動。
- B.在【1】中外層實現NestedScrollingParent的View不應攔截NestedScrollingChild的MotionEvent.ACTION_DOWN;在【2】中若是NestedScrollingParent配合處理滑動時,實現NestedScrollingChild的View應該經過getParent().requestDisallowInterceptTouchEvent(true)往上遞歸關閉外層View的事件攔截機制,這樣確保【3】中NestedScrollingChild先拿到MotionEvent.ACTION_MOVE。具體能夠參考RecyclerView和NestedScrollView源碼的觸摸事件處理。
前面提到Android 5.0及以上的View、ViewGroup自身分別就有NestedScrollingChild和NestedScrollingParent的方法,而方法邏輯就是對應的NestedScrollingChildHelper和NestedScrollingParentHelper的具體方法實現,因此本小節不講解View、ViewGroup的NestedScrolling機制相關內容,請自行查看源碼。
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();
/** * 滑動完成後,將已經消費、剩餘的滑動值分發給NestedScrollingParent * @param dxConsumed 水平方向消費的距離 * @param dyConsumed 垂直方向消費的距離 * @param dxUnconsumed 水平方向剩餘的距離 * @param dyUnconsumed 垂直方向剩餘的距離 * @param offsetInWindow 含有View今後方法調用以前到調用完成後的屏幕座標偏移量, * 可使用這個偏移量來調整預期的輸入座標(即上面4個消費、剩餘的距離)跟蹤,此參數可空。 * @return 返回該事件是否被成功分發 */
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
/** * 在滑動以前,將滑動值分發給NestedScrollingParent * @param dx 水平方向消費的距離 * @param dy 垂直方向消費的距離 * @param consumed 輸出座標數組,consumed[0]爲NestedScrollingParent消耗的水平距離、 * consumed[1]爲NestedScrollingParent消耗的垂直距離,此參數可空。 * @param offsetInWindow 同上dispatchNestedScroll * @return 返回NestedScrollingParent是否消費部分或所有滑動值 */
boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow);
/** * 將慣性滑動的速度和NestedScrollingChild自身是否須要消費此慣性滑動分發給NestedScrollingParent * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @param consumed NestedScrollingChild自身是否須要消費此慣性滑動 * @return 返回NestedScrollingParent是否消費所有慣性滑動 */
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
/** * 在慣性滑動以前,將慣性滑動值分發給NestedScrollingParent * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @return 返回NestedScrollingParent是否消費所有慣性滑動 */
boolean dispatchNestedPreFling(float velocityX, float velocityY);
}
複製代碼
public interface NestedScrollingParent {
/** * 對NestedScrollingChild發起嵌套滑動做出應答 * @param child 佈局中包含下面target的直接父View * @param target 發起嵌套滑動的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滑動完成後將滑動值分發給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滑動完以前將滑動值分發給NestedScrollingParent回調此方法 * @param target 同上 * @param dx 水平方向的距離 * @param dy 水平方向的距離 * @param consumed 返回NestedScrollingParent是否消費部分或所有滑動值 */
void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed);
/** * NestedScrollingChild在慣性滑動以前,將慣性滑動的速度和NestedScrollingChild自身是否須要消費此慣性滑動分 * 發給NestedScrollingParent回調此方法 * @param target 同上 * @param velocityX 水平方向的速度 * @param velocityY 垂直方向的速度 * @param consumed NestedScrollingChild自身是否須要消費此慣性滑動 * @return 返回NestedScrollingParent是否消費所有慣性滑動 */
boolean onNestedFling(@NonNull View target, float velocityX, float velocityY, boolean consumed);
/** * NestedScrollingChild在慣性滑動以前,將慣性滑動的速度分發給NestedScrollingParent * @param target 同上 * @param velocityX 同上 * @param velocityY 同上 * @return 返回NestedScrollingParent是否消費所有慣性滑動 */
boolean onNestedPreFling(@NonNull View target, float velocityX, float velocityY);
/** * @return 返回當前嵌套滑動的方向 */
int getNestedScrollAxes();
}
複製代碼
NestedScrollingChildHepler對NestedScrollingChild的接口方法作了代理,您能夠結合實際狀況藉助它來實現,如:
public class MyScrollView extends View implements NestedScrollingChild{
...
@Override
public boolean startNestedScroll(int axes) {
return mChildHelper.startNestedScroll(axes);
}
}
複製代碼
這裏只分析關鍵的方法,具體代碼請參考源碼。
public boolean startNestedScroll(int axes) {
//判斷是否找到配合處理滑動的NestedScrollingParent
if (hasNestedScrollingParent()) {
// Already in progress
return true;
}
if (isNestedScrollingEnabled()) {//判斷是否開啓滑動嵌套
ViewParent p = mView.getParent();
View child = mView;
//循環往上層尋找配合處理滑動的NestedScrollingParent
while (p != null) {
//ViewParentCompat.onStartNestedScroll()會判斷p是否實現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;
}
複製代碼
這個方法首先會判斷是否已經找到了配合處理滑動的NestedScrollingParent、若找到了則返回true,不然會判斷是否開啓嵌套滑動,若開啓了則經過構造函數注入的View來循環往上層尋找配合處理滑動的NestedScrollingParent,循環條件是經過ViewParentCompat這個兼容類判斷p是否實現NestedScrollingParent,如果則將p轉爲NestedScrollingParent類型調用onStartNestedScroll()方法若是返回true則證實找配合處理滑動的NestedScrollingParent,因此接下來一樣藉助ViewParentCompat調用NestedScrollingParent的onNestedScrollAccepted()。
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];
}
//初始化輸出數組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數組
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
//consumed數組的兩個元素的值有其中一個不爲0則說明NestedScrollingParent消耗的部分或者所有滑動值
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
複製代碼
這個方法首先會判斷是否開啓嵌套滑動並找到配合處理滑動的NestedScrollingParent,若符合這兩個條件則會根據參數dx、dy滑動值判斷是否有水平或垂直方向滑動,如有滑動調用mView.getLocationInWindow()將View當前的在Window上的x、y座標值賦值進offsetInWindow數組並以startX、startY記錄,接下來初始化輸出數組consumed、並經過ViewParentCompat調用NestedScrollingParent的onNestedPreScroll(),再次調用mView.getLocationInWindow()將調用NestedScrollingParent的onNestedPreScroll()後的View在Window上的x、y座標值賦值進offsetInWindow數組並與以前記錄好的startX、startY相減計算得出偏移量,接着以consumed數組的兩個元素的值有其中一個不爲0做爲boolean值返回,若條件爲true說明NestedScrollingParent消耗的部分或者所有滑動值。
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數組
mView.getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
//返回true代表NestedScrollingChild的dispatchNestedScroll事件成功分發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()方法十分相似,這裏就不細說了。
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是否消費所有慣性滑動。
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機制的 系統控件 嵌套滑動,當內部View快速滑動產生慣性滑動到邊緣就中止,而不將慣性滑動傳遞給外部View繼續消費慣性滑動,就會出現下圖兩個NestedScrollView嵌套滑動這種 慣性滑動不連續 的狀況:
這裏以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)) {
//分發慣性滑動
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)) {//將慣性滑動分發給NestedScrollingParent,讓它先對慣性滑動進行處理
dispatchNestedFling(0, velocityY, canFling);//若慣性滑動沒被消費,再次將慣性滑動分發給NestedScrollingParent,並帶上自身是否能消費fling的canFling參數讓NestedScrollingParent根據狀況處理決定canFling是true仍是false
if (canFling) {
//執行fling()消費慣性滑動
fling(velocityY);
}
}
}
public void fling(int velocityY) {
if (getChildCount() > 0) {
int height = getHeight() - getPaddingBottom() - getPaddingTop();
int bottom = getChildAt(0).getHeight();
//初始化fling的參數
mScroller.fling(getScrollX(), getScrollY(), 0, velocityY, 0, 0, 0,
Math.max(0, bottom - height), 0, height/2);
//重繪會觸發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());
}
}
}
}
}
複製代碼
上面代碼執行以下:
1.當快速滑動並擡起手指時onTouchEvent()方法會命中MotionEvent.ACTION_UP,執行關鍵flingWithNestedDispatch()方法將垂直方向的慣性滑動值分發。
2.flingWithNestedDispatch()方法先調用dispatchNestedPreFling()將慣性滑動分發給NestedScrollingParent,若NestedScrollingParent沒有消費則調用dispatchNestedFling()並帶上自身是否能消費fling的canFling參數讓NestedScrollingParent能夠根據狀況處理決定canFling是true仍是false,若canFling值爲true,執行fling()方法。
3.fling()方法執行mScroller.fling()初始化fling參數,而後 調用ViewCompat.postInvalidateOnAnimation()重繪觸發computeScroll()方法進行滾動。
4.computeScroll()方法裏面只讓自身進行fling,並無在自身fling到邊緣時將慣性滑動分發給NestedScrollingParent。
在Revision 26.1.0的android.support.v4兼容包添加了NestedScrollingChild二、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類型參數type,這個參數有兩個值:TYPE_TOUCH值爲0表示滑動由用戶手勢滑動屏幕觸發;TYPE_NON_TOUCH值爲1表示滑動不是由用戶手勢滑動屏幕觸發;同時View、ViewGroup、NestedScrollingChildHelper、NestedScrollingParentHelper一樣根據參數type作了調整。
前面說到由於系統控件在computeScroll()方法裏面只讓自身進行fling,並無在自身fling到邊緣時將慣性滑動分發給NestedScrollingParent致使慣性滑動不連貫,因此這裏以com.android.support:appcompat-v7:26.1.0的NestedScrollView源碼看看如何使用改進後的NestedScrolling機制:
public void fling(int velocityY) {
if (getChildCount() > 0) {
//發起滑動嵌套,注意ViewCompat.TYPE_NON_TOUCH參數表示不是由用戶手勢滑動屏幕觸發
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(將滑動值分發給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)) {//若滾動值沒有分發成功給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方法發起滑動嵌套。
2.computeScroll()方法首先調用dispatchNestedPreScroll()將滑動值分發給NestedScrollingParent2,若滑動值沒有被NestedScrollingParent2所有消費掉,則自身進行消費滾動,而後再調用dispatchNestedScroll()將自身消費、剩餘的滑動值分發給NestedScrollingParent2,若分發失敗則用EdgeEffect(這個用來滑動到頂部或者底部時會出現一個波浪形的邊緣效果)消費掉,當mScroller滾動完成後調用stopNestedScroll()方法結束嵌套滑動。
在使用以前NestedScrolling機制的 系統控件 嵌套滑動,當子、父View都在頂部時,首先快速下滑子View並擡起手指製造慣性滑動,而後立刻滑動父View,這時就會出現上圖的兩個NestedScrollView嵌套滑動現象,你手指往上滑視圖內容往下滾一段距離,視圖內容馬上就會自動往上回滾。
這裏仍是以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();
}
}
...
}
...
}
複製代碼
代碼執行以下:
1.這裏分析場景是兩個NestedScrollView嵌套滑動,因此dispatchNestedPreFling()返回值爲false,子View執行就會fling()方法,前面分析過fling()方法調用mScroller.fling()觸發computeScroll()進行實際的滾動。
2.在子View調用computeScroll()方法期間,若是此時子View不命中MotionEvent.ACTION_DOWN,mScroller是不會中止滾動,只能等待它完成,因而就子View就不停調用dispatchNestedPreScroll()和dispatchNestedScroll()分發滑動值給父View,就出現了上圖的場景。
在androidx.core 1.1.0-alpha01開始引入NestedScrollingChild三、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數組consumed參數做爲輸出參數記錄NestedScrollingParent3消費的滑動值,同理,NestedScrollingParent3繼承NestedScrollingParent2重載onNestedScroll添加了一個int數組consumed參數來對應NestedScrollingChild3,NestedScrollingChildHepler、NestedScrollingParentHelper一樣根據變化作了適配調整。
下面是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(分發滑動值給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(分發滑動值給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()將滑動值分發給NestedScrollingParent3並附帶mScrollConsumed數組做爲輸出參數記錄其具體消費多少滑動值,變量unconsumed表示剩餘的滑動值,在調用dispatchNestedPreScroll()後,unconsumed減去以前的mScrollConsumed數組的元素從新賦值;
2.此時unconsumed值不爲0,說明NestedScrollingParent3沒有消費掉所有滑動值,則自身掉用overScrollByCompat()進行滾動消費滑動值,unconsumed減去記錄本次消費的滑動值scrolledByMe從新賦值;而後調用dispatchNestedScroll()相似於【1】將滑動值分發給NestedScrollingParent3的操做而後計算unconsumed;
3.若unconsumed值還不爲0,說明滑動值沒有徹底消費掉,此時實現NestedScrollingParent三、NestedScrollingChild3對應的父View、子View在同一方向都滑動到了邊緣盡頭,此時自身用EdgeEffect消費剩餘滑動值並調用abortAnimatedScroll()來 中止mScroller滾動並結束嵌套滑動;
若是你最低支持android版本是5.0及其以上,你可使用View、ViewGroup自己對應的NestedScrollingChild、NestedScrollingParent接口;若是你使用AndroidX那麼你就須要使用NestedScrollingChild三、NestedScrollingParent3;若是你兼容Android5.0以前版本請使用NestedScrollingChild二、NestedScrollingParent2。下面的例子是僞代碼,由於下面的自定義View沒有實現相似Scroller的方式來消費滑動值,所以它運行也不能實現嵌套滑動進行滑動,只是提供給你們處理觸摸事件調用NestedScrolling機制的思路。
若是要兼容NestedScrollingParent則覆寫其接口便可,能夠藉助NestedScrollingParentHelper結合需求做方法代理,你能夠根據具體業務在onStartNestedScroll()選擇在嵌套滑動的方向、在onNestedPreScroll()要不要消費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;
//開始滑動以前,分發滑動值給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;
//根據需求判斷是否能Fling
boolean canFling=true;
if (!dispatchNestedPreFling(0, velocityY)) {
dispatchNestedFling(0, velocityY, canFling);
//模擬執行慣性滑動,若是你但願慣性滑動也能傳遞給NestedScrollingParent2,對於每次消費滑動距離,
// 與MOVE事件中處理滑動同樣,按照dispatchNestedPreScroll() -> 本身消費 -> dispatchNestedScroll() -> 本身消費的順序進行消費滑動值
fling(velocityY);
}
//中止嵌套滑動
stopNestedScroll(ViewCompat.TYPE_TOUCH);
break;
case MotionEvent.ACTION_CANCEL:
//中止嵌套滑動
stopNestedScroll(ViewCompat.TYPE_TOUCH);
break;
}
return true;
}
複製代碼
這種狀況一般是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()應該這麼寫達到嵌套滑動事件往外分發的效果:
//NestedScrollingParent2
@Override
public void onStopNestedScroll(@NonNull View target, int type) {
mParentHelper.onStopNestedScroll(target, type);
//往外分發
stopNestedScroll(type);
}
//NestedScrollingChild2
@Override
public void stopNestedScroll(int type) {
mChildHelper.stopNestedScroll(type);
}
複製代碼
除了下面的餓了麼商家詳情頁外其餘的效果能夠用 CoordinatorLayout+AppBarLayout+CollapsingToolbarLayout 實現摺疊懸停效果,其實它們底層Behavior也是基於NestedScrolling機制來實現的,而像餓了麼這樣的效果若是使用自定View的話要麼用NestedScrolling機制來實現,要能基於傳統的觸摸事件分發實現。
本文偏向概念性內容,不免有些枯燥,但若遇到稍微有點挑戰要解決的問題,沒有現成的工具能夠利用,只能靠本身思考和分析或者借鑑其餘現成的工具的原理,就離不開這些看不起眼的「細節知識」;因爲本人水平有限僅給各位提供參考,但願可以拋磚引玉,若是有什麼能夠討論的問題能夠在評論區留言或聯繫本人,下篇將帶你們實戰基於NestedScrolling機制自定義View實現餓了麼商家詳情頁效果。