UI系列一Android多子view嵌套通用解決方案

原創 zhanghao 百度App技術git

1.多子view嵌套應用背景

百度App在17年的版本中實現2個子view嵌套滾動,用於Feed落地頁(webview呈現文章詳情 + recycle呈現Native評論)。原理是在外層提供一個UI容器(咱們稱之爲」聯動容器」)處理WebView和Recyclerview連貫嵌套滾動。github

當時的聯動容器對子view限制比較大,僅支持WebView和Recyclerview進行聯動滾動,數量也只支持2個子View。web

隨着組件化進程的推動,爲方便各業務解耦,對聯動容器提出了更高的要求,須要支持任意類型、任意數量的子view進行聯動滾動,也就是本文要闡述的多子view嵌套滾動通用解決方案。express

先直觀感覺下聯動容器嵌套滾動的Demo效果:數組

2. 多子view嵌套實現原理

同大多數自定義控件相似,聯動容器也須要處理子view的測量、佈局以及手勢處理。測量和佈局對聯動容器的場景來講很是簡單,手勢處理相對複雜些。bash

從demo效果能夠看出,聯動容器須要處理好和子view嵌套滑動問題。嵌套滑動的處理方案有兩種app

  • 基於Google的NestedScrolling機制實現嵌套滑動;
  • 是由聯動容器內部處理和子view嵌套滑動的邏輯。

百度App早期版本的聯動容器採用的方案2實現的,下圖爲方案2聯動容器手勢處理流程:ide

筆者對方案2聯動容器的實現代碼作了開源,感興趣的同窗能夠參考: github.com/baiduapp-te… 基於google的NestedScrolling實現多子view嵌套能節省很多開發量,故筆者對多子view嵌套的實現採用方案一。

3. 核心邏輯

3.1 Google嵌套滑動機制

Google在Android 5.0推出了一套NestedScrolling機制,這套機制滾動打破了對以前Android傳統的事件處理的認知,是按照逆向事件傳遞機制來處理嵌套滾動,事件傳遞可參考下圖:工具

網上有不少關於NestedScrolling的文章,若是沒接觸過NestedScrolling的同窗可參考下張鴻洋的這篇文章: blog.csdn.net/lmj62356579…

3.2 接口設計

爲了保證聯動容器中子view的任意性,聯動容器需提供完善的接口抽象供子view去實現。下圖爲聯動容器暴露的接口類圖:組件化

ILinkageScroll是置於聯動容器中的子view必需要實現的接口,聯動容器在初始化時若是發現某個子view沒實現該接口,會拋出異常。ILinkageScroll中又會涉及兩個接口:LinkageScrollHandler、ChildLinkageEvent。

LinkageScrollHandler接口中的方法聯動容器會在須要時主動調用,以通知子view完成一些功能,好比:獲取子view是否可滾動,獲取子view滾動條相關數據等。

ChildLinkageEvent接口定義了子view的一些事件信息,好比子view的內容滾動到頂部或底部。當發生這些事件後,子view主動調用對應方法,這樣聯動容器收到子view一些事件後會作出相應的反應,保證正常的聯動效果。

上面僅簡單說明了下接口功能,想更加深刻了解的同窗請參考:github.com/baiduapp-te…

接下來咱們詳細分析下聯動容器對手勢處理細節,根據手勢類型,將嵌套滑動分爲兩種狀況來分析:1. scroll手勢;2. fling手勢;

3.3 scroll手勢

