自定義View事件篇進階篇(二)-自定義NestedScrolling實戰

AndyJennifer發表於2019-07-29

前言

在上篇文章自定義View事件之進階篇(一)-NestedScrolling(巢狀滑動)機制中,我們分析了谷歌對NestedScrolling機制的設計,瞭解的不同介面的使用場景。現在就讓我們一起結合一個實際的使用例子,來鞏固之前學習的知識點吧。

效果展示

先看我們需要仿寫的實際效果吧。如下圖所示:

Demo展示

上文展示的demo,在專案NestedScrollingDemo有具體實現。

在上述Demo中,整個介面分為標題欄、展示圖片、TabLayout、ViewPage。其中ViewPager中擁有多個Fragment。其中每個fragment中都對應著一個RecyclerView。整個Demo的實現效果如下所示:

  • 當產生向上的手勢滑動與fling時,如果展示圖片沒被父控制元件遮擋,那麼父控制元件先攔截事件並滑動。當圖片完全被遮擋時,子控制元件(RecyclerView)再接著處理。
  • 當產生向下的手勢滑動與fling時,如果展示圖片沒完全顯示,那麼父控制元件先攔截事件並滑動。當圖片完全顯示時,子控制元件(RecyclerView)再接著處理。
  • 標題欄中的回退鍵,會隨著父控制元件的滑動,有一個從白色到黑色的漸變效果。
  • 標題欄中的透明度,會隨著父控制元件的滑動,透明度從0到1的變化效果。

現在就讓我們一起來實現該效果吧!!

介面使用分析

要實現巢狀滑動,我們首先想到的是要實現NestedScrollingChild與NestedScrollingParent介面,但是我們這裡的Demo需要父控制元件處理部分fling,所以我們這裡要使用NestedScrollingChild2與NestedScrollingParent2介面。又因為RecyclerView、NestedScrollView等滾動的View,在谷歌中都實現了NestedScrollingChild2介面,所以我們不用單獨來處理子控制元件對手勢滑動與fling的分發,我們只用關心父控制元件的處理就行了。

又因為整體佈局為豎直方向,所以這裡我們採用了繼承LinearLayout並實現NestedScrollingParent2介面的方式。同時為了相容低版本,我們也要使用NestedScrollingParentHelper這個幫助類。具體類實現類StickyNavLayout程式碼如下所示;

public class StickyNavLayout extends LinearLayout implements NestedScrollingParent2 {

    private NestedScrollingParentHelper mNestedScrollingParentHelper = new NestedScrollingParentHelper(this);

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

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

    public StickyNavLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        setOrientation(VERTICAL);//設定佈局方向為豎直方向。
    }

    @Override
    public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes, int type) {
         return (axes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0
    }

    @Override
    public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes, int type) {
        mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes, type);
    }

    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {}


    @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {}

    @Override
    public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
        return false;
    }

    @Override
    public void onStopNestedScroll(@NonNull View target, int type) {
        mNestedScrollingParentHelper.onStopNestedScroll(target, type);
    }

    @Override
    public int getNestedScrollAxes() {
        return mNestedScrollingParentHelper.getNestedScrollAxes();
    }
}
複製程式碼

在上述程式碼中,我們需要注意以下幾點:

  • StickyNavLayout實現類預設佈局為豎直方向。
  • 為了讓父控制元件處理豎直方向上的事件,我們需要在onStartNestedScroll方法判斷axes & ViewCompat.SCROLL_AXIS_VERTICAL
  • 為了讓子控制元件也處理fling,我們需要在onNestedPreFling方法中返回false。因為在巢狀滑動機制中,如果該方法返回true,那麼子控制元件就沒有機會處理fling了。
  • 為了相容低版本並獲得正確的巢狀滑動狀態,我們需要在onNestedScrollAccepted、onStopNestedScroll、onStopNestedScroll、中呼叫NestedScrollingParentHelper的相應方法。

佈局設定

當我們把父控制元件(StickNavaLayout)的基本框架搭好後,現在就準備處理整個介面的佈局了。觀察Demo效果,我們發現當標題欄透明的時候,圖片是完全展示的,那麼也就說明標題欄佈局的層級是在圖片之上的。大致佈局如下圖所示:

整體佈局.png

