又來一個庫,WebView,RecyclerView 多佈局連貫滑動

不知道大家看源碼了沒有,其主要是利用嵌套滾動,而且代碼相對難理解一些。

最近看到作者的這篇文章,作者拋棄了嵌套滾動,通過自定義 ViewGroup 的方式實現,相對來說,代碼好理解太多了,我也實際體驗了一下,體驗效果還可以。

仔細看了下源碼,作者的思路還是很值得學習的,一開始我以為是默認讓內部空間全部展開都失去滾動效果,交給外層,實際看了代碼之後發現並沒有拋棄 RecyclerView 這些複用,他在其外層 scrollTo的時候,會考慮交給內層控件滾動,感興趣可以學習一波。


概述


ConsecutiveScrollerLayout是我在GitHub開源的一個Android自定義滑動佈局,它可以讓多個滑動佈局和普通控件在界面上像一個整體一樣連續順暢地滑動。

試想我們有這樣一個需求,在一個界面上有輪播圖、像九宮格一樣的分類佈局、幾個樣式不一樣的列表,中間還夾雜著各種廣告圖和展示各類活動的佈局,這樣的設計在大型的app首頁上非常常見。

又比如像諮詢類的文章詳情頁或者電商類的商品詳情頁這種一個WebView加上原生的評論列表、推薦列表和廣告位。這種複雜的佈局實現起來往往比較困難,而且對於頁面的滑動流暢性和佈局的顯示效率要求較高。

在以前我遇到這種複雜的佈局,會使用我在Github開源的項目GroupedRecyclerViewAdapter 實現。

https://github.com/donkingliang/GroupedRecyclerViewAdapter

當初設計GroupedRecyclerViewAdapter,是為了能讓RecyclerView方便地實現二級列表、分組列表和在一個RecyclerView上顯示不同的列表。

由於GroupedRecyclerViewAdapter支持設置不同item類型的頭部、尾部和子項,所有它能在一個RecyclerView上顯示多種不同的佈局和列表,也符合實現複雜佈局的需求。

但是由於GroupedRecyclerViewAdapter並非為這種複雜佈局設計的,用它來實現這種佈局,需要使用者在GroupedRecyclerViewAdapter的子類上管理好頁面的數據和各種類型佈局的顯示邏輯,顯得臃腫又麻煩。

如果不把它整合在一個RecyclerView上,而是使用佈局的嵌套實現,不僅嚴重影響佈局的性能,而且解決滑動衝突也是個令人頭疼的問題。儘管Google為了更好地解決滑動佈局間的滑動衝突問題,在Android 5.0的時候推出了NestedScrolling機制,不過要自己來處理各種滑動問題,依然不是一件容易的事情。

無論多麼複雜的頁面,它都是由一個個小控件組成的。如果能有一個佈局容器幫我們處理好佈局內所有的子View的滑動問題,使得無論是普通控件還是滑動佈局,在這個容器裡都能像一個整體一樣滑動,滑動它就好像是滑動一個普通的ScrollView一樣。那麼我們是否就可以不用再關心佈局的滑動衝突和滑動性能問題。


無論多麼複雜的佈局,我們都只需要考慮佈局的各個小部分該用什麼控件就用什麼控件,任何複雜的佈局都將不再複雜。


ConsecutiveScrollerLayout正是基於這樣的需求而設計的。


設計思路


在構思ConsecutiveScrollerLayout時,我是考慮使用NestedScrolling機制實現的,但是後來我放棄了這種方案,主要原因有二:


1、NestedScrolling機制主要是協調父佈局和子佈局的滑動衝突,分發滑動事件,至於佈局的滑動是由它們自己各自完成的。


這不符合我希望把ConsecutiveScrollerLayout的所有子View當作一個滑動整體的構思,我希望把子View的內容視作是ConsecutiveScrollerLayout內容的一部分,無論是ConsecutiveScrollerLayout自身還是它的子View,都由ConsecutiveScrollerLayout來統一處理滑動事件。


2、NestedScrolling機制要求父佈局實現NestedScrollingParent接口,所有可滑動的子View實現NestedScrollingChild接口。


而我希望ConsecutiveScrollerLayout在使用上儘可能的沒有限制,任何View放進它都可以很好的工作,而且子View無需關心它是怎麼滑動的。


否決了NestedScrolling機制後,我嘗試從View的內容滑動的相關方法來尋找突破點。我發現Android幾乎所有的View都是通過scrollBy() -> scrollTo()方法滑動View的內容,而且大部分的滑動佈局也是直接或者間接調用這個方法來實現滑動的。


所以這兩個方法是處理佈局滑動的入口,通過重寫這兩個方法可以重新定義佈局的滑動邏輯。


