Andorid 嵌套滑動機制 NestedScrollingParent2和NestedScrollingChild2 詳解

前言

NestedScrolling 是Andorid 5.0推出的一個嵌套滑動機制,主要是利用 NestedScrollingParent 和 NestedScrollingChild 讓父View和子View在滾動時互相協調配合,極大的方便了我們對於嵌套滑動的處理。通過 NestedScrolling 我們可以很簡單的實現類似知乎首頁,QQ空間首頁等非常漂亮的交互效果。

但是有一個問題,對於fling的傳遞,NestedScrolling的處理並不友好,child只是簡單粗暴的將fling結果拋給parent。對於fling,要麼child處理,要麼parent處理。當我們想要先由child處理一部分,剩餘的再交個parent來處理的時候,就顯得比較乏力了;
老規矩,直接上圖:


很明顯,列表處理了fling,在滑動到頂端的時候就停下來了,需要在再次觸摸滑動,才能顯示出頂部的圖片;這種情況下,如果和UI進行鬥智鬥勇,我們是必敗無疑。

不過,在Andorid 8.0 ,google爸爸應該也瞭解到了這種情況,推出了一個升級版本 NestedScrollingParent2 和 NestedScrollingChild2 ,友好的處理了fling的分配問題,可以實現非常絲滑柔順的滑動效果,直接看圖:


在這個版本中,列表在消耗fling之後滑動到第一個item之後,將剩餘的fling交個parent來處理,滑動出頂部的圖片,整個流程非常流程,沒有任何卡頓;接下來本文將詳細的剖析一下NestedScrollingParent2 和 NestedScrollingChild2 的工作原理;

正文

NestedScrollingParent 和 NestedScrollingChild 已經有很多的教程,大家可以自行學習,本片文章主要對 NestedScrollingParent2 和 NestedScrollingChild2 進行分析;

1、先了解API

  • NestedScrollingParent2
public interface NestedScrollingParent2 extends NestedScrollingParent {
       /**
    * 即將開始嵌套滑動,此時嵌套滑動尚未開始,由子控件的 startNestedScroll 方法調用
    *
    * @param child  嵌套滑動對應的父類的子類(因爲嵌套滑動對於的父控件不一定是一級就能找到的,可能挑了兩級父控件的父控件,child的輩分>=target)
    * @param target 具體嵌套滑動的那個子類
    * @param axes   嵌套滑動支持的滾動方向
    * @param type   嵌套滑動的類型,有兩種ViewCompat.TYPE_NON_TOUCH fling效果,ViewCompat.TYPE_TOUCH 手勢滑動
    * @return true 表示此父類開始接受嵌套滑動,只有true時候,纔會執行下面的 onNestedScrollAccepted 等操作
    */
   boolean onStartNestedScroll(@NonNull View child, @NonNull View target, @ScrollAxis int axes,
           @NestedScrollType int type);

   /**
    * 當onStartNestedScroll返回爲true時,也就是父控件接受嵌套滑動時,該方法纔會調用
    *
    * @param child
    * @param target
    * @param axes
    * @param type
    */
   void onNestedScrollAccepted(@NonNull View child, @NonNull View target, @ScrollAxis int axes,
           @NestedScrollType int type);

   /**
    * 在子控件開始滑動之前,會先調用父控件的此方法,由父控件先消耗一部分滑動距離,並且將消耗的距離存在consumed中,傳遞給子控件
    * 在嵌套滑動的子View未滑動之前
    * ,判斷父view是否優先與子view處理(也就是父view可以先消耗,然後給子view消耗)
    *
    * @param target   具體嵌套滑動的那個子類
    * @param dx       水平方向嵌套滑動的子View想要變化的距離
    * @param dy       垂直方向嵌套滑動的子View想要變化的距離 dy<0向下滑動 dy>0 向上滑動
    * @param consumed 這個參數要我們在實現這個函數的時候指定,回頭告訴子View當前父View消耗的距離
    *                 consumed[0] 水平消耗的距離,consumed[1] 垂直消耗的距離 好讓子view做出相應的調整
    * @param type     滑動類型,ViewCompat.TYPE_NON_TOUCH fling效果,ViewCompat.TYPE_TOUCH 手勢滑動
    */
   void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
           @NestedScrollType int type);
           
   /**
    * 在 onNestedPreScroll 中,父控件消耗一部分距離之後,剩餘的再次給子控件,
    * 子控件消耗之後,如果還有剩餘,則把剩餘的再次還給父控件
    *
    * @param target       具體嵌套滑動的那個子類
    * @param dxConsumed   水平方向嵌套滑動的子控件滑動的距離(消耗的距離)
    * @param dyConsumed   垂直方向嵌套滑動的子控件滑動的距離(消耗的距離)
    * @param dxUnconsumed 水平方向嵌套滑動的子控件未滑動的距離(未消耗的距離)
    * @param dyUnconsumed 垂直方向嵌套滑動的子控件未滑動的距離(未消耗的距離)
    * @param type     滑動類型,ViewCompat.TYPE_NON_TOUCH fling效果,ViewCompat.TYPE_TOUCH 手勢滑動
    */
   void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
           int dxUnconsumed, int dyUnconsumed, @NestedScrollType int type);

    /**
    * 停止滑動
    *
    * @param target
    * @param type     滑動類型,ViewCompat.TYPE_NON_TOUCH fling效果,ViewCompat.TYPE_TOUCH 手勢滑動
    */
 void onStopNestedScroll(@NonNull View target, @NestedScrollType int type);


}
  • NestedScrollingParent2