先給出scroll手勢處理的核心代碼:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {
    @Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
        boolean moveUp = dy > 0;
        boolean moveDown = !moveUp;
        int scrollY = getScrollY();
        int topEdge = target.getTop();
        LinkageScrollHandler targetScrollHandler
                = ((ILinkageScroll)target).provideScrollHandler();
        if (scrollY == topEdge) {    // 聯動容器scrollY與當前子view的top座標重合            
            if ((moveDown && !targetScrollHandler.canScrollVertically(-1))
                    || (moveUp && !targetScrollHandler.canScrollVertically(1))) {
                // 在對應的滑動方向上,若是子view不能垂直滑動,則由聯動容器消費滾動距離
                scrollBy(0, dy);
                consumed[1] = dy;
            } 
        } else if (scrollY > topEdge) {    // 聯動容器scrollY大於當前子view的top座標,也就是說,子view頭部已經滑出聯動容器
            if (moveUp) {
                // 若是手指上滑,則由聯動容器消費滾動距離
                scrollBy(0, dy);
                consumed[1] = dy;
            }
            if (moveDown) {
                // 若是手指下滑,聯動容器會先消費部分距離,此時聯動容器的scrollY會不斷減少,
                // 直到等於子view的top座標後,剩餘的滑動距離則由子view繼續消費。
                int end = scrollY + dy;
                int deltaY;
                deltaY = end > topEdge ? dy : (topEdge - scrollY);
                scrollBy(0, deltaY);
                consumed[1] = deltaY;
            }
        } else if (scrollY < topEdge) {    // 聯動容器scrollY小於當前子view的top座標,也就是說,子view尚未徹底露出
            if (moveDown) {
                // 若是手指下滑,則由聯動容器消費滾動距離
                scrollBy(0, dy);
                consumed[1] = dy;
            }
            if (moveUp) {
                // 若是手指上滑,聯動容器會先消費部分距離,此時聯動容器的scrollY會不斷增大,
                // 直到等於子view的top座標後,剩餘的滑動距離則由子view繼續消費。
                int end = scrollY + dy;
                int deltaY;
                deltaY = end < topEdge ? dy : (topEdge - scrollY);
                scrollBy(0, deltaY);
                consumed[1] = deltaY;
            }
        }
    }
    @Override
    public void scrollBy(int x, int y) {
        // 邊界檢查
        int scrollY = getScrollY();
        int deltaY;
        if (y < 0) {
            deltaY = (scrollY + y) < 0 ? (-scrollY) : y;
        } else {
            deltaY = (scrollY + y) > mScrollRange ?
                    (mScrollRange - scrollY) : y;
        }
        if (deltaY != 0) {
            super.scrollBy(x, deltaY);
        }
    }
}
複製代碼

onNestedPreScroll()回調是google嵌套滑動機制NestedScrollingParent接口中的方法。當子view滾動時,會先經過此方法詢問父view是否消費這段滾動距離,父view根據自身狀況決定是否消費以及消費多少,並將消費的距離放入數組consumed中,子view再根據數組中的內容決定本身的滾動距離。

代碼註釋比較詳細,這裏總體再作個解釋:經過對子view的上邊沿閾值和聯動容器的scrollY進行比較,處理了3種case下的滾動狀況。

第10行,當scrollY == topEdge時,只要子view沒有滾動到頂或者底,都由子view正常消費滾動距離,不然由聯動容器消費滾動距離,並將消費的距離經過consumed變量通知子view,子view會根據consumed變量中的內容決定本身的滑動距離。

第17行,當scrollY > topEdge時,也就是說當觸摸的子view頭部已經滑出聯動容器,此時若是手指向上滑動,滑動距離所有由聯動容器消費,若是手指向下滑動,聯動容器會先消費部分距離,當聯動容器的scrollY達到topEdge後,剩餘的滑動距離由子view繼續消費。

第32行,當scrollY < topEdge這個和上一個第17行判斷相似,這裏不作過多解釋。scroll手勢處理流程圖以下:

3.4 fling手勢

聯動容器對fling手勢的處理大體思路以下:若是聯動容器的scrollY等於子view的top座標,則由子view自身處理fling手勢,不然由聯動容器處理fling手勢。

並且在一次完整的fling週期中,聯動容器和各子view將會交替去完成滑動行爲,直到速度降爲0,聯動容器須要處理好交替滑動時的速度銜接,保證整個fling的流暢行。接下來看下詳細實現:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {
    @Override
    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
        int scrollY = getScrollY();
        int targetTop = target.getTop();
        mFlingOrientation = velocityY > 0 ? FLING_ORIENTATION_UP : FLING_ORIENTATION_DOWN;
        if (scrollY == targetTop) {    // 當聯動容器的scrollY等於子view的top座標,則由子view自身處理fling手勢
            // 跟蹤velocity,當target滾動到頂或底,保證parent繼續fling
            trackVelocity(velocityY);
            return false;
        } else {    // 由聯動容器消費fling手勢
            parentFling(velocityY);
            return true;
        }
    }
}
複製代碼

onNestedPreFling()回調是google嵌套滑動機制NestedScrollingParent接口中的方法。當子view發生fling行爲時,會先經過此方法詢問父view是否要消費此次fling手勢,若是返回true,表示父view要消費此次fling手勢,反之不消費。