繼續觀察Demo實現效果,我們可以發現得到如下幾點:

  • 當產生向上的手勢滑動與fling時,如果展示圖片沒被父控制元件遮擋,那麼父控制元件先攔截事件並滑動。當圖片完全被遮擋時,子控制元件再接著處理。
  • 當產生向下的手勢滑動與fling時,如果展示圖片沒完全顯示,那麼父控制元件先攔截事件並滑動。當圖片完全顯示時,子控制元件再接著處理。

那麼展示圖片遮擋的效果是如何實現的呢?其實很簡單,我們只需要在我們的父控制元件中新增一個與展示圖片相同高度的透明的View就行了。那麼當父控制元件在滾動的時候,就可以產生一種遮蓋的效果啦。具體設計如下圖所示:

StickyNavLayout佈局.png

那麼再對應Android的佈局檔案,整個介面的佈局大概是下面這個樣子:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!--展示圖片-->
    <ImageView
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:scaleType="fitXY"
        android:src="@drawable/ic_launcher_background"/>

    <!--標題欄-->
    <include layout="@layout/layout_common_toolbar"/>

    <!--巢狀滑動父控制元件-->
    <com.jennifer.andy.nestedscrollingdemo.view.StickyNavLayout
        android:id="@+id/sick_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <!--透明TopView-->
        <View
            android:id="@+id/sl_top_view"
            android:layout_width="match_parent"
            android:layout_height="200dp"/>
        <!--TabLayout-->
        <android.support.design.widget.TabLayout
            android:id="@+id/sl_tab"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="#fff"
            app:tabIndicatorColor="@color/colorPrimaryDark"/>
        <!--ViewPager-->
        <android.support.v4.view.ViewPager
            android:id="@+id/sl_viewpager"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="#fff"/>
    </com.jennifer.andy.nestedscrollingdemo.view.StickyNavLayout>

</RelativeLayout>
複製程式碼

父控制元件滑動範圍

在完成了整體介面的佈局後,現在我們需要處理父控制元件的滾動。繼續觀察Demo,我們能發現父控制元件滾動的範圍為展示圖片的高度減去標題欄的高度。為了計算父控制元件的滾動範圍,我們需要獲取父控制元件內部的TopView(與展示圖片高度相同的透明View)的高度。要獲取父控制元件的子控制元件,我們可以通過onFinishInflate方法。具體程式碼如下所示:

    private View mTopView;//與展示圖片高度相同的透明View

   @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mTopView = findViewById(R.id.sl_top_view);
    }
複製程式碼

獲取了子控制元件後,我們可以在onSizeChanged中得到,可以父控制元件可以滑動的距離(mCanScrollDistance = 展示圖片的高度 - 標題欄的高度)。具體程式碼如下所示:

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        mCanScrollDistance = mTopView.getMeasuredHeight() - getResources().getDimension(R.dimen.normal_title_height);
    }
複製程式碼

因為標題欄的高度基本都是48dp,所以我這裡並沒單獨去獲取標題欄的高度,而是在values/dimens檔案中宣告瞭normal_title_height = 48dp。

父控制元件巢狀滑動實現

處理了父控制元件的滑動範圍,現在到了最關鍵的巢狀滑動的處理了。當ViewPager中的RecyclerView接受到滑動後,會將滑動先分發給父控制元件,我們的父控制元件(StickyNavaLayout)需要判斷是否進行消耗,而判斷是否消耗的條件如下:

  • 當向下滑動時,如果RecyclerView不能繼續向下滑動且父控制元件(StickyNavaLayout)已經滑動了移動距離後,父控制元件(StickyNavaLayout)需要消耗。
  • 當向上滑動時,如果父控制元件(StickyNavaLayout)已經滑動了部分距離,那麼父控制元件(StickyNavaLayout)需要消耗需要消耗。

具體程式碼如下所示:

    @Override
    public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed, int type) {
        //如果子view欲向上滑動,則先交給父view滑動
        boolean hideTop = dy > 0 && getScrollY() < mCanScrollDistance;
        //如果子view欲向下滑動,必須要子view不能向下滑動後,才能交給父view滑動
        boolean showTop = dy < 0 && getScrollY() >= 0 && !target.canScrollVertically(-1);
        if (hideTop || showTop) {
            scrollBy(0, dy);
            consumed[1] = dy;// consumed[0] 水平消耗的距離,consumed[1] 垂直消耗的距離
        }
    }