public interface NestedScrollingChild2 extends NestedScrollingChild {

   /**
    * 開始滑動前調用,在慣性滑動和觸摸滑動前都會進行調用,此方法一般在 onInterceptTouchEvent或者onTouch中,通知父類方法開始滑動
    * 會調用父類方法的 onStartNestedScroll onNestedScrollAccepted 兩個方法
    *
    * @param axes 滑動方向
    * @param type 開始滑動的類型 the type of input which cause this scroll event
    * @return 有父視圖並且開始滑動,則返回true 實際上就是看parent的 onStartNestedScroll 方法
    */
   boolean startNestedScroll(@ScrollAxis int axes, @NestedScrollType int type);

  /**
    * 子控件停止滑動,例如手指擡起,慣性滑動結束
    *
    * @param type 停止滑動的類型 TYPE_TOUCH,TYPE_NON_TOUCH
    */
   void stopNestedScroll(@NestedScrollType int type);

    /**
    * 判斷是否有父View 支持嵌套滑動
    */
   boolean hasNestedScrollingParent(@NestedScrollType int type);

 /**
    * 在dispatchNestedPreScroll 之後進行調用
    * 當滑動的距離父控件消耗後,父控件將剩餘的距離再次交個子控件,
    * 子控件再次消耗部分距離後,又繼續將剩餘的距離分發給父控件,由父控件判斷是否消耗剩下的距離。
    * 如果四個消耗的距離都是0,則表示沒有神可以消耗的了,會直接返回false,否則會調用父控件的
    * onNestedScroll 方法,父控件繼續消耗剩餘的距離
    * 會調用父控件的
    *
    * @param dxConsumed     水平方向嵌套滑動的子控件滑動的距離(消耗的距離)    dx<0 向右滑動 dx>0 向左滑動 (保持和 RecycleView 一致)
    * @param dyConsumed     垂直方向嵌套滑動的子控件滑動的距離(消耗的距離)    dy<0 向下滑動 dy>0 向上滑動 (保持和 RecycleView 一致)
    * @param dxUnconsumed   水平方向嵌套滑動的子控件未滑動的距離(未消耗的距離)dx<0 向右滑動 dx>0 向左滑動 (保持和 RecycleView 一致)
    * @param dyUnconsumed   垂直方向嵌套滑動的子控件未滑動的距離(未消耗的距離)dy<0 向下滑動 dy>0 向上滑動 (保持和 RecycleView 一致)
    * @param offsetInWindow 子控件在當前window的偏移量
    * @return 如果返回true, 表示父控件又繼續消耗了
    */
   boolean dispatchNestedScroll(int dxConsumed, int dyConsumed,
           int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow,
           @NestedScrollType int type);

   /**
    * 子控件在開始滑動前,通知父控件開始滑動,同時由父控件先消耗滑動時間
    * 在子View的onInterceptTouchEvent或者onTouch中,調用該方法通知父View滑動的距離
    * 最終會調用父view的 onNestedPreScroll 方法
    *
    * @param dx             水平方向嵌套滑動的子控件想要變化的距離 dx<0 向右滑動 dx>0 向左滑動 (保持和 RecycleView 一致)
    * @param dy             垂直方向嵌套滑動的子控件想要變化的距離 dy<0 向下滑動 dy>0 向上滑動 (保持和 RecycleView 一致)
    * @param consumed       父控件消耗的距離,父控件消耗完成之後,剩餘的纔會給子控件,子控件需要使用consumed來進行實際滑動距離的處理
    * @param offsetInWindow 子控件在當前window的偏移量
    * @param type           滑動類型,ViewCompat.TYPE_NON_TOUCH fling效果,ViewCompat.TYPE_TOUCH 手勢滑動
    * @return true    表示父控件進行了滑動消耗,需要處理 consumed 的值,false表示父控件不對滑動距離進行消耗,可以不考慮consumed數據的處理,此時consumed中兩個數據都應該爲0
    */
   boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
           @Nullable int[] offsetInWindow, @NestedScrollType int type);
}
  • 調用流程

上面的API我已經做了很詳細的註釋,應該不難理解,梳理下拉,大概流程就是:

一般情況下,事件是從child的觸摸事件開始的,

  1. 首先調用child.startNestedScroll()方法,此方法內部通過 NestedScrollingChildHelper 調用並返回parent.onStartNestedScroll()方法的結果,爲true,說明parent接受了嵌套滑動,同時調用了parent.onNestedScrollAccepted()方法,此時開始嵌套滑動;

  2. 在滑動事件中,child通過child.dispatchNestedPreScroll()方法分配滑動的距離,child.dispatchNestedPreScroll()內部會先調用parent.onNestedPreScroll()方法,由parent先處理滑動距離。

  3. parent消耗完成之後,再將剩餘的距離傳遞給child,child拿到parent使用完成之後的距離之後,自己再處理剩餘的距離。

  4. 如果此時子控件還有未處理的距離,則將剩餘的距離再次通過 child.dispatchNestedScroll()方法調用parent.onNestedScroll()方法,將剩餘的距離交個parent來進行處理

  5. 滑動結束之後,調用 child.stopNestedScroll()通知parent滑動結束,至此,觸摸滑動結束

  6. 觸摸滑動結束之後,child會繼續進行慣性滑動,慣性滑動可以通過 Scroller 實現,具體滑動可以自己來處理,在fling過程中,和觸摸滑動調用流程一樣,需要注意type參數的區分,用來通知parent兩種不同的滑動流程