第6行根據velocityY正負值記錄本次的fling的方向;

第7行,當聯動容器scrollY值等於觸摸子view的top值,fling手勢由子view處理,同時聯動容器對本次fling手勢的速度進行追蹤,目的是當子view內容滾到頂或者底時,可以得到剩餘速度以讓聯動容器繼續fling;

第12行,由聯動容器消費本次fling手勢。下面看下聯動容器和子view交替fling的細節:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {
    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            int y = mScroller.getCurrY();
            y = y < 0 ? 0 : y;
            y = y > mScrollRange ? mScrollRange : y;
            // 獲取聯動容器下個滾動邊界值,若是達到邊界值,速度會傳給下個子view,讓子view繼續快速滑動
            int edge = getNextEdge();
            // 邊界檢查
            if (mFlingOrientation == FLING_ORIENTATION_UP) {
                y = y > edge ? edge : y;
            }
            // 邊界檢查
            if (mFlingOrientation == FLING_ORIENTATION_DOWN) {
                y = y < edge ? edge : y;
            }
            // 聯動容器滾動子view
            scrollTo(x, y);
            int scrollY = getScrollY();
            // 聯動容器最新的scrollY是否達到了邊界值
            if (scrollY == edge) {
                // 獲取剩餘的速度
                int velocity = (int) mScroller.getCurrVelocity();
                if (mFlingOrientation == FLING_ORIENTATION_UP) {
                    velocity = velocity > 0? velocity : - velocity;
                }
                if (mFlingOrientation == FLING_ORIENTATION_DOWN) {
                    velocity = velocity < 0? velocity : - velocity;
                }    
                // 獲取top爲edge的子view
                View target = getTargetByEdge(edge);
                // 子view根據剩餘的速度繼續fling
                ((ILinkageScroll) target).provideScrollHandler()
                        .flingContent(target, velocity);
                trackVelocity(velocity);
            }
            invalidate();
        }
    }
    /**
     * 根據fling的方向獲取下一個滾動邊界,
     * 內部會判斷下一個子View是否isScrollable,
     * 若是爲false,會順延取下一個target的edge。
     */
    private int getNextEdge() {
        int scrollY = getScrollY();
        if (mFlingOrientation == FLING_ORIENTATION_UP) {
            for (View target : mLinkageChildren) {
                LinkageScrollHandler handler
                        = ((ILinkageScroll)target).provideScrollHandler();
                int topEdge = target.getTop();
                if (topEdge > scrollY
                        && isTargetScrollable(target)
                        && handler.canScrollVertically(1)) {
                    return topEdge;
                }
            }
        } else if (mFlingOrientation == FLING_ORIENTATION_DOWN) {
            for (View target : mLinkageChildren) {
                LinkageScrollHandler handler
                        = ((ILinkageScroll)target).provideScrollHandler();
                int bottomEdge = target.getBottom();
                if (bottomEdge >= scrollY
                        && isTargetScrollable(target)
                        && handler.canScrollVertically(-1)) {
                    return target.getTop();
                }
            }
        }
        return mFlingOrientation == FLING_ORIENTATION_UP ? mScrollRange : 0;
    }
    /** 
     * child view的滾動事件 
     */
    private ChildLinkageEvent mChildLinkageEvent = new ChildLinkageEvent() {
        @Override
        public void onContentScrollToTop(View target) {
            // 子view內容滾動到頂部回調
            if (mVelocityScroller.computeScrollOffset()) {
                // 從速度追蹤器中獲取剩餘速度
                float currVelocity = mVelocityScroller.getCurrVelocity();
                currVelocity = currVelocity < 0 ? currVelocity : - currVelocity;
                mVelocityScroller.abortAnimation();
                // 聯動容器根據剩餘速度繼續fling
                parentFling(currVelocity);
            }
        }
        @Override
        public void onContentScrollToBottom(View target) {
            // 子view內容滾動到底部回調
            if (mVelocityScroller.computeScrollOffset()) {
                // 從速度追蹤器中獲取剩餘速度
                float currVelocity = mVelocityScroller.getCurrVelocity();
                currVelocity = currVelocity > 0 ? currVelocity : - currVelocity;
                mVelocityScroller.abortAnimation();
                // 聯動容器根據剩餘速度繼續fling
                parentFling(currVelocity);
            }
        }
    };
}
複製代碼