具體的思路是通過攔截可滑動的子view的滑動事件,使它無法自己滑動,而把事件統一交由ConsecutiveScrollerLayout處理,ConsecutiveScrollerLayout重寫scrollBy()、scrollTo()方法,在scrollTo()方法中通過計算分發滑動的偏移量,決定是由自身還是具體的子View消費滑動的距離,調用自身的super.scrollTo()和子View的scrollBy()來滑動自身和子View的內容。


說了這麼多,下面讓我們通過代碼,分析一下ConsecutiveScrollerLayout是如何實現的。下面給出的代碼是源碼的一些主要片段,刪除了一些與設計思路和主要流程無關的處理細節,便於大家更好的理解它的設計和實現原理。


效果圖


在開始前,先讓大家看一下ConsecutiveScrollerLayout實現的效果。


又來一個庫,WebView,RecyclerView 多佈局連貫滑動


又來一個庫,WebView,RecyclerView 多佈局連貫滑動


onMeasure、onLayout


ConsecutiveScrollerLayout繼承自ViewGroup,一個自定義佈局總是免不了重寫onMeasure、onLayout來測量和定位子View。


<code>@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int count = getChildCount();
    for (int i = 0; i         View child = getChildAt(i);
        measureChild(child, widthMeasureSpec, heightMeasureSpec);
    }
}

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    mScrollRange = 0;
    int childTop = t + getPaddingTop();
    int left = l + getPaddingLeft();

    List<view> children = getNonGoneChildren();
    int count = children.size();
    for (int i = 0; i         View child = children.get(i);
        int bottom = childTop + child.getMeasuredHeight();
        child.layout(left, childTop, left + child.getMeasuredWidth(), bottom);
        childTop = bottom;
        // 聯動容器可滾動最大距離
        mScrollRange += child.getHeight();
    }
    // 聯動容器可滾動range
    mScrollRange -= getMeasuredHeight() - getPaddingTop() - getPaddingBottom();
}

    /**
 * 返回所有的非GONE子View

 */
private List<view> getNonGoneChildren() {
    List<view> children = new ArrayList<>();
    int count = getChildCount();
    for (int i = 0; i         View child = getChildAt(i);
        if (child.getVisibility() != GONE) {
            children.add(child);
        }
    }
    return children;
}/<view>/<view>/<view>/<code>


onMeasured的邏輯很簡單,遍歷測量子vew即可。


onLayout是把子view從上到下排列,就像一個垂直的LinearLayout一樣。getNonGoneChildren()方法過濾掉隱藏的子view,隱藏的子view不參與佈局。


上面的mScrollRange變量是佈局自身可滑動的範圍,它等於所有子view的高度減去佈局自身的內容顯示高度。在後面,它將用於計算佈局的滑動偏移和邊距限制。


攔截滑動事件


前面說過ConsecutiveScrollerLayout會攔截它的可滑動的子view的滑動事件,由自己來處理所有的滑動。下面是它攔截事件的實現。


<code>@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_MOVE) {
        // 需要攔截事件
        if (isIntercept(ev)) {
            return true;
        }
    }
    return super.onInterceptTouchEvent(ev);
}/<code>


如果是滑動事件(ACTION_MOVE),判斷是否需要攔截事件,攔截則直接返回true,讓事件交由ConsecutiveScrollerLayout的onTouchEvent方法處理。判斷是否需要攔截的關鍵是isIntercept(ev)方法。


<code>  /**
   * 判斷是否需要攔截事件
   */
  private boolean isIntercept(MotionEvent ev) {
          // 根據觸摸點獲取當前觸摸的子view
      View target = getTouchTarget((int) ev.getRawX(), (int) ev.getRawY());

      if (target != null) {
        // 判斷子view是否允許父佈局攔截事件
          ViewGroup.LayoutParams lp = target.getLayoutParams();
          if (lp instanceof LayoutParams) {
              if (!((LayoutParams) lp).isConsecutive) {
                  return false;
              }
          }

        // 判斷子view是否可以垂直滑動

          if (ScrollUtils.canScrollVertically(target)) {
              return true;
          }
      }

      return false;
  }

public class ScrollUtils {

static boolean canScrollVertically(View view) {
      return canScrollVertically(view, 1) || canScrollVertically(view, -1);
  }

static boolean canScrollVertically(View view, int direction) {
      return view.canScrollVertically(direction);
  }
}/<code>


判斷是否需要攔截事件,主要是通過判斷觸摸的子view是否可以垂直滑動,如果可以垂直滑動,就攔截事件,讓事件由ConsecutiveScrollerLayout自己處理。如果不是,就不攔截,一般不能滑動的view不會消費滑動事件,所以事件最終會由ConsecutiveScrollerLayout所消費。之所以不直接攔截,是為了能讓子view儘可能的獲得事件處理和分發給下面的view的機會。