至此, NestedScrollingParent2 和 NestedScrollingChild2 的流程和主要方法已經很清晰了;但是沒有僅僅看到這裏應該還有比較難以理解,畢竟沒有代碼的API和耍流氓沒什麼區別,接下來,還是上源碼;

2、通過RecycleView學習 NestedScrollingChild2

沒有什麼知識點是從源碼裏獲取不到的,RecycleView是我們最常用的列表組件,同時也是嵌套滑動需求最多的組件,它本身也實現了 NestedScrollingChild2 ,這裏就以此爲例進行分析;

1、 RecycleView中的 NestedScrollingChild2

首先,我們先找到RecycleView中的 NestedScrollingChild2 的方法;

  @Override
   public boolean startNestedScroll(int axes, int type) {
       return getScrollingChildHelper().startNestedScroll(axes, type);
   }

   @Override
   public void stopNestedScroll(int type) {
       getScrollingChildHelper().stopNestedScroll(type);
   }

 
   @Override
   public boolean hasNestedScrollingParent(int type) {
       return getScrollingChildHelper().hasNestedScrollingParent(type);
   }

 
   @Override
   public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
           int dyUnconsumed, int[] offsetInWindow, int type) {
       return getScrollingChildHelper().dispatchNestedScroll(dxConsumed, dyConsumed,
               dxUnconsumed, dyUnconsumed, offsetInWindow, type);
   }

 

   @Override
   public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow,
           int type) {
       return getScrollingChildHelper().dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow,
               type);
   }
  private NestedScrollingChildHelper getScrollingChildHelper() {
       if (mScrollingChildHelper == null) {
           mScrollingChildHelper = new NestedScrollingChildHelper(this);
       }
       return mScrollingChildHelper;
   }

從上面可以看到,RecycleView 本身並沒有去處理 NestedScrollingChild2 方法,而是交給 NestedScrollingChildHelper 方法進行處理,NestedScrollingChildHelper 主要作用是和 parent 之間進行一些數據的傳遞處理,邏輯比較簡單,篇幅有限,就不詳細敘述了。

2、 NestedScrollingChild2在 RecycleView 觸摸滑動過程的邏輯

RecycleView 源碼本身非常複雜,爲了便於理解這裏我剔除掉一些與本次邏輯無關的代碼,根據上面的邏輯邏輯,首先找到 startNestedScroll()方法,並以此開始一步步的跟進:

@Override
   public boolean onTouchEvent(MotionEvent e) {

      // ... 此處剔除了部分和嵌套滑動關係不大的邏輯
       switch (action) {
           case MotionEvent.ACTION_DOWN: {
           mLastTouchX = (int) (e.getX() + 0.5f);
           mLastTouchY = (int) (e.getY() + 0.5f);
               //此處開始進行嵌套滑動
               startNestedScroll(nestedScrollAxis, TYPE_TOUCH);
           } break;


           case MotionEvent.ACTION_MOVE: {
              
              //省略部分無關邏輯
               int dx = mLastTouchX - x;
               int dy = mLastTouchY - y;
               
               //在開始滑動前,將手指一動距離交個parent處理
               if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
                   //如果parent 消耗掉部分距離,此處進行處理
                   dx -= mScrollConsumed[0];
                   dy -= mScrollConsumed[1];
               }
               //省略RecycleView 本身的滑動邏輯
               //......
               //scrollByInternal()本質調用的還是 dispatchNestedScroll()方法,在父控件消耗完成之後,且自己也消耗之後,將剩餘的距離再次交個父控件處理 
               scrollByInternal(
                       canScrollHorizontally ? dx : 0,
                       canScrollVertically ? dy : 0,
                       vtev);
           } break;
           case MotionEvent.ACTION_UP: {
               //省略速度計算相關代碼
               // ....
                fling((int) xvel, (int) yvel); 
               resetTouch();
           } break;
       }
       return true;
   }

去除掉不相關的邏輯之後,觸摸事件就變得非常簡單明晰

1. MotionEvent.ACTION_DOWN 中,開始滑動,調用child.startNestedScroll()方法
2. MotionEvent.ACTION_MOVE 中,調用 dispatchNestedPreScroll()和dispatchNestedScroll()方法

從源碼中可以看到,在 MotionEvent.ACTION_MOVE 中,首先調用了 dispatchNestedPreScroll()方法,如果返回true,表示父控件消耗了部分距離,此時 RecycleView 調用了兩行代碼

 dx -= mScrollConsumed[0];
 dy -= mScrollConsumed[1];

在父控件消耗這段距離這會,RecycleView也相應的減少了這部分的滑動距離;

在RecycleView處理完成滑動之後,如果還有剩餘的距離,則調用dispatchNestedScroll(),將剩餘的距離再次交給parent處理;