複製程式碼

在上述程式碼中,我們通過呼叫View的canScrollVertically(int direction)方法來判斷是否能夠向下滑動,其中當direcation負數時,是檢查對應View是否能夠向下滑動,能,返回為true,反之返回false。當direcation正數時,是檢查對應View是否能夠向上滑動,能,返回為true,反之返回false。

需要注意的是在onNestedPreScroll方法中,我們並沒有區分是手勢滑動還是fling,也就是區分type為TYPE_TOUCH(0)還是TYPE_NON_TOUCH(1)。因為不管是手勢滑動還是fling。在Demo效果中父控制元件都需要處理。所以我們並沒有進行判斷。

當我們處理了onNestedPreScroll方法後,我們還需要處理onNestedScroll方法。因為根據巢狀滑動機制,當父控制元件預處理後,子控制元件會再消耗剩餘的距離,如果子控制元件消耗後,還有剩餘的距離。那麼就又會傳遞給父控制元件。也就是會走onNestedScroll方法。在該方法中,我們只需要單獨處理子控制元件的剩餘的向下fling。具體程式碼如下所示:

  @Override
    public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int type) {
        if (dyUnconsumed < 0 && type == ViewCompat.TYPE_NON_TOUCH) {//表示已經向下滑動到頭,且為fling
            scrollBy(0, dyUnconsumed);
        }
    }

複製程式碼

當子控制元件產生fling時,如果子控制元件消耗不完,那麼就會傳遞給父控制元件。也就是dyConsumed肯定是有值的,又因為我們只關心向下的fling。所以上述程式碼這樣判斷。

完成了巢狀滑動的處理後,我們還需要對父控制元件(StickyNavaLayout)的滾動範圍進行校驗,我們直接重寫scrollTo方法。進行判斷就好了。

    @Override
    public void scrollTo(int x, int y) {
        if (y < 0) {
            y = 0;
        }
        if (y > mCanScrollDistance) {
            y = (int) mCanScrollDistance;
        }
        if (getScrollY() != y) super.scrollTo(x, y);
    }
複製程式碼

父控制元件(StickyNavaLayout)的滾動範圍為0-mCanScrollDistance。其中mCanScrollDistance = 展示圖片的高度 - 標題欄的高度。

ViewPager高度的矯正

到現在大家可能覺得基本的巢狀滑動就結束了。但是如果你這樣寫的話你會發現一個問題:就是當我們的父控制元件(StickyNavaLayout)滾動到標題欄下後,我們會發現我們的ViewPager並沒有填充螢幕剩下的距離,而是會有一個空白距離。如下所示:

空白區域.png

是因為我們的父控制元件(StickyNavaLayout)繼承了LinearLayout且ViewPager的高度為match_parent,那麼根據View的測量規則,ViewPager實際的高度為螢幕中剩餘的高度。所以父控制元件(StickyNavaLayout)滾動到標題欄下後,會出現一段空白,那麼為了使ViewPager填充整個螢幕,我們需要重新設定ViewPager的高度。也就是我們需要重寫父控制元件(StickyNavaLayout)的onMeasure方法。具體程式碼如下所示:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //先測量一次
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //ViewPager修改後的高度= 總高度-TabLayout的高度
        ViewGroup.LayoutParams lp = mViewPager.getLayoutParams();
        lp.height = getMeasuredHeight() - mNavView.getMeasuredHeight();
        mViewPager.setLayoutParams(lp);
        //因為ViewPager修改了高度,所以需要重新測量
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }
複製程式碼

漸變效果實現

現在我們就剩下最後兩個效果了,回退鍵漸變與標題欄的透明度的變化了,其實實現也非常簡單,因為我們的父控制元件(StickyNavaLayout)有一個最大滑動的範圍,那麼我們就可以得到當前父控制元件滑動的距離與最大滑動範圍的比例,拿到這個比例後,我們可以設定標題欄的透明度。也可以通過谷歌提供的ArgbEvaluator得到漸變顏色。具體的實現方式,讀者朋友可以自行思考解決。因為篇幅的限制,這裡就不在講解具體的實現方式了。有需要的小夥伴,可以參看專案NestedScrollingDemo中的NestedScrolling2DemoActivity中的具體實現。

最後

整個Demo就講解完畢了,大家有什麼問題,歡迎提出~

相關文章