歡迎光臨
每天分享高質量文章

大廠的文章詳情頁 WebView與 RecyclerView如何連貫滑動的?

作者:王正一

連結: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的效果,需要自定義兩個巢狀滑動容器:

  1. 自定義一個支援巢狀WebView和RecyclerView滑動的外部容器。

  2. 自定義一個實現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 = (intevent.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 = (intevent.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


已同步到看一看
贊(0)

分享創造快樂