3.MotionEvent.ACTION_UP 中開啓慣性滑動,同時調用 stopNestedScroll()通知停止觸摸滑動

ACTION_UP 事件中,主要調用了 fling((int) xvel, (int) yvel)和 resetTouch();fling開始進行慣性滑動,而resetTouch()源碼如下,主要通知調用stopNestedScroll()方法,通知父控件停止觸摸滑動

  private void resetTouch() {
     if (mVelocityTracker != null) {
         mVelocityTracker.clear();
     }
     stopNestedScroll(TYPE_TOUCH);
     releaseGlows();
 }

至此RecycleView在嵌套互動過程中的觸摸滑動已經完成,同時也開始了fling滑動

3、NestedScrollingChild2 在 RecycleView 慣性滑動過程的邏輯

在上一小節中,MotionEvent.ACTION_UP 事件已經出發了 fling((int) xvel, (int) yvel) 方法,並且開始慣性滑動,這裏就從fling()方法開始,理解NestedScrollingChild2 在慣性滑動時候的邏輯處理:

1.開始慣性滑動,調用 startNestedScroll()方法

老規矩,先剔除掉一些不相干代碼,可以看到,

    public boolean fling(int velocityX, int velocityY) {
    //... 剔除掉部分不相干的代碼
       startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH);
       velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
       velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
       mViewFlinger.fling(velocityX, velocityY);
       return true;
   }

可以看到,fling()方法實質上僅僅做了兩件事

  1. 調用 startNestedScroll(nestedScrollAxis, TYPE_NON_TOUCH) 通知 parent 開始慣性滑動。注意第二個參數TYPE_NON_TOUCH,和觸摸滑動時候的 TYPE_TOUCH 區別開,是父控件區分滑動狀態的重要參數
  2. 開始慣性滑動
2.開始慣性滑動後的邏輯處理

在開始慣性滑動之後,我們來看一下fling過程中的邏輯處理,代碼主要在 ViewFlinger 的run()方法中,我們去除掉一些並不重要的代碼之後,得到下面的僞代碼:

     public void run() {
            final OverScroller scroller = mScroller;
            final SmoothScroller smoothScroller = mLayout.mSmoothScroller;
                //開始慣性滑動前,先將數據交個父控件處理
                if (dispatchNestedPreScroll(dx, dy, scrollConsumed, null, TYPE_NON_TOUCH)) {
                //處理被父控件消耗掉的
                    dx -= scrollConsumed[0];
                    dy -= scrollConsumed[1];
                }
                //... 省略RecycleView本身慣性滑動邏輯處理
                
                //將剩餘的距離交個父控件進行處理
                if (!dispatchNestedScroll(hresult, vresult, overscrollX, overscrollY, null,
                        TYPE_NON_TOUCH)
                        && (overscrollX != 0 || overscrollY != 0)) {
                }
                //處理完成之後,通知父控件此次慣性滑動結束
                stopNestedScroll(TYPE_NON_TOUCH);
        }

慣性滑動的過程和觸摸滑動非常相似,雖然僅僅加了一個參數,但是已經將慣性滑動的數據傳遞給了父控件,非常簡單的完成了整個流程的處理,不得不說,google爸爸永遠是google爸爸;

到此爲止,我們已經完整的分析了RecycleView作爲child的邏輯流程,相信對於 NestedScrollingChild2 也已經有了一個初步的瞭解;
NestedScrollingParent2 相對來說比較簡單,這裏就不進行詳細的分析了,只要根據 NestedScrollingChild2 傳來的數據,進行處理就好了

3、實戰,自己寫一個完整的嵌套滑動

光說不練都是假把式,在已經初步瞭解RecycleView的流程的情況下,自己寫一個小小的Demo,實現開頭的效果,直接上代碼:

1、NestedScrollingParent2Layout 繼承NestedScrollingParent2 實現parent 代碼邏輯

使用這個代碼直接包裹RecycleView和一個ImageView就可以直接實現開頭的效果了
package com.sang.refrush;

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.NestedScrollingParent2;
import androidx.core.view.NestedScrollingParentHelper;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.RecyclerView;

import com.sang.refrush.utils.FRLog;


/**
* Description:NestedScrolling2機制下的嵌套滑動,實現NestedScrollingParent2接口下,處理fling效果的區別
*/

public class NestedScrollingParent2Layout extends LinearLayout implements NestedScrollingParent2 {

   private View mTopView;
   private View mContentView;
   private View mBottomView;
   private int mTopViewHeight;
   private int mGap;
   private int mBottomViewHeight;