這裡有一個isConsecutive的LayoutParams屬性,它是ConsecutiveScrollerLayout.LayoutParams的自定義屬性,用於表示一個子view是否允許ConsecutiveScrollerLayout攔截它的滑動事件,默認為true。如果把它設置為false,父佈局將不會攔截這個子view的事件,而是完全交由子view處理。這使得子view有了自己處理滑動事件的機會和分發事件的主動權。


這對於實現一些需要實現局部區域內滑動的特殊需求十分有用。我在GitHub中提供的demo和使用介紹中對isConsecutive有詳細的說明,在這就不做過多介紹了。


滑動處理


把事件攔截後,就要在onTouchEvent方法中處理滑動事件。


<code>@Override
public boolean onTouchEvent(MotionEvent ev) {
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 記錄觸摸點
            mTouchY = (int) ev.getY();
            // 追蹤滑動速度
            initOrResetVelocityTracker();
            mVelocityTracker.addMovement(ev);
            break;
        case MotionEvent.ACTION_MOVE:
            if (mTouchY == 0) {
                mTouchY = (int) ev.getY();
                return true;
            }
            int y = (int) ev.getY();
            int dy = y - mTouchY;
            mTouchY = y;
            // 滑動佈局
            scrollBy(0, -dy);
            // 追蹤滑動速度

            initVelocityTrackerIfNotExists();
            mVelocityTracker.addMovement(ev);
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            mTouchY = 0;

            if (mVelocityTracker != null) {
                // 處理慣性滑動
                mVelocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
                int yVelocity = (int) mVelocityTracker.getYVelocity();
                recycleVelocityTracker();
                fling(-yVelocity);
            }
            break;
    }
    return true;
}

// 慣性滑動
private void fling(int velocityY) {
    if (Math.abs(velocityY) > mMinimumVelocity) {
        mScroller.fling(0, mOwnScrollY,
                1, velocityY,
                0, 0,
                Integer.MIN_VALUE, Integer.MAX_VALUE);
        invalidate();
    }
}

@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        int curY = mScroller.getCurrY();
        // 滑動佈局
        dispatchScroll(curY);
        invalidate();
    }
}/<code>


onTouchEvent方法的邏輯非常簡單,就是根據手指的滑動距離通過view的scrollBy方法滑動佈局內容,同時通過VelocityTracker追蹤手指的滑動速度,使用Scroller配合computeScroll()方法實現慣性滑動。


滑動距離的分發

在處理慣性滑動是時候,我們調用了dispatchScroll()方法,這個方法是整個ConsecutiveScrollerLayout的核心,它決定了應該由誰來消費這次滑動,應該滑動那個佈局。


其實ConsecutiveScrollerLayout的scrollBy()和scrollTo()方法最終都是調用它來處理滑動的分發的。

這裡有個mOwnScrollY屬性,是用於記錄ConsecutiveScrollerLayout的整體滑動距離的,相當於View的mScrollY屬性。


dispatchScroll()方法把滑動分成向上和向下兩部分處理。讓我們先看向上滑動部分的處理。

<code>private void scrollUp(int offset) {
    int scrollOffset = 0;  // 消費的滑動記錄
    int remainder = offset; // 未消費的滑動距離
    do {
        scrollOffset = 0;
        // 是否滑動到底部
        if (!isScrollBottom()) {
            // 找到當前顯示的第一個View
            View firstVisibleView = findFirstVisibleView();
            if (firstVisibleView != null) {
                awakenScrollBars();
                // 獲取View滑動到自身底部的偏移量

                int bottomOffset = ScrollUtils.getScrollBottomOffset(firstVisibleView);
                if (bottomOffset > 0) {
                    // 如果bottomOffset大於0,表示這個view還沒有滑動到自身的底部,那麼就由這個view來消費這次的滑動距離。
                    int childOldScrollY = ScrollUtils.computeVerticalScrollOffset(firstVisibleView);
                    // 計算需要滑動的距離
                    scrollOffset = Math.min(remainder, bottomOffset);
                    // 滑動子view
                    scrollChild(firstVisibleView, scrollOffset);
                    // 計算真正的滑動距離
                    scrollOffset = ScrollUtils.computeVerticalScrollOffset(firstVisibleView) - childOldScrollY;
                } else {
                    // 如果子view已經滑動到自身的底部,就由父佈局消費滑動距離,直到把這個子view滑出屏幕
                    int selfOldScrollY = getScrollY();
                    // 計算需要滑動的距離
                    scrollOffset = Math.min(remainder,
                            firstVisibleView.getBottom() - getPaddingTop() - getScrollY());
                    // 滑動父佈局
                    scrollSelf(getScrollY() + scrollOffset);
                    // 計算真正的滑動距離
                    scrollOffset = getScrollY() - selfOldScrollY;
                }
                // 計算消費的滑動距離,如果還沒有消費完,就繼續循環消費。
                mOwnScrollY += scrollOffset;
                remainder = remainder - scrollOffset;
            }
        }
    } while (scrollOffset > 0 && remainder > 0);
}