fling的速度傳遞分爲:

  1. 從聯動容器向子view傳遞;2. 從子view向聯動容器傳遞。

先看速度從聯動容器向子view傳遞。核心代碼在computeScroll()回調方法中。第9行,獲取聯動容器下一個滾動邊界值,若是達到下一個滾動邊界值,聯動容器須要將剩餘速度傳給下個子view,讓其繼續滾動。

第46行,getNextEdge()方法內部總體邏輯:遍歷全部子view,將聯動容器當前的scrollY與子view的top/bottom進行比較來獲取下一個滑動邊界。

第34行,當聯動容器檢測到滑動到下個邊界時,則調用ILinkageScroll.flingContent()讓子view根據剩餘速度繼續滾動。

再看速度從子view向聯動容器傳遞,核心代碼在第76行。當子view內容滾動到頂或者底,會回調onContentScrollToTop()方法或者onContentScrollToBottom()方法,聯動容器收到回調後,在第86行和第98行,繼續執行後續滾動。fling手勢處理流程圖以下:

4. 滾動條

4.1 Android系統的ScrollBar

對於內容可滾動的頁面,ScrollBar則是一個不可或缺的UI組件,因此,ScrollBar也是聯動容器必需要實現的功能。

好在Android系統對滾動條的抽象很是友好,自定義控件只須要重寫View中的幾個方法,Android系統就能幫助你正確繪製出滾動條。咱們先看下View中的相關方法:

/**
 * <p>Compute the vertical offset of the vertical scrollbar's thumb within the horizontal range. This value is used to compute the position * of the thumb within the scrollbar's track.</p>
 *
 * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollRange()} and
 * {@link #computeVerticalScrollExtent()}.</p>
 *
 * @return the vertical offset of the scrollbar's thumb */ protected int computeVerticalScrollOffset() { return mScrollY; } /** * <p>Compute the vertical extent of the vertical scrollbar's thumb within the vertical range. This value is used to compute the length
 * of the thumb within the scrollbar's track.</p> * * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollRange()} and * {@link #computeVerticalScrollOffset()}.</p> * * @return the vertical extent of the scrollbar's thumb
 */
protected int computeVerticalScrollExtent() {
    return getHeight();
}
/**
 * <p>Compute the vertical range that the vertical scrollbar represents.</p>
 *
 * <p>The range is expressed in arbitrary units that must be the same as the units used by {@link #computeVerticalScrollExtent()} and
 * {@link #computeVerticalScrollOffset()}.</p>
 *
 * @return the total vertical range represented by the vertical scrollbar
 */
protected int computeVerticalScrollRange() {
    return getHeight();
}
複製代碼

對於垂直Scrollbar,咱們只須要重寫computeVerticalScrollOffset(),computeVerticalScrollExtent(),computeVerticalScrollRange()這三個方法便可。Android對這三個方法註釋已經很是詳細了,這裏再簡單解釋下:

computeVerticalScrollOffset()表示當前頁面內容滾動的偏移值,這個值是用來控制Scrollbar的位置。缺省值爲當前頁面Y方向上的滾動值。

computeVerticalScrollExtent()表示滾動條的範圍,也就是滾動條在垂直方向上所能觸及的最大界限,這個值也會被系統用來計算滾動條的長度。缺省值是View的實際高度。

computeVerticalScrollRange()表示整個頁面內容可滾動的數值範圍,缺省值爲View的實際高度。

須要注意的是:offset,extent,range三個值在單位上必須保持一致。

4.2 聯動容器實現ScrollBar

聯動容器是由系統中可滾動的子view組成的,這些子view(ListView、RecyclerView、WebView)確定都實現了ScrollBar功能,那麼聯動容器實現ScrollBar就很是簡單了,聯動容器只需拿到全部子view的offset,extent,range值,而後再根據聯動容器的滑動邏輯把全部子view的這些值轉換成聯動容器對應的offset,extent,range便可。接口設計以下:

public interface LinkageScrollHandler {
    // ...省略無關代碼
    /**
     * get scrollbar extent value
     *
     * @return extent
     */
    int getVerticalScrollExtent();
    /**
     * get scrollbar offset value
     *
     * @return extent
     */
    int getVerticalScrollOffset();
    /**
     * get scrollbar range value
     *
     * @return extent
     */
    int getVerticalScrollRange();
}
複製代碼