   private NestedScrollingParentHelper mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);

   public NestedScrollingParent2Layout(Context context) {
       this(context, null);
   }

   public NestedScrollingParent2Layout(Context context, @Nullable AttributeSet attrs) {
       this(context, attrs, 0);
   }

   public NestedScrollingParent2Layout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
       super(context, attrs, defStyleAttr);
       setOrientation(VERTICAL);
   }


   /**
    * 即將開始嵌套滑動,此時嵌套滑動尚未開始,由子控件的 startNestedScroll 方法調用
    *
    * @param child  嵌套滑動對應的父類的子類(因爲嵌套滑動對於的父控件不一定是一級就能找到的,可能挑了兩級父控件的父控件,child的輩分>=target)
    * @param target 具體嵌套滑動的那個子類
    * @param axes   嵌套滑動支持的滾動方向
    * @param type   嵌套滑動的類型,有兩種ViewCompat.TYPE_NON_TOUCH fling效果,ViewCompat.TYPE_TOUCH 手勢滑動
    * @return true 表示此父類開始接受嵌套滑動,只有true時候,纔會執行下面的 onNestedScrollAccepted 等操作
    */
   @Override
   public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
       if (mContentView != null && mContentView instanceof RecyclerView) {
           ((RecyclerView) mContentView).stopScroll();
       }
       mTopView.stopNestedScroll();
       return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
   }


   /**
    * 當onStartNestedScroll返回爲true時,也就是父控件接受嵌套滑動時,該方法纔會調用
    *
    * @param child
    * @param target
    * @param axes
    * @param type
    */
   @Override
   public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
       mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type);
   }

   /**
    * 在子控件開始滑動之前,會先調用父控件的此方法,由父控件先消耗一部分滑動距離,並且將消耗的距離存在consumed中,傳遞給子控件
    * 在嵌套滑動的子View未滑動之前
    * ,判斷父view是否優先與子view處理(也就是父view可以先消耗,然後給子view消耗)
    *
    * @param target   具體嵌套滑動的那個子類
    * @param dx       水平方向嵌套滑動的子View想要變化的距離
    * @param dy       垂直方向嵌套滑動的子View想要變化的距離 dy<0向下滑動 dy>0 向上滑動
    * @param consumed 這個參數要我們在實現這個函數的時候指定,回頭告訴子View當前父View消耗的距離
    *                 consumed[0] 水平消耗的距離,consumed[1] 垂直消耗的距離 好讓子view做出相應的調整
    * @param type     滑動類型,ViewCompat.TYPE_NON_TOUCH fling效果,ViewCompat.TYPE_TOUCH 手勢滑動
    */
   @Override
   public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
       //這裏不管手勢滾動還是fling都處理
       boolean hideTop = dy > 0 && getScrollY() < mTopViewHeight ;
       boolean showTop = dy < 0
               && getScrollY() >= 0
               && !target.canScrollVertically(-1)
               && !mContentView.canScrollVertically(-1)
               &&target!=mBottomView
               ;
       boolean cunsumedTop = hideTop || showTop;

       //對於底部佈局
       boolean hideBottom = dy < 0 && getScrollY() > mTopViewHeight;
       boolean showBottom = dy > 0
               && getScrollY() >= mTopViewHeight
               && !target.canScrollVertically(1)
               && !mContentView.canScrollVertically(1)
               &&target!=mTopView
               ;
       boolean cunsumedBottom = hideBottom || showBottom;

       if (cunsumedTop) {
           scrollBy(0, dy);
           consumed[1] = dy;
       } else if (cunsumedBottom) {
           scrollBy(0, dy);
           consumed[1] = dy;
       }
   }


   /**
    * 在 onNestedPreScroll 中,父控件消耗一部分距離之後,剩餘的再次給子控件,
    * 子控件消耗之後,如果還有剩餘,則把剩餘的再次還給父控件
    *
    * @param target       具體嵌套滑動的那個子類
    * @param dxConsumed   水平方向嵌套滑動的子控件滑動的距離(消耗的距離)
    * @param dyConsumed   垂直方向嵌套滑動的子控件滑動的距離(消耗的距離)
    * @param dxUnconsumed 水平方向嵌套滑動的子控件未滑動的距離(未消耗的距離)
    * @param dyUnconsumed 垂直方向嵌套滑動的子控件未滑動的距離(未消耗的距離)
    */
   @Override
   public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
       if (dyUnconsumed<0){
           //對於向下滑動
           if (target == mBottomView){
               mContentView.scrollBy(0, dyUnconsumed);
           }
       }else {
           if (target == mTopView){
               mContentView.scrollBy(0, dyUnconsumed);
           }
       }

   }


   /**
    * 停止滑動
    *
    * @param target
    * @param type
    */
   @Override
   public void onStopNestedScroll(@NonNull View target, int type) {
       if (type == ViewCompat.TYPE_NON_TOUCH) {
           System.out.println("onStopNestedScroll");
       }

       mNestedScrollingParentHelper.onStopNestedScroll(target, type);
   }


   @Override
   public int getNestedScrollAxes() {
       return mNestedScrollingParentHelper.getNestedScrollAxes();
   }

   @Override
   protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
       //ViewPager修改後的高度= 總高度-導航欄高度
       super.onMeasure(widthMeasureSpec, heightMeasureSpec);
       ViewGroup.LayoutParams layoutParams = mContentView.getLayoutParams();
       layoutParams.height = getMeasuredHeight();
       mContentView.setLayoutParams(layoutParams);
       super.onMeasure(widthMeasureSpec, heightMeasureSpec);
   }

   @Override
   protected void onFinishInflate() {
       super.onFinishInflate();
       if (getChildCount() > 0) {
           mTopView = getChildAt(0);
       }
       if (getChildCount() > 1) {
           mContentView = getChildAt(1);
       }
       if (getChildCount() > 2) {
           mBottomView = getChildAt(2);
       }

   }

   @Override
   protected void onSizeChanged(int w, int h, int oldw, int oldh) {
       super.onSizeChanged(w, h, oldw, oldh);
       if (mTopView != null) {
           mTopViewHeight = mTopView.getMeasuredHeight() ;
       }
       if (mBottomView != null) {
           mBottomViewHeight = mBottomView.getMeasuredHeight();
       }
   }

   @Override
   public void scrollTo(int x, int y) {
       FRLog.d("scrollTo:" + y);
       if (y < 0) {
           y = 0;
       }

       //對滑動距離進行修正
       if (mContentView.canScrollVertically(1)) {
           //可以向上滑棟
           if (y > mTopViewHeight) {
               y = mTopViewHeight-mGap;
           }
       } else if ((mContentView.canScrollVertically(-1))) {
           if (y < mTopViewHeight) {
               y = mTopViewHeight+mGap ;
           }
       }
       if (y > mTopViewHeight + mBottomViewHeight) {
           y = mTopViewHeight + mBottomViewHeight;
       }
       super.scrollTo(x, y);
   }
}