public boolean isScrollBottom() {
    List<view> children = getNonGoneChildren();
    if (children.size() > 0) {
        View child = children.get(children.size() - 1);
        return getScrollY() >= mScrollRange && !child.canScrollVertically(1);
    }
    return true;

}

public View findFirstVisibleView() {
    int offset = getScrollY() + getPaddingTop();
    List<view> children = getNonGoneChildren();
    int count = children.size();
    for (int i = 0; i         View child = children.get(i);
        if (child.getTop() <= offset && child.getBottom() > offset) {
            return child;
        }
    }
    return null;
}

private void scrollSelf(int y) {
    int scrollY = y;

    // 邊界檢測
    if (scrollY         scrollY = 0;
    } else if (scrollY > mScrollRange) {
        scrollY = mScrollRange;
    }
    super.scrollTo(0, scrollY);
}

private void scrollChild(View child, int y) {
    child.scrollBy(0, y);
}/<view>/<view>/<code>


向上滑動的處理邏輯是,先找到當前顯示的第一個子view,判斷它的內容是否已經滑動到它的底部,如果沒有,則由它來消費滑動距離。


如果已經滑動到它的底部,則由ConsecutiveScrollerLayout來消費滑動距離,直到把這個子view滑出屏幕。這樣下一次獲取顯示的第一個view就是它的下一個view了,重複以上的操作,直到把ConsecutiveScrollerLayout和所有的子view都滑動到底部,這樣就整體都滑動到底部了。


這裡使用了一個while循環操作,這樣做是因為一次滑動距離,可能會由多個對象來消費,比如需要滑動50px的距離,但是當前顯示的第一個子view還需要10px滑動到自己的底部,那麼這個子view會消費10px的距離,剩下40px的距離就要進行下一次的分發,找到需要消費它的對象,以此類推。

向下滑動的處理跟向上滑動是一摸一樣的,只是判斷的對象和滑動的方向不同。

<code>private void scrollDown(int offset) {
    int scrollOffset = 0;  // 消費的滑動記錄
    int remainder = offset;  // 未消費的滑動距離
    do {
        scrollOffset = 0;
        // 是否滑動到頂部
        if (!isScrollTop()) {
            // 找到當前顯示的最後一個View
            View lastVisibleView = findLastVisibleView();
            if (lastVisibleView != null) {
                awakenScrollBars();
                // 獲取View滑動到自身頂部的偏移量
                int childScrollOffset = ScrollUtils.getScrollTopOffset(lastVisibleView);
                if (childScrollOffset                     // 如果childScrollOffset大於0,表示這個view還沒有滑動到自身的頂部,那麼就由這個view來消費這次的滑動距離。
                    int childOldScrollY = ScrollUtils.computeVerticalScrollOffset(lastVisibleView);
                    // 計算需要滑動的距離
                    scrollOffset = Math.max(remainder, childScrollOffset);
                    // 滑動子view

                    scrollChild(lastVisibleView, scrollOffset);
                    // 計算真正的滑動距離
                    scrollOffset = ScrollUtils.computeVerticalScrollOffset(lastVisibleView) - childOldScrollY;
                } else {
                    // 如果子view已經滑動到自身的頂部,就由父佈局消費滑動距離,直到把這個子view完全滑動進屏幕
                    int scrollY = getScrollY();
                    // 計算需要滑動的距離
                    scrollOffset = Math.max(remainder,
                            lastVisibleView.getTop() + getPaddingBottom() - scrollY - getHeight());
                    // 滑動父佈局
                    scrollSelf(scrollY + scrollOffset);
                    // 計算真正的滑動距離
                    scrollOffset = getScrollY() - scrollY;
                }
                // 計算消費的滑動距離,如果還沒有消費完,就繼續循環消費。
                mOwnScrollY += scrollOffset;
                remainder = remainder - scrollOffset;
            }
        }
    } while (scrollOffset }

public boolean isScrollTop() {
    List<view> children = getNonGoneChildren();
    if (children.size() > 0) {
        View child = children.get(0);
        return getScrollY() <= 0 && !child.canScrollVertically(-1);
    }
    return true;
}

public View findLastVisibleView() {
    int offset = getHeight() - getPaddingBottom() + getScrollY();
    List<view> children = getNonGoneChildren();
    int count = children.size();
    for (int i = 0; i         View child = children.get(i);
        if (child.getTop() = offset) {
            return child;
        }
    }
    return null;
}/<view>/<view>/<code>


文章不易,如果大家喜歡這篇文章,或者對你有幫助希望大家多多,點贊,轉發,關注 哦。文章會持續更新的。絕對乾貨!!!



分享到:


相關文章: