一、引言
- 巢狀滑動處理的難點在於:當子控制元件消費了事件,那麼父控制元件就不會再有機會處理事件了。
- 巢狀滑動的基本原理是在子控制元件接收到滑動一段距離的請求時,先詢問父控制元件是否要滑動,如果滑動了父控制元件就通知子控制元件它消耗了一部分滑動距離,子控制元件就只處理剩下的滑動距離,然後子控制元件滑動完畢後再把剩餘的滑動距離傳給父控制元件。
- 這樣父控制元件和子控制元件就有機會對滑動操作作出響應,尤其父控制元件能夠分別在子控制元件處理滑動距離之前和之後對滑動距離進行響應。
二、相容性問題
- 在
SDK21
之後,巢狀滑動相關的邏輯被寫入了View
和ViewGroup
類。 - 在
android.support.v4
中提供了介面NestedScrollingChild
和NestedScrollingParent
,他們分別定義了View
和ViewParent
中新增的方法,還有兩個相關輔助類NestedScrollingChildHelper
和NestedScrollingParentHelper
。 - 如果版本是
SDK21
之前,那麼就會判斷控制元件是否實現了介面,然後呼叫介面的方法,如果是SDK21
之後,那麼就可以直接呼叫對應的方法。
三、預設處理邏輯
雖然View
和ViewGroup
本身就具有巢狀滑動的相關方法,但是預設情況是不會呼叫,因為View
和ViewGroup
本身不支援滑動,即本身不支援滑動的控制元件即使有巢狀滑動的相關方法也不能進行巢狀滑動。
因此,要讓控制元件支援巢狀滑動,那麼要滿足:
- 控制元件類具有巢狀滑動的相關方法,要麼僅支援
21
之後的版本,要麼實現對應的介面。 - 控制元件要在合適的位置主動調起巢狀滑動方法。
四、相關方法
4.1 NestedScrollingChild
startNestedScroll
:起始方法,主要作用是找到接收滑動距離資訊的外控制元件。dispatchNestedPreScroll
:在內控制元件處理滑動前把滑動資訊分發給外控制元件。dispatchNestedScroll
:在內控制元件處理完滑動後把剩下的距離資訊分發給外控制元件。stopNestedScroll
:結束方法,主要作用是清空巢狀滑動的相關狀態。setNestedScrollingEnabled
和isNestedScrollingEnabled
:用來判斷控制元件是否支援巢狀滑動。dispatchNestedPreFling
和dispatchNestedFling
:和Scroll
的對應方法類似,但是分發的是Fling
資訊。
4.2 NestedScrollingParent
因為內控制元件是發起者,所以外控制元件的大部分方法都是被內控制元件的對應方法所回撥的。
onStartNestedScroll
:對應startNestedScroll
,內控制元件通過呼叫外控制元件的這個方法來確定外控制元件是否接收滑動資訊。onNestedScrollAccepted
:當外控制元件確定接收滑動資訊後該方法被回撥,可以讓外控制元件做一些前期工作。onNestedPreScroll
:關鍵方法,接收內控制元件處理滑動前的距離資訊,在這裡外控制元件可以優先響應滑動操作,消耗部分或者全部滑動距離。onNestedScroll
:關鍵方法,接收內控制元件處理完滑動後的距離資訊,在這裡外控制元件可以選擇是否處理剩餘的滑動資訊。onStopNestedScroll
:對應stopNestedScroll
,用來做一些收尾工作。getNestedScrollAxes
:返回巢狀滑動的方向。onNestedPreFling
和onNestedFling
:同上。
五、NestedScrollView
5.1 收到down
事件,尋找外控制元件
NestedScrollView
實際上是一個FrameLayout
,同時它實現了NestedScrollingParent、NestedScrollingChild、ScrollingView
這三個介面,它既可以用來作為外控制元件,也可以用來作為內控制元件。
我們先從入口函式startNestedScroll
方法看起,它在NestedScrollView
中呼叫的地方有以下三處:
public boolean onInterceptTouchEvent(MotionEvent ev)
public boolean onTouchEvent(MotionEvent ev)
public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes)
而在startNestedScroll
又會呼叫mChildHelper/View
的startNestedScroll
方法,下面我們來看一下它的實現,它遍歷它所有的祖先節點,並呼叫每個節點的onStartNestedScroll(child, this,axes)
方法,如果該方法返回了true
,那麼就將他作為巢狀滑動的外控制元件記錄下來,之後所有和外控制元件的互動都是通過mNestedScrollingParent
來實現的,接下來呼叫它的onNestedScrollAccepted(child, this, axes)
方法,並停止遍歷,返回true
。如果它所有的祖先結點都不滿足巢狀滑動的條件,那麼最終返回false
。
public boolean startNestedScroll(int axes) {
if (hasNestedScrollingParent()) {
// Already in progress
return true;
}
if (isNestedScrollingEnabled()) {
ViewParent p = getParent();
View child = this;
while (p != null) {
try {
if (p.onStartNestedScroll(child, this, axes)) {
mNestedScrollingParent = p;
p.onNestedScrollAccepted(child, this, axes);
return true;
}
} catch (AbstractMethodError e) {
Log.e(VIEW_LOG_TAG, "ViewParent " + p + " does not implement interface " +
"method onStartNestedScroll", e);
// Allow the search upward to continue
}
if (p instanceof View) {
child = (View) p;
}
p = p.getParent();
}
}
return false;
}
複製程式碼
接下來,我們看一下mParentHelper/ViewGroup
的public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes)
,它在ViewGroup
預設值是返回false
:
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
return false;
}
複製程式碼
而在NestedScrollView
中的條件是:
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
複製程式碼
在接著呼叫的onNestedScrollAccepted
中,ViewGroup
記錄下axes
的值:
@Override
public void onNestedScrollAccepted(View child, View target, int axes) {
mNestedScrollAxes = axes;
}
複製程式碼
而NestedScrollView
則會繼續呼叫startNestedScroll
來尋找它的外控制元件:
@Override
public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes) {
mParentHelper.onNestedScrollAccepted(child, target, nestedScrollAxes);
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
}
複製程式碼
總結:第一個階段主要是為了尋找到巢狀滑動的外控制元件,並確定滑動的方向。
5.2 收到move
事件,交給外控制元件處理一部分的滑動距離
之後的滑動就需要通過public boolean onTouchEvent(MotionEvent ev)
中的ACTION_MOVE
來處理了,我們來看一下NestedScrollView
的處理邏輯:
case MotionEvent.ACTION_MOVE:
final int activePointerIndex = MotionEventCompat.findPointerIndex(ev,
mActivePointerId);
if (activePointerIndex == -1) {
Log.e(TAG, "Invalid pointerId=" + mActivePointerId + " in onTouchEvent");
break;
}
//1.獲得當前的y座標
final int y = (int) MotionEventCompat.getY(ev, activePointerIndex);
//2.記錄該次滑動的距離
int deltaY = mLastMotionY - y;
//3.如果有外控制元件,那麼交給它先處理滑動事件,這裡傳入了3個引數:
if (dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) {
deltaY -= mScrollConsumed[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
}
if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
mIsBeingDragged = true;
if (deltaY > 0) {
deltaY -= mTouchSlop;
} else {
deltaY += mTouchSlop;
}
}
//.....
複製程式碼
在View
的dispatchNestedPreScroll
,它通過先前儲存下來的外控制元件變數,把當前滑動的距離傳給它來處理,在ViewGroup
中這個函式什麼事情也沒有做,如果我們要實現自己的巢狀滑動邏輯,那麼就要在這裡面進行處理:
public boolean dispatchNestedPreScroll(int dx, int dy,
@Nullable @Size(2) int[] consumed, @Nullable @Size(2) int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
if (dx != 0 || dy != 0) {
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
if (consumed == null) {
if (mTempNestedScrollConsumed == null) {
mTempNestedScrollConsumed = new int[2];
}
consumed = mTempNestedScrollConsumed;
}
consumed[0] = 0;
consumed[1] = 0;
//呼叫父控制元件的介面,詢問它是否要消耗滑動事件.
mNestedScrollingParent.onNestedPreScroll(this, dx, dy, consumed);
if (offsetInWindow != null) {
getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
return consumed[0] != 0 || consumed[1] != 0;
} else if (offsetInWindow != null) {
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
複製程式碼
這個階段的過程,可以理解為:
- 得到當前
y
座標的值 - 根據上次
y
座標的值計算出這次滑動的距離deltaY
- 把這個
deltaY
值交給外控制元件處理 - 外控制元件返回兩個陣列,
mScrollConsumed
表示該階段外控制元件消耗的距離,mScrollOffset
表示本次交給外控制元件之後,內控制元件視窗變動的座標值,如果消耗的x
或y
值不為0,那麼該函式返回true
。 deltaY - mScrollConsumed[1]
得到內控制元件接下來要處理的距離。
5.3 外控制元件處理完滑動距離後,交給內控制元件滾動
if (mIsBeingDragged) {
// Scroll to follow the motion event
mLastMotionY = y - mScrollOffset[1];
final int oldY = getScrollY();
final int range = getScrollRange();
final int overscrollMode = ViewCompat.getOverScrollMode(this);
boolean canOverscroll = overscrollMode == ViewCompat.OVER_SCROLL_ALWAYS ||
(overscrollMode == ViewCompat.OVER_SCROLL_IF_CONTENT_SCROLLS &&
range > 0);
// Calling overScrollByCompat will call onOverScrolled, which
// calls onScrollChanged if applicable.
if (overScrollByCompat(0, deltaY, 0, getScrollY(), 0, range, 0,
0, true) && !hasNestedScrollingParent()) {
// Break our velocity if we hit a scroll barrier.
mVelocityTracker.clear();
}
//.....
}
複製程式碼
5.4 內控制元件滾動完畢後,交給外控制元件繼續處理
final int scrolledDeltaY = getScrollY() - oldY;
final int unconsumedY = deltaY - scrolledDeltaY;
if (dispatchNestedScroll(0, scrolledDeltaY, 0, unconsumedY, mScrollOffset)) {
mLastMotionY -= mScrollOffset[1];
vtev.offsetLocation(0, mScrollOffset[1]);
mNestedYOffset += mScrollOffset[1];
} else if (canOverscroll) {
//..
}
複製程式碼
這裡呼叫了mChildHelper/View
的dispatchNestedScroll
方法,它裡面會通過mNestedScrollingParent
來通知外控制元件來處理剩餘的距離,在ViewGroup
的onNestedScroll
方法中,什麼也沒有做:
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
int dxUnconsumed, int dyUnconsumed, @Nullable @Size(2) int[] offsetInWindow) {
if (isNestedScrollingEnabled() && mNestedScrollingParent != null) {
if (dxConsumed != 0 || dyConsumed != 0 || dxUnconsumed != 0 || dyUnconsumed != 0) {
int startX = 0;
int startY = 0;
if (offsetInWindow != null) {
getLocationInWindow(offsetInWindow);
startX = offsetInWindow[0];
startY = offsetInWindow[1];
}
mNestedScrollingParent.onNestedScroll(this, dxConsumed, dyConsumed,
dxUnconsumed, dyUnconsumed);
if (offsetInWindow != null) {
getLocationInWindow(offsetInWindow);
offsetInWindow[0] -= startX;
offsetInWindow[1] -= startY;
}
return true;
} else if (offsetInWindow != null) {
// No motion, no dispatch. Keep offsetInWindow up to date.
offsetInWindow[0] = 0;
offsetInWindow[1] = 0;
}
}
return false;
}
複製程式碼
5.5 收到up
事件,停止巢狀滑動
通過呼叫stopNestedScroll
方法來停止滑動:
public boolean onInterceptTouchEvent(MotionEvent ev)
的ACTION_UP
public boolean onTouchEvent(MotionEvent ev)
的ACTION_UP
和ACTION_CANCEL
在View
的stopNestedScroll
方法中,呼叫外控制元件的onStopNestedScroll
方法來通知它整個滑動結束:
public void stopNestedScroll() {
if (mNestedScrollingParent != null) {
mNestedScrollingParent.onStopNestedScroll(this);
mNestedScrollingParent = null;
}
}
複製程式碼
六、運用NestedScrollView
下面,我們再通過一個簡單的例子,來看一下使用NestedScrollView
的效果,佈局檔案:
<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 標題部分 -->
<android.support.design.widget.AppBarLayout
android:id="@+id/appbar"
android:layout_height="wrap_content"
android:layout_width="match_parent">
<android.support.v7.widget.Toolbar
android:id="@+id/toolbar"
app:layout_scrollFlags="scroll|enterAlways"
android:background="@android:color/holo_blue_dark"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize">
</android.support.v7.widget.Toolbar>
</android.support.design.widget.AppBarLayout>
<!-- 內容部分 -->
<android.support.v4.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:text="1"
android:layout_width="match_parent"
android:layout_height="200dp"/>
<TextView
android:text="2"
android:layout_width="match_parent"
android:layout_height="200dp"/>
<TextView
android:text="3"
android:layout_width="match_parent"
android:layout_height="200dp"/>
<TextView
android:text="4"
android:layout_width="match_parent"
android:layout_height="200dp"/>
</LinearLayout>
</android.support.v4.widget.NestedScrollView>
</android.support.design.widget.CoordinatorLayout>
複製程式碼
我們通過CoordinatorLayout
把標題部分和內容部分包裹起來,這樣再滑動下面的NestedScrollView
時,可以實現標題欄的隱藏和顯示。