2、NestedScrollingChild2View 繼承 NestedScrollingChild2 實現 child 代碼邏輯

當然,僅僅使用parent ,我們會發現頂部圖片並不具備滑動功能,有時候我我們也需要頂部佈局擁有觸摸滑動和慣性滑動事件,還好,RecycleView 的源碼我們已經學習過了,照葫蘆畫瓢,我們也來實現以下child的代碼吧;代碼邏輯先相對來說複雜一些,我已經儘可能的進行了詳細的註釋,應該很容易理解,重點請關注onTouchEvent() 和慣性滑動的代碼

public class NestedScrollingChild2View extends LinearLayout implements NestedScrollingChild2 {


  private NestedScrollingChildHelper mScrollingChildHelper = new NestedScrollingChildHelper(this);
  private final int mMinFlingVelocity;
  private final int mMaxFlingVelocity;
  private Scroller mScroller;
  private int lastY = -1;
  private int lastX = -1;
  private int[] offset = new int[2];
  private int[] consumed = new int[2];
  private int mOrientation;
  private boolean fling;//判斷當前是否是可以進行慣性滑動



  public NestedScrollingChild2View(Context context) {
      this(context, null);

  }

  public NestedScrollingChild2View(Context context, @Nullable AttributeSet attrs) {
      this(context, attrs, 0);
  }

  public NestedScrollingChild2View(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
      super(context, attrs, defStyleAttr);
      setOrientation(VERTICAL);
      mOrientation = getOrientation();
      setNestedScrollingEnabled(true);
      ViewConfiguration vc = ViewConfiguration.get(context);
      mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
      mMaxFlingVelocity = vc.getScaledMaximumFlingVelocity();
      mScroller = new Scroller(context);
  }


  /**
   * 開始滑動前調用,在慣性滑動和觸摸滑動前都會進行調用,此方法一般在 onInterceptTouchEvent或者onTouch中,通知父類方法開始滑動
   * 會調用父類方法的 onStartNestedScroll onNestedScrollAccepted 兩個方法
   *
   * @param axes 滑動方向
   * @param type 開始滑動的類型 the type of input which cause this scroll event
   * @return 有父視圖並且開始滑動,則返回true 實際上就是看parent的 onStartNestedScroll 方法
   */
  @Override
  public boolean startNestedScroll(int axes, int type) {
      return mScrollingChildHelper.startNestedScroll(axes, type);
  }


