作者:王正一
連結:https://segmentfault.com/a/1190000019272870
很多大廠 App 新聞類客戶端文章詳情頁都內容區域是 Webview,下麵評論區域是 RecylcerView 但是可以連貫在一起滾動,是如何做到的呢? 相信這篇文章會給你一定的啟發。
1、從一個簡單的DEMO看什麼是巢狀滾動
我們先來看一下DEMO的效果,直觀的感受一下什麼是巢狀滾動:
在解釋上圖涉及到哪些巢狀滑動操作之前,我先貼一下巢狀佈局的xml結構:
其中:
1、NestedWebViewRecyclerViewGroup為最外層滑動容器;
2、com.wzy.nesteddetail.view.NestedScrollWebView為佈局頂部可巢狀滑動的View;
3、TextView為佈局中部不可滑動的View;
4、android.support.v7.widget.RecyclerView為佈局底部可滑動的View;
現在我們來說明一下,簡單的DEMO效果中包含了哪些巢狀滑動操作:
1、向上滑動頂部WebView時,首先滑動WebView的內容,WebView的內容滑動到底後再滑動外層容器。外層容器滑動到RecyclerView完全露出後,再將滑動距離或者剩餘速度傳遞給RecyclerView繼續滑動.
2、滑動底部RecyclerView時,首先滑動RecyclerView的內容,RecyclerView的內容滑動到頂後再滑動外層容器。外層容器也滑動到頂後,再將滑動距離或者剩餘速度傳遞給WebView繼續滑動.
3、觸控本身不可滑動的TextView時,滑動事件被外層容器攔截。外層容器根據滑動方向和是否滑動到相應閾值,再將相應的滑動距離或者速度傳遞給WebView或者RecyclerView.
再不知道NestedScrolling機制之前,我相信大部分人想實現上面的滑動效果都是比較頭大的,特別是滑動距離和速度要從WebView->外層容器->RecyclerView並且還要支援反向傳遞。
有了Google提供的牛逼巢狀滑動機制,再加上這篇文章粗淺的科普,我相信大部分人都能夠實現這種滑動效果。這種效果最常見的應用場景就是各種新聞客戶端的詳情頁。
2、NestedScrolling介面簡介
Android在support.v4包中提供了用於View支援巢狀滑動的兩個介面:
-
NestedScrollingParent
-
NestedScrollingChild
我先用比較白話的語言介紹一下NestedScrolling的工作原理:
1、Google從邏輯上區分了滑動的兩個角色:NestedScrollingParent簡稱ns parent,NestedScrollingChild簡稱ns child。對應了滑動佈局中的外層滑動容器和內部滑動容器。
2、ns child在收到DOWN事件時,找到離自己最近的ns parent,與它進行系結並關閉它的事件攔截機制。
3、ns child會在接下來的MOVE事件中判定出使用者觸發了滑動手勢,並把事件攔截下來給自己消費。
4. 消費MOVE事件流時,對於每一個MOVE事件增加的滑動距離:
-
4.1. ns child並不是直接自己消費,而是先將它交給ns parent,讓ns parent可以在ns child滑動前進行消費。
-
4.2. 如果ns parent沒有消費或者滑動沒消費完,ns child再消費剩下的滑動。
-
4.3. 如果ns child消費後滑動還是有剩餘,會把剩下的滑動距離再交給ns parent消費。
-
4.4. 最後如果ns parent消費滑動後還有剩餘,ns child可以做最終處理。
5、ns child在收到UP事件時,可以計算出需要滾動的速度,ns child對於速度的消費流程是:
-
5.1 ns child在進行flying操作前,先詢問ns parent是否需要消費該速度。如果ns parent消費該速度,後續就由ns parent帶飛,自己就不消費該速度了。如果ns parent不消費,則ns child進行自己的flying操作。
-
5.2 ns child在flying過程中,如果已經滾動到閾值速度仍沒有消費完,會再次將速度分發給ns parent,將ns parent進行消費。
NestedScrollingParent和NestedScrollingChild的原始碼定義也是為了配合滑動實現定義出來的:
NestedScrollingChild
// 設定是否開啟巢狀滑動
void setNestedScrollingEnabled(boolean enabled);
// 獲得設定開啟了巢狀滑動
boolean isNestedScrollingEnabled();
// 沿給定的軸線開始巢狀滾動
boolean startNestedScroll(@ScrollAxis int axes);
// 停止當前巢狀滾動
void stopNestedScroll();
// 如果有ns parent,傳回true
boolean hasNestedScrollingParent();
// 消費滑動時間前,先讓ns parent消費
boolean dispatchNestedPreScroll(int dx , int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow);
// ns parent消費ns child剩餘滾動後是否還有剩餘。return true代表還有剩餘
boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow);
// 消費fly速度前,先讓ns parent消費
boolean dispatchNestedPreFling(float velocityX, float velocityY);
// ns parent消費ns child消費後的速度之後是否還有剩餘。return true代表還有剩餘
boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed);
NestedScrollingParent
// 決定是否接收子View的滾動事件
boolean onStartNestedScroll();
// 響應子View的滾動
void onNestedScrollAccepted();
// 滾動結束的回呼
void onStopNestedScroll();
// ns child滾動前回呼
void onNestedPreScroll();
// ns child滾動後回呼
void onNestedScroll();
// ns child flying前回呼
boolean onNestedPreFling();
// ns child flying後回呼
boolean onNestedFling();
// 傳回當前佈局巢狀滾動的坐標軸
int getNestedScrollAxes();
Google為了讓開發者更加方便的實現這兩個介面,提供了NestedScrollingParentHelper和NestedScrollingChildHelper這兩個輔助。所以實現NestedScrolling這兩個介面的常用寫法是:
ns child:
public class NestedScrollingWebView extends WebView implements NestedScrollingChild {
private NestedScrollingChildHelper mChildHelper;
private NestedScrollingChildHelper getNestedScrollingHelper() {
if (mChildHelper == null) {
mChildHelper = new NestedScrollingChildHelper(this);
}
return mChildHelper;
}
@Override
public void setNestedScrollingEnabled(boolean enabled) {
getNestedScrollingHelper().setNestedScrollingEnabled(enabled);
}
@Override
public boolean isNestedScrollingEnabled() {
return getNestedScrollingHelper().isNestedScrollingEnabled();
}
@Override
public boolean startNestedScroll(int axes) {
return getNestedScrollingHelper().startNestedScroll(axes);
}
@Override
public void stopNestedScroll() {
getNestedScrollingHelper().stopNestedScroll();
}
@Override
public boolean hasNestedScrollingParent() {
return getNestedScrollingHelper().hasNestedScrollingParent();
}
@Override
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow) {
return getNestedScrollingHelper().dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
}
@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow) {
return getNestedScrollingHelper().dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow);
}
@Override
public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
return getNestedScrollingHelper().dispatchNestedFling(velocityX, velocityY, consumed);
}
@Override
public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
return getNestedScrollingHelper().dispatchNestedPreFling(velocityX, velocityY);
}
@Override
public boolean startNestedScroll(int axes, int type) {
return getNestedScrollingHelper().startNestedScroll(axes, type);
}
@Override
public void stopNestedScroll(int type) {
getNestedScrollingHelper().stopNestedScroll(type);
}
@Override
public boolean hasNestedScrollingParent(int type) {
return getNestedScrollingHelper().hasNestedScrollingParent(type);
}
@Override
public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
return getNestedScrollingHelper().dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow, type);
}
@Override
public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, int type) {
return getNestedScrollingHelper().dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
}
}
ns parent:
public class NestedScrollingDetailContainer extends ViewGroup implements NestedScrollingParent {
private NestedScrollingParentHelper mParentHelper;
private NestedScrollingParentHelper getNestedScrollingHelper() {
if (mParentHelper == null) {
mParentHelper = new NestedScrollingParentHelper(this);
}
return mParentHelper;
}
@Override
public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes) {
return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
}
@Override
public int getNestedScrollAxes() {
return getNestedScrollingHelper().getNestedScrollAxes();
}
@Override
public void onNestedScrollAccepted(View child, View target, int axes) {
getNestedScrollingHelper().onNestedScrollAccepted(child, target, axes);
}
@Override
public void onStopNestedScroll(View child) {
getNestedScrollingHelper().onStopNestedScroll(child);
}
@Override
public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
// 處理預先flying事件
return false;
}
@Override
public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) {
// 處理後續flying事件
return false;
}
@Override
public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) {
// 處理後續scroll事件
}
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @Nullable int[] consumed) {
// 處理預先滑動scroll事件
}
}
3、效果實現
只知道原理大家肯定是不滿足的,結合原理進行實操才是關鍵。這裡以DEMO的效果為例,想要實現DEMO的效果,需要自定義兩個巢狀滑動容器:
-
自定義一個支援巢狀WebView和RecyclerView滑動的外部容器。
-
自定義一個實現NestedScrollingChild介面的WebView。
外部滑動容器
在實現外部滑動的容器的時候,我們首先需要考慮這個外部滑動容器的滑動閾值是什麼?
答: 外部滑動的滑動閾值=外部容器中所有子View的高度-外部容器的高度。
同理類似WebView的滑動閾值=WebView的內容高度-WebView的容器高度。
對應程式碼實現:
private int mInnerScrollHeight; // 可滑動的最大距離
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width;
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int measureWidth = MeasureSpec.getSize(widthMeasureSpec);
int measureHeight = MeasureSpec.getSize(heightMeasureSpec);
if (widthMode == MeasureSpec.EXACTLY) {
width = measureWidth;
} else {
width = mScreenWidth;
}
int left = getPaddingLeft();
int right = getPaddingRight();
int top = getPaddingTop();
int bottom = getPaddingBottom();
int count = getChildCount();
for (int i = 0; i View child = getChildAt(i);
LayoutParams params = child.getLayoutParams();
int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, left + right, params.width);
int childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec, top + bottom, params.height);
measureChild(child, childWidthMeasureSpec, childHeightMeasureSpec);
}
setMeasuredDimension(width, measureHeight);
findWebView(this);
findRecyclerView(this);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childTotalHeight = 0;
mInnerScrollHeight = 0;
for (int i = 0; i View child = getChildAt(i);
int childWidth = child.getMeasuredWidth();
int childHeight = child.getMeasuredHeight();
child.layout(0, childTotalHeight, childWidth, childHeight + childTotalHeight);
childTotalHeight += childHeight;
mInnerScrollHeight += childHeight;
}
mInnerScrollHeight -= getMeasuredHeight();
}
其次,需要考慮當WebView傳遞上滑事件和RecyclerView傳遞下滑事件時如何處理:
1、向上滑動時,如果WebView內容還沒有到底,該事件交給WebView處理;如果WebView內容已經滑動到底,但是滑動距離沒有超過外部容器的最大滑動距離,該事件由滑動容器自身處理;如果WebView內容已經滑動到底,並且滑動距離超過了外部容器的最大滑動距離,這時將滑動事件傳遞給底部的 RecyclerView,讓RecyclerView處理;
2、向下滑動時,如果RecyclerView沒有到頂部,該事件交給RecyclerView處理;如果RecyclerView已經到頂部,並且外部容器的滑動距離不為0,該事件由外部容器處理;如果RecyclerView已經到頂部,並且外部容器的滑動距離已經為0,則該事件交給WebView處理;
對應的WebView傳遞上滑速度和RecyclerView傳遞下滑速度,處理和Scroll傳遞類似。
對應程式碼實現:
@Override
public void onNestedPreScroll(@NonNull View target, int dx, int dy, @Nullable int[] consumed, int type) {
boolean isWebViewBottom = !canWebViewScrollDown();
boolean isCenter = isParentCenter();
if (dy > 0 && isWebViewBottom && getScrollY() //為了WebView滑動到底部,繼續向下滑動父控制元件
scrollBy(0, dy);
if (consumed != null) {
consumed[1] = dy;
}
} else if (dy 0 && isCenter) {
//為了RecyclerView滑動到頂部時,繼續向上滑動父控制元件
scrollBy(0, dy);
if (consumed != null) {
consumed[1] = dy;
}
}
if (isCenter && !isWebViewBottom) {
//異常情況的處理
scrollToWebViewBottom();
}
}
@Override
public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
if (target instanceof NestedScrollingWebView) {
//WebView滑到底部時,繼續向下滑動父控制元件和RV
mCurFlyingType = FLYING_FROM_WEBVIEW_TO_PARENT;
parentFling(velocityY);
} else if (target instanceof RecyclerView && velocityY 0 && getScrollY() >= getInnerScrollHeight()) {
//RV滑動到頂部時,繼續向上滑動父控制元件和WebView,這裡用於計算到達父控制元件的頂部時RV的速度
mCurFlyingType = FLYING_FROM_RVLIST_TO_PARENT;
parentFling((int) velocityY);
} else if (target instanceof RecyclerView && velocityY > 0) {
mIsRvFlyingDown = true;
}
return false;
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
int currY = mScroller.getCurrY();
switch (mCurFlyingType) {
case FLYING_FROM_WEBVIEW_TO_PARENT://WebView向父控制元件滑動
if (mIsRvFlyingDown) {
//RecyclerView的區域的fling由自己完成
break;
}
scrollTo(0, currY);
invalidate();
checkRvTop();
if (getScrollY() == getInnerScrollHeight() && !mIsSetFlying) {
//滾動到父控制元件底部,滾動事件交給RecyclerView
mIsSetFlying = true;
recyclerViewFling((int) mScroller.getCurrVelocity());
}
break;
case FLYING_FROM_PARENT_TO_WEBVIEW://父控制元件向WebView滑動
scrollTo(0, currY);
invalidate();
if (currY <= 0 && !mIsSetFlying) {
//滾動父控制元件頂部,滾動事件交給WebView
mIsSetFlying = true;
webViewFling((int) -mScroller.getCurrVelocity());
}
break;
case FLYING_FROM_RVLIST_TO_PARENT://RecyclerView向父控制元件滑動,fling事件,單純用於計算速度。RecyclerView的flying事件傳遞最終會轉換成Scroll事件處理.
if (getScrollY() != 0) {
invalidate();
} else if (!mIsSetFlying) {
mIsSetFlying = true;
//滑動到頂部時,滾動事件交給WebView
webViewFling((int) -mScroller.getCurrVelocity());
}
break;
}
}
}
最後,我們需要考慮,如果使用者觸控的是內部的一個不可滑動View時,這時事件是沒法透過NestedScrolling機制傳遞給自身的。所以需要主動攔截這種事件,攔截標準:
1、MOVE超過的TOUCH SLOP距離。
2、當前觸控的不是支援NestedScrolling機制的View。
相應程式碼如下:
private int mLastY;
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mLastY = (int) event.getY();
break;
case MotionEvent.ACTION_MOVE:
if (mLastY == 0) {
mLastY = (int) event.getY();
return true;
}
int y = (int) event.getY();
int dy = y - mLastY;
mLastY = y;
scrollBy(0, -dy);
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
mLastY = 0;
break;
}
return true;
}
private int mLastMotionY;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final int action = ev.getAction();
switch (action & MotionEvent.ACTION_MASK) {
case MotionEvent.ACTION_MOVE:
// 攔截落在不可滑動子View的MOVE事件
final int y = (int) ev.getY();
final int yDiff = Math.abs(y - mLastMotionY);
boolean isInNestedChildViewArea = isTouchNestedInnerView((int)ev.getRawX(), (int)ev.getRawY());
if (yDiff > TOUCH_SLOP && !isInNestedChildViewArea) {
mIsBeingDragged = true;
mLastMotionY = y;
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(true);
}
}
break;
case MotionEvent.ACTION_DOWN:
mLastMotionY = (int) ev.getY();
mIsBeingDragged = false;
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mIsBeingDragged = false;
break;
}
return mIsBeingDragged;
}
private boolean isTouchNestedInnerView(int x, int y) {
List innerView = new ArrayList<>();
if (mChildWebView != null) {
innerView.add(mChildWebView);
}
if (mChildRecyclerView != null) {
innerView.add(mChildRecyclerView);
}
for (View nestedView : innerView) {
if (nestedView.getVisibility() != View.VISIBLE) {
continue;
}
int[] location = new int[2];
nestedView.getLocationOnScreen(location);
int left = location[0];
int top = location[1];
int right = left + nestedView.getMeasuredWidth();
int bottom = top + nestedView.getMeasuredHeight();
if (y >= top && y <= bottom && x >= left && x <= right) {
return true;
}
}
return false;
}
實現一個支援巢狀滑動的WebView
本身WebView是不支援巢狀滑動的,想要支援巢狀滑動,我們需要讓WebView實現NestedScrollingChild介面,並且處理好TouchEvent方法中的事件傳遞。
實現NestedScrollingChild介面比較簡單,上面也介紹過了,可以使用Google提供的NestedScrollingChildHelper輔助類。
處理TouchEvent的思路,需要遵循以下步驟:
1、DOWN事件時通知父佈局,自己要開始巢狀滑動了。
2、對於MOVE事件,先交給父佈局消費。父佈局判斷WebView不能向下滑動了,就父佈局消費;還能向下滑動,就給WebView消費。
3、對於Flying事件,同樣先諮詢父佈局是否消費。父佈局判斷WebView不能向下滑動了,就父佈局消費;還能向下滑動,就給WebView消費。
WebView最大滑動距離=WebView自身內容的高度-WebView容器的高度
思路比較簡單,我們看一下對應的核心程式碼:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
mWebViewContentHeight = 0;
mLastY = (int) event.getRawY();
mFirstY = mLastY;
if (!mScroller.isFinished()) {
mScroller.abortAnimation();
}
initOrResetVelocityTracker();
mIsSelfFling = false;
mHasFling = false;
mMaxScrollY = getWebViewContentHeight() - getHeight();
startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL);
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(true);
}
break;
case MotionEvent.ACTION_MOVE:
initVelocityTrackerIfNotExists();
mVelocityTracker.addMovement(event);
int y = (int) event.getRawY();
int dy = y - mLastY;
mLastY = y;
if (getParent() != null) {
getParent().requestDisallowInterceptTouchEvent(true);
}
if (!dispatchNestedPreScroll(0, -dy, mScrollConsumed, null)) {
scrollBy(0, -dy);
}
if (Math.abs(mFirstY - y) > TOUCHSLOP) {
//遮蔽WebView本身的滑動,滑動事件自己處理
event.setAction(MotionEvent.ACTION_CANCEL);
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (isParentResetScroll() && mVelocityTracker != null) {
mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
int yVelocity = (int) -mVelocityTracker.getYVelocity();
recycleVelocityTracker();
mIsSelfFling = true;
flingScroll(0, yVelocity);
}
break;
}
super.onTouchEvent(event);
return true;
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
final int currY = mScroller.getCurrY();
if (!mIsSelfFling) {
// parent flying
scrollTo(0, currY);
invalidate();
return;
}
if (isWebViewCanScroll()) {
scrollTo(0, currY);
invalidate();
}
if (!mHasFling
&& mScroller.getStartY() && !canScrollDown()
&& startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL)
&& !dispatchNestedPreFling(0, mScroller.getCurrVelocity())) {
//滑動到底部時,將fling傳遞給父控制元件和RecyclerView
mHasFling = true;
dispatchNestedFling(0, mScroller.getCurrVelocity(), false);
}
}
}
總結
NestedScrolling機制看似複雜,但其實就是實現兩個介面的事情,而且Google提供了強大的輔助類Helper來幫助我們實現介面。
這種機制將滑動事件的傳遞封裝起來,透過Helper輔助類實現ns parent和ns child之間的連線和互動。透過介面回呼,也實現了ns parent和ns child的解耦。
DEMO專案連結:
https://github.com/wangzhengyi/Android-NestedDetail
朋友會在“發現-看一看”看到你“在看”的內容