LinkageScrollHandler接口在3.2小節解釋過,這裏不在贅述。這裏面三個方法由子view去實現,聯動容器會經過這三個方法獲取子view與滾動條相關的值。下面看下聯動容器中關於ScrollBar的詳細邏輯:

public class ELinkageScrollLayout extends ViewGroup implements NestedScrollingParent {
    /** 構造方法 */
    public ELinkageScrollLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        // ...省略了無關代碼
        // 確保聯動容器調用onDraw()方法
        setWillNotDraw(false);
        // enable vertical scrollbar
        setVerticalScrollBarEnabled(true);
    }
    /** child view的滾動事件 */
    private ChildLinkageEvent mChildLinkageEvent = new ChildLinkageEvent() {
        // ...省略了無關代碼
        @Override
        public void onContentScroll(View target) {
            // 收到子view滾動事件,顯示滾動條
            awakenScrollBars();
        }
    }
    @Override
    protected int computeVerticalScrollExtent() {
        // 使用缺省的extent值
        return super.computeVerticalScrollExtent();
    }
    @Override
    protected int computeVerticalScrollRange() {
        int range = 0;
        // 遍歷全部子view,獲取子view的Range
        for (View child : mLinkageChildren) {
            ILinkageScroll linkageScroll = (ILinkageScroll) child;
            int childRange = linkageScroll.provideScrollHandler().getVerticalScrollRange();
            range += childRange;
        }
        return range;
    }
    @Override
    protected int computeVerticalScrollOffset() {
        int offset = 0;
        // 遍歷全部子view,獲取子view的offset
        for (View child : mLinkageChildren) {
            ILinkageScroll linkageScroll = (ILinkageScroll) child;
            int childOffset = linkageScroll.provideScrollHandler().getVerticalScrollOffset();
            offset += childOffset;
        }
        // 加上聯動容器自身在Y方向上的滾動偏移
        offset += getScrollY();
        return offset;
    }
}
複製代碼

以上就是聯動容器實現ScrollBar的核心代碼,註釋也很是詳細,這裏再重點強調幾點:

系統爲了提升效率,ViewGroup默認不調用onDraw()方法,這樣就不會走ScrollBar的繪製邏輯。因此在第6行,須要調用setWillNotDraw(false)打開ViewGroup繪製流程;

第16行,收到子view的滾動回調,調用awakenScrollBars()觸發滾動條的繪製;

對於extent,直接使用缺省的extent,即聯動容器的高度;

對於range,對全部子view的range進行求和,最後獲得值即爲聯動容器的range;

對於offset,一樣先對全部子view的offset進行求和,以後還須要加上聯動容器自身的scrollY值,最終獲得的值即爲聯動容器的offset。

你們能夠返回到文章開頭,再看下Demo中滾動條的效果,相比於市面上其它使用相似聯動技術的App,本文對滾動條的實現很是接近原生了。

5. 注意事項

聯動容器執行fling操做時,藉助OverScroller工具類完成的。代碼以下:

private void parentFling(float velocityY) {
    // ... 省略了無關代碼
    mScroller.fling(0, getScrollY(),
                0, (int) velocityY,
                0, 0,
                Integer.MIN_VALUE, Integer.MAX_VALUE);
    invalidate();
}
複製代碼

藉助OverScroller.fling()方法完成聯動容器的fling行爲,這段代碼在小米手機上運行聯動會出現問題,mScroller.getCurrVelocity()一直是0。

緣由是小米手機Rom重寫了OverScroller,當fling()方法第三個參數傳0時,OverScroller.mCurrVelocity一直爲NaN,致使沒法計算出正確剩餘速度。

爲了解決小米手機的問題,咱們須要將第三個參數傳個非0值,這裏給1便可。

private void parentFling(float velocityY) {
    // ... 省略了無關代碼
    mScroller.fling(0, getScrollY(),
                1, (int) velocityY,
                0, 0,
                Integer.MIN_VALUE, Integer.MAX_VALUE);
    invalidate();
}
複製代碼

6. 總結

多子view嵌套實現原理並不複雜,對手勢處理的邊界條件比較瑣碎,須要來回調試完善,歡迎業內的朋友一塊兒交流學習。

Sample地址: github.com/baiduapp-te…

相關文章
相關標籤/搜索