  /**
   * 子控件在開始滑動前,通知父控件開始滑動,同時由父控件先消耗滑動時間
   * 在子View的onInterceptTouchEvent或者onTouch中,調用該方法通知父View滑動的距離
   * 最終會調用父view的 onNestedPreScroll 方法
   *
   * @param dx             水平方向嵌套滑動的子控件想要變化的距離 dx<0 向右滑動 dx>0 向左滑動 (保持和 RecycleView 一致)
   * @param dy             垂直方向嵌套滑動的子控件想要變化的距離 dy<0 向下滑動 dy>0 向上滑動 (保持和 RecycleView 一致)
   * @param consumed       父控件消耗的距離,父控件消耗完成之後,剩餘的纔會給子控件,子控件需要使用consumed來進行實際滑動距離的處理
   * @param offsetInWindow 子控件在當前window的偏移量
   * @param type           滑動類型,ViewCompat.TYPE_NON_TOUCH fling效果,ViewCompat.TYPE_TOUCH 手勢滑動
   * @return true    表示父控件進行了滑動消耗,需要處理 consumed 的值,false表示父控件不對滑動距離進行消耗,可以不考慮consumed數據的處理,此時consumed中兩個數據都應該爲0
   */
  @Override
  public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed, @Nullable int[] offsetInWindow, int type) {
      return mScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow, type);
  }


  /**
   * 在dispatchNestedPreScroll 之後進行調用
   * 當滑動的距離父控件消耗後,父控件將剩餘的距離再次交個子控件,
   * 子控件再次消耗部分距離後,又繼續將剩餘的距離分發給父控件,由父控件判斷是否消耗剩下的距離。
   * 如果四個消耗的距離都是0,則表示沒有神可以消耗的了,會直接返回false,否則會調用父控件的
   * onNestedScroll 方法,父控件繼續消耗剩餘的距離
   * 會調用父控件的
   *
   * @param dxConsumed     水平方向嵌套滑動的子控件滑動的距離(消耗的距離)    dx<0 向右滑動 dx>0 向左滑動 (保持和 RecycleView 一致)
   * @param dyConsumed     垂直方向嵌套滑動的子控件滑動的距離(消耗的距離)    dy<0 向下滑動 dy>0 向上滑動 (保持和 RecycleView 一致)
   * @param dxUnconsumed   水平方向嵌套滑動的子控件未滑動的距離(未消耗的距離)dx<0 向右滑動 dx>0 向左滑動 (保持和 RecycleView 一致)
   * @param dyUnconsumed   垂直方向嵌套滑動的子控件未滑動的距離(未消耗的距離)dy<0 向下滑動 dy>0 向上滑動 (保持和 RecycleView 一致)
   * @param offsetInWindow 子控件在當前window的偏移量
   * @return 如果返回true, 表示父控件又繼續消耗了
   */
  @Override
  public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
      return mScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow, type);
  }

  /**
   * 子控件停止滑動,例如手指擡起,慣性滑動結束
   *
   * @param type 停止滑動的類型 TYPE_TOUCH,TYPE_NON_TOUCH
   */
  @Override
  public void stopNestedScroll(int type) {
      mScrollingChildHelper.stopNestedScroll(type);
  }


  /**
   * 設置當前子控件是否支持嵌套滑動,如果不支持,那麼父控件是不能夠響應嵌套滑動的
   *
   * @param enabled true 支持
   */
  @Override
  public void setNestedScrollingEnabled(boolean enabled) {
      mScrollingChildHelper.setNestedScrollingEnabled(enabled);
  }

  /**
   * 當前子控件是否支持嵌套滑動
   */
  @Override
  public boolean isNestedScrollingEnabled() {
      return mScrollingChildHelper.isNestedScrollingEnabled();
  }

  /**
   * 判斷當前子控件是否擁有嵌套滑動的父控件
   */
  @Override
  public boolean hasNestedScrollingParent(int type) {
      return mScrollingChildHelper.hasNestedScrollingParent(type);
  }


  private VelocityTracker mVelocityTracker;

  @Override
  public boolean onTouchEvent(MotionEvent event) {
      int action = event.getActionMasked();
      cancleFling();//停止慣性滑動
      if (lastX == -1 || lastY == -1) {
          lastY = (int) event.getRawY();
          lastX = (int) event.getRawX();
      }

      //添加速度檢測器,用於處理fling效果
      if (mVelocityTracker == null) {
          mVelocityTracker = VelocityTracker.obtain();
      }
      mVelocityTracker.addMovement(event);

      switch (action) {
          case MotionEvent.ACTION_DOWN: {//當手指按下
              lastY = (int) event.getRawY();
              lastX = (int) event.getRawX();
              //即將開始滑動,支持垂直方向的滑動
              if (mOrientation == VERTICAL) {
                  //此方法確定開始滑動的方向和類型,爲垂直方向,觸摸滑動
                  startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, TYPE_TOUCH);
              } else {
                  startNestedScroll(ViewCompat.SCROLL_AXIS_HORIZONTAL, TYPE_TOUCH);

              }
              break;
          }
          case MotionEvent.ACTION_MOVE://當手指滑動
              int currentY = (int) (event.getRawY());
              int currentX = (int) (event.getRawX());
              int dy = lastY - currentY;
              int dx = lastX - currentX;
              //即將開始滑動,在開始滑動前,先通知父控件,確認父控件是否需要先消耗一部分滑動
              //true 表示需要先消耗一部分
              if (dispatchNestedPreScroll(dx, dy, consumed, offset, TYPE_TOUCH)) {
                  //如果父控件需要消耗,則處理父控件消耗的部分數據
                  dy -= consumed[1];
                  dx -= consumed[0];
              }
              //剩餘的自己再次消耗,
              int consumedX = 0, consumedY = 0;
              if (mOrientation == VERTICAL) {
                  consumedY = childConsumedY(dy);
              } else {
                  consumedX = childConsumeX(dx);
              }
              //子控件的滑動事件處理完成之後,剩餘的再次傳遞給父控件,讓父控件進行消耗
              //因爲沒有滑動事件,因此次數自己滑動距離爲0,剩餘的再次全部還給父控件
              dispatchNestedScroll(consumedX, consumedY, dx - consumedX, dy - consumedY, null, TYPE_TOUCH);
              lastY = currentY;
              lastX = currentX;
              break;

          case MotionEvent.ACTION_UP:  //當手指擡起的時,結束嵌套滑動傳遞,並判斷是否產生了fling效果
          case MotionEvent.ACTION_CANCEL:  //取消的時候,結束嵌套滑動傳遞,並判斷是否產生了fling效果
              //觸摸滑動停止
              stopNestedScroll(TYPE_TOUCH);

              //開始判斷是否需要慣性滑動
              mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
              int xvel = (int) mVelocityTracker.getXVelocity();
              int yvel = (int) mVelocityTracker.getYVelocity();
              fling(xvel, yvel);
              if (mVelocityTracker != null) {
                  mVelocityTracker.clear();
              }
              lastY = -1;
              lastX = -1;
              break;


      }

      return true;
  }

  private boolean fling(int velocityX, int velocityY) {
      //判斷速度是否足夠大。如果夠大才執行fling
      if (Math.abs(velocityX) < mMinFlingVelocity) {
          velocityX = 0;
      }
      if (Math.abs(velocityY) < mMinFlingVelocity) {
          velocityY = 0;
      }
      if (velocityX == 0 && velocityY == 0) {
          return false;
      }
      //通知父控件,開始進行慣性滑動
      if (mOrientation == VERTICAL) {
          //此方法確定開始滑動的方向和類型,爲垂直方向,觸摸滑動
          startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
      } else {
          startNestedScroll(ViewCompat.SCROLL_AXIS_HORIZONTAL, ViewCompat.TYPE_NON_TOUCH);
      }

      velocityX = Math.max(-mMaxFlingVelocity, Math.min(velocityX, mMaxFlingVelocity));
      velocityY = Math.max(-mMaxFlingVelocity, Math.min(velocityY, mMaxFlingVelocity));
      //開始慣性滑動
      doFling(velocityX, velocityY);
      return true;

  }

  private int mLastFlingX;
  private int mLastFlingY;
  private final int[] mScrollConsumed = new int[2];

  /**
   * 實際的fling處理效果
   */
  private void doFling(int velocityX, int velocityY) {
      fling = true;
      mScroller.fling(0, 0, velocityX, velocityY, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
      postInvalidate();
  }

  @Override
  public void computeScroll() {
      if (mScroller.computeScrollOffset() && fling) {
          int x = mScroller.getCurrX();
          int y = mScroller.getCurrY();
          int dx = mLastFlingX - x;
          int dy = mLastFlingY - y;
          FRLog.i("y: " + y + " X: " + x + " dx: " + dx + " dy: " + dy);
          mLastFlingX = x;
          mLastFlingY = y;
          //在子控件處理fling之前,先判斷父控件是否消耗
          if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, null, ViewCompat.TYPE_NON_TOUCH)) {
              //計算父控件消耗後,剩下的距離
              dx -= mScrollConsumed[0];
              dy -= mScrollConsumed[1];
          }
          //因爲之前默認向父控件傳遞的豎直方向,所以這裏子控件也消耗剩下的豎直方向
          int hResult = 0;
          int vResult = 0;
          int leaveDx = 0;//子控件水平fling 消耗的距離
          int leaveDy = 0;//父控件豎直fling 消耗的距離

          //在父控件消耗完之後,子控件開始消耗
          if (dx != 0) {
              leaveDx = childFlingX(dx);
              hResult = dx - leaveDx;//得到子控件消耗後剩下的水平距離
          }
          if (dy != 0) {
              leaveDy = childFlingY(dy);//得到子控件消耗後剩下的豎直距離
              vResult = dy - leaveDy;
          }
          //將最後剩餘的部分,再次還給父控件
          dispatchNestedScroll(leaveDx, leaveDy, hResult, vResult, null, ViewCompat.TYPE_NON_TOUCH);
          postInvalidate();
      } else {
          stopNestedScroll(ViewCompat.TYPE_NON_TOUCH);
          cancleFling();
      }
  }

  private void cancleFling() {
      fling = false;
      mLastFlingX = 0;
      mLastFlingY = 0;
  }


  /**
   * 判斷子子控件是否能夠滑動,只有能滑動才能處理fling
   */
  private boolean canScroll() {
      //具體邏輯自己實現
      return true;
  }

  /**
   * 子控件消耗多少豎直方向上的fling,由子控件自己決定
   *
   * @param dy 父控件消耗部分豎直fling後,剩餘的距離
   * @return 子控件豎直fling,消耗的距離
   */
  private int childFlingY(int dy) {

      return 0;
  }

  /**
   * 子控件消耗多少豎直方向上的fling,由子控件自己決定
   *
   * @param dx 父控件消耗部分水平fling後,剩餘的距離
   * @return 子控件水平fling,消耗的距離
   */
  private int childFlingX(int dx) {
      return 0;
  }

  /**
   * 觸摸滑動時候子控件消耗多少豎直方向上的 ,由子控件自己決定
   *
   * @param dy 父控件消耗部分豎直fling後,剩餘的距離
   * @return 子控件豎直fling,消耗的距離
   */
  private int childConsumedY(int dy) {

      return 0;
  }

  /**
   * 觸摸滑動子控件消耗多少豎直方向上的,由子控件自己決定
   *
   * @param dx 父控件消耗部分水平fling後,剩餘的距離
   * @return 子控件水平fling,消耗的距離
   */
  private int childConsumeX(int dx) {
      return 0;
  }



在頂部的圖片用child進行包裹,你會發現,圖片也有了觸摸滑動和慣性滑動效果,並且能將剩餘的滑動距離傳遞給RecycleView;

到此爲止,我們已經完成了嵌套滑動的學習,時間比較倉促,如果有還不完善的地方,請多多指正

最後,部分內容參考一些大佬的代碼,因爲時間太久已經記不清楚了,沒辦吧一一註明,如果引起不適請留言或者私信我;

最後的最後:源碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章