自定義 behavior – 完美仿 QQ 瀏覽器首頁,美團商家詳情頁

  • 2019 年 10 月 3 日
  • 筆記

使用CoordinatorLayout打造各種炫酷的效果

自定義Behavior —— 仿知乎,FloatActionButton隱藏與展示

NestedScrolling 機制深入解析

一步步帶你讀懂 CoordinatorLayout 源碼

自定義 Behavior -仿新浪微博發現頁的實現

ViewPager,ScrollView 嵌套ViewPager滑動衝突解決

自定義 behavior – 完美仿 QQ 瀏覽器首頁,美團商家詳情頁

前言

記得兩年前的時候,曾寫過自定義 behavior 的文章 自定義 Behavior -仿新浪微博發現頁的實現,到現在差不多有一萬多的閱讀量吧。

image

今天,對該 behavior 進行升級,相對於兩年前的 behavior,增加了以下功能

  • 級聯滑動過程中增加監聽回調,方便外部根據滑動距離,進行相應的動畫,展現炫酷的 UI,通過 setPagerStateListener 設置回調監聽
  • 在滑動到頂部的時候,可以設置是否能夠滑動將 Head 滑動下來,方法為 setCouldScroollOpen
  • 手指在 header 部分慣性滑動的時候,增加 fling 回調,可根據需要,是否滑動 content 部分的 list ,方法為 setOnHeaderFlingListener
  • HeaderBehavior ,ContentBehavior 代碼優化,與業務邏輯剝離開,方便復用。

使用說明

效果圖

我們先來看一下新浪微博發現頁的效果:

9fe4afa0gy1ffiedfo5sbg208n0hokjo.gif

接下來我們在來看一下我們兩年前仿照新浪微博實現的效果

image

仿 QQ 瀏覽器

image

仿美團商家詳情頁面的:

image

分析說明:

image

有兩種狀態,open 和 close 狀態。

  • open 狀態指 Tab+ViewPager 還沒有滑動到頂部的時候,header 還 沒有被完全移除屏幕的時候
  • close 狀態指 Tab+ViewPager 滑動到頂部的時候,Header 被移除屏幕的時候

從效果圖,我們可以看到 在 open 狀態下,我們向上滑動 ViewPager 裏面的 RecyclerView 的 時候,RecyclerView 並不會向上移動(RecyclerView 的滑動事件交給 外部的容器處理,被被全部消費掉了),而是整個布局(指 Header + Tab +ViewPager)會向上偏移。當 Tab 滑動到頂部的時候,我們向上滑動 ViewPager 裏面的 RecyclerView 的時候,RecyclerView 可以正常向上滑動,即此時外部容器沒有攔截滑動事件

同時我們可以看到在 open 狀態的時候,我們是不支持下拉刷新的,這個比較容易實現,監聽頁面的狀態,如果是 open 狀態,我們設置 SwipeRefreshLayout setEnabled 為 false,這樣不會 攔截事件,在頁面 close 的時候,設置 SwipeRefreshLayout setEnabled 為 TRUE,這樣就可以支持下拉刷新了。

基於上面的分析,我們這裡可以把整個效果劃分為三個部分

第一部分 Header 部分:在 Header 部分還沒有滑動到頂部的時候(即 open 的時候),跟隨手指滑動
第二部分 Content 部分:我們向上滑動的時候,當Header 處於 open 狀態,這時候 Header 向上滑動, content 部分的 recyclerView 不會滑動,當 header 處於 close 狀態,content 部分向上滑動, RecyclerView 向上滑動。當我們向下滑動的時候,header 並不會隨着滑動,只會滑動 content 部分的 recyclerView
第三部分 search 部分:當我們向上滑動的時候,Search 部分會隨着滑動,最終停留在固定的位置.

我們把這三部分的關係定義為 Content 依賴於 Header。Header 移動的時候,Content 跟着 移動。所以,我們在處理滑動事件的時候,只需要處理好 Header 部分的 Behavior 就oK了,Content 部分的 Behavior 不需要處理滑動事件,只需依賴於 Header ,跟着做相應的移動即可。Search 部分的 behavior 也不需要處理滑動事件,只需依賴與 Header,跟着做相應的移動。

至於具體怎麼實現的,可以看自定義 Behavior -仿新浪微博發現頁的實現,核心思想差不多,這裡不再重複。

使用說明

這裡我們已仿 QQ 瀏覽器 demo 進行說明:

我們一起來看一下怎樣使用:簡單來說,只需要兩步:

  1. 第一步,分別在 xml 文件中,為 header 部分, content 部分指定我們對應的 behavior
  2. 第二部分,在代碼裏面設置一些配置參數

第一步:編寫 xml 文件,並指定相應的 behavior

<?xml version="1.0" encoding="utf-8"?>  <android.support.design.widget.CoordinatorLayout 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"      android:background="@android:color/holo_blue_light"      android:fitsSystemWindows="true">        <!-- Header 部分-->      <FrameLayout          android:id="@+id/id_uc_news_header_pager"          android:layout_width="match_parent"          android:layout_height="wrap_content"          app:layout_behavior="@string/behavior_qq_browser_header_pager">              <com.xj.qqbroswer.behavior.base.NestedLinearLayout              android:layout_width="match_parent"              android:layout_height="@dimen/header_height"              android:orientation="vertical">                <TextView                  android:id="@+id/news_tv_header_pager"                  style="@style/TextAppearance.AppCompat.Title"                  android:layout_width="match_parent"                  android:layout_height="match_parent"                  android:layout_gravity="center_vertical"                  android:gravity="center"                  android:text="QQBrowser Header"                  android:textColor="@android:color/white" />              </com.xj.qqbroswer.behavior.base.NestedLinearLayout>      </FrameLayout>        <!-- ContentProvide 部分-->      <LinearLayout          android:id="@+id/behavior_content"          android:layout_width="match_parent"          android:layout_height="wrap_content"          android:orientation="vertical"          app:layout_behavior="@string/behavior_contents">            <android.support.design.widget.TabLayout              android:id="@+id/id_uc_news_tab"              android:layout_width="match_parent"              android:layout_height="@dimen/tabs_height"              android:background="@color/colorPrimary"              app:tabGravity="fill"              app:tabIndicatorColor="@color/colorPrimaryLight"              app:tabSelectedTextColor="@color/colorPrimaryLight"              app:tabTextColor="@color/colorPrimaryIcons" />            <android.support.v4.view.ViewPager              android:id="@+id/id_uc_news_content"              android:layout_width="match_parent"              android:layout_height="match_parent"              android:background="#F0F4C3">            </android.support.v4.view.ViewPager>      </LinearLayout>        <!--search 部分-->      <RelativeLayout          android:layout_width="match_parent"          android:layout_height="@dimen/header_title_height"          app:layout_behavior="@string/behavior_search">            <android.support.v7.widget.SearchView              android:layout_width="match_parent"              android:layout_height="30dp"              android:layout_centerVertical="true"              android:layout_marginLeft="10dp"              android:layout_marginRight="50dp"              android:background="@android:color/white"              app:defaultQueryHint="搜索"              app:queryHint="搜索">            </android.support.v7.widget.SearchView>            <android.support.v7.widget.AppCompatImageView              android:layout_width="30dp"              android:layout_height="30dp"              android:layout_alignParentRight="true"              android:layout_centerVertical="true"              android:layout_marginRight="10dp"              android:src="@mipmap/camera"              android:tint="@android:color/white" />        </RelativeLayout>      </android.support.design.widget.CoordinatorLayout>  

第二步:在代碼裏面動態設置一些參數

private void initBehavior() {      Resources resources = DemoApplication.getAppContext().getResources();      mHeaderBehavior = (QQBrowserHeaderBehavior) ((CoordinatorLayout.LayoutParams) findViewById(R.id.id_uc_news_header_pager).getLayoutParams()).getBehavior();      mHeaderBehavior.setPagerStateListener(new QQBrowserHeaderBehavior.OnPagerStateListener() {          @Override          public void onPagerClosed() {              if (BuildConfig.DEBUG) {                  Log.d(TAG, "onPagerClosed: ");              }              Snackbar.make(mNewsPager, "pager closed", Snackbar.LENGTH_SHORT).show();              setFragmentRefreshEnabled(true);              setViewPagerScrollEnable(mNewsPager, true);          }            @Override          public void onScrollChange(boolean isUp, int dy, int type) {            }            @Override          public void onPagerOpened() {              Snackbar.make(mNewsPager, "pager opened", Snackbar.LENGTH_SHORT).show();              setFragmentRefreshEnabled(false);          }      });      // 設置為 header height 的相反數      mHeaderBehavior.setHeaderOffsetRange(-resources.getDimensionPixelOffset(R.dimen.header_height));      // 設置 header close 的時候是否能夠通過滑動打開      mHeaderBehavior.setCouldScroollOpen(false);        mContentBehavior = (QQBrowserContentBehavior) ((CoordinatorLayout.LayoutParams) findViewById(R.id.behavior_content).getLayoutParams()).getBehavior();      // 設置依賴於哪一個 id,這裡要設置為 Header layout id      mContentBehavior.setDependsLayoutId(R.id.id_uc_news_header_pager);      // 設置 content 部分最終停留的位置      mContentBehavior.setFinalY(resources.getDimensionPixelOffset(R.dimen.header_title_height));  }  

mHeaderBehavior.setHeaderOffsetRange 設置 Header 部分的偏移量,我們是通過 translationY 實現的,因此我們一般設置為 header 高度的相反數即可。
mHeaderBehavior.setCouldScroollOpen(false) , 設置 header close 的時候是否能夠通過滑動打開。

mContentBehavior.setDependsLayoutId(R.id.id_uc_news_header_pager);設置依賴於哪一個 id,這裡要設置為 Header layout id。 mContentBehavior.setFinalY 設置 content 部分最終停留的位置。

我們來看一下 OnPagerStateListener 的回調

/**   * callback for HeaderPager 's state   */  public interface OnPagerStateListener {      /**       * do callback when pager closed       */      void onPagerClosed();        /**       * when scrooll, it would call back       *       * @param isUp  isScroollUp       * @param dy   child.getTanslationY       * @param type touch or not touch, TYPE_TOUCH, TYPE_NON_TOUCH       */      void onScrollChange(boolean isUp, int dy, @ViewCompat.NestedScrollType int type);        /**       * do callback when pager opened       */      void onPagerOpened();  }  

主要有三個方法,第一個方法,onPagerClosed 當 header close 的時候,會回調,第二個方法,當 header 滑動距離變化的時候,會回調 onScrollChange 方法。它有三個參數, isUp 代表是否是向上滑動, dy 代表 header 的偏移量, type 代表類型是 touch 或者是非 touch 的(即 fling 滑動的)

如果你想要做一些酷炫的效果的話,你可以在 onScrollChange 方法中,根據滑動的距離,各個不同的 View 做相應的動畫。

仿美圖商家詳情頁面

步驟跟上面的仿 QQ 瀏覽器的步驟是一樣的,這裡不再重複相同的步驟,說幾個關鍵點:
第一:在頁面 header close 的時候,我們可以通過滑動打開header,這是通過調用 mHeaderBehavior.setCouldScroollOpen(true); 實現的。
第二:滑動 header, fling 的時候,可以看到 content 部分的 recyclerView 也在滑動,我們是通過 header 的 fling 事件做到的,在 onFlingStart 的時候手動調用 RecyclerView 的 smoothScrollBy 進行滑動。

mHeaderBehavior.setOnHeaderFlingListener(new HeaderFlingRunnable.OnHeaderFlingListener() {      @Override      public void onFlingFinish() {        }        @Override      public void onFlingStart(View child, View target, float velocityX, float velocityY) {          Log.i(TAG, "onFlingStart: velocityY =" + velocityY);          if (velocityY < 0) {              mRecyclerView.smoothScrollBy(0, (int) Math.abs(velocityY), new AccelerateDecelerateInterpolator());          }        }        @Override      public void onHeaderClose() {        }        @Override      public void onHeaderOpen() {        }  });  

碰到的坑

header 部分無法響應滑動事件

我們是通過自定義一個 NestedLinearLayout ,重寫它的 onTouchEvent 事件,通過 NestedScrolling 機制將事件傳遞給 NestedScrollingParent,即 CoordinatorLayout,而 NestedScrollingParent 會交給子 View 的 behavior 進行處理。

@Override  public boolean onTouchEvent(MotionEvent event) {      mGestureDetector.onTouchEvent(event);      final int action = MotionEventCompat.getActionMasked(event);      switch (action) {          case MotionEvent.ACTION_DOWN:              startNestedScroll(ViewCompat.SCROLL_AXIS_HORIZONTAL                      | ViewCompat.SCROLL_AXIS_VERTICAL);                break;          case MotionEvent.ACTION_MOVE:              int dy = (int) (event.getRawY() - lastY);              lastY = (int) event.getRawY();              //  dy < 0 上滑, dy>0 下拉              if (dy < 0) { // 上滑的時候交給父類去處理                  if (startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL) // 如果找到了支持嵌套滾動的父類                          && dispatchNestedPreScroll(0, -dy, consumed, offset)) {//                      // 父類進行了一部分滾動                    }              } else {                  if (startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL) // 如果找到了支持嵌套滾動的父類                          && dispatchNestedScroll(0, 0, 0, -dy, offset)) {//                      // 父類進行了一部分滾動                    }              }              break;          case MotionEvent.ACTION_CANCEL:          case MotionEvent.ACTION_UP:              stopNestedScroll();              break;        }      return super.onTouchEvent(event);  }    

當我們給 header 的子 View 設置點擊事件的時候,無法滑動 header

對 Android 事件分發機制有一定了解的,都知道,在 Android 中,默認的事件傳遞機制是這樣的,

當TouchEvent發生時,首先Activity將TouchEvent傳遞給最頂層的View,TouchEvent最先到達最頂層 view 的 dispatchTouchEvent ,然後由 dispatchTouchEvent 方法進行分發。

  • 如果dispatchTouchEvent返回true 消費事件,事件終結。
  • 如果dispatchTouchEvent返回 false ,則回傳給父View的onTouchEvent事件處理;

    onTouchEvent事件返回true,事件終結,返回false,交給父View的OnTouchEvent方法處理

  • 如果dispatchTouchEvent返回super的話,默認會調用自己的onInterceptTouchEvent方法

    默認的情況下interceptTouchEvent回調用super方法,super方法默認返回false,所以會交給子View的onDispatchTouchEvent方法處理
    如果 interceptTouchEvent 返回 true ,也就是攔截掉了,則交給它的 onTouchEvent 來處理
    如果 interceptTouchEvent 返回 false ,那麼就傳遞給子 view ,由子 view 的 dispatchTouchEvent 再來開始這個事件的分發。

因此,當我們給子 View 設置點擊事件的時候,由於默認的 parent 沒有攔截事件,會走到子 View 的 onToucheEvent 事件中,由於設置了點擊事件,事件被消費了,所以不會回調父 View onTouchEvent 中的 ACTION_MOVE 事件。

解決辦法: 重寫 NestedLinearLayout 的 onInterceptToucheEvent 事件,當是 ACTION_MOVE 事件的時候,返回 true ,攔截,這樣會調用自己的 onTouchEvent 事件,從而保證可以滑動。

@Override  public boolean onInterceptTouchEvent(MotionEvent event) {      switch (event.getAction()) {          case MotionEvent.ACTION_DOWN:              mDownY = (int) event.getRawY();              // 當開始滑動的時候,告訴父view              startNestedScroll(ViewCompat.SCROLL_AXIS_HORIZONTAL                      | ViewCompat.SCROLL_AXIS_VERTICAL);              break;          case MotionEvent.ACTION_MOVE:              // 確保不消耗 ACTION_DOWN 事件              if (Math.abs(event.getRawY() - mDownY) > mScaledTouchSlop) {                  logD("onInterceptTouchEvent: ACTION_MOVE  mScaledTouchSlop =" + mScaledTouchSlop);                  return true;              }      }      return super.onInterceptTouchEvent(event);  }    

但這裡還有一個坑,正常一個點擊事件,會促發 ACTION_DOWN, ACTION_MOVE, ACTION_UP,如果我們直接在 ACTION_MOVE 裏面返回 true,將會導致子 View 的 onClick 事件失效。

解決辦法:

final ViewConfiguration configuration = ViewConfiguration.get(getContext());  mScaledTouchSlop = configuration.getScaledTouchSlop();  if (Math.abs(event.getRawY() - mDownY) > mScaledTouchSlop) {      return true;  }

關於滑動衝突解決的,可以看我以前的一篇博客:ViewPager,ScrollView 嵌套ViewPager滑動衝突解決

如何判斷 header 是 fling 動作

我們這裡通過手勢處理器 GestureDetector 做到的,當然你也可以通過 VelocityTracker 計算,只不過比較繁瑣

public boolean onTouchEvent(MotionEvent event) {      mGestureDetector.onTouchEvent(event);  }            GestureDetector.OnGestureListener onGestureListener = new GestureDetector.OnGestureListener() {                @Override              public boolean onDown(MotionEvent e) {                  return false;              }                -----// 省略若干代碼                @Override              public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {                  Log.d(TAG, "onFling: velocityY =" + velocityY);  //                fling((int) velocityY);                  getScrollingChildHelper().dispatchNestedPreFling(velocityX, velocityY);                  return false;              }          };            mGestureDetector = new GestureDetector(getContext(), onGestureListener);      

題外話

有時候,做一些筆記真的挺重要的。

這一次寫這一篇博客,是因為在項目中要做類似的效果。剛開始,真的沒什麼思路。但清楚得記得兩年前寫過類似的文章,具體實現原理早已忘光。我查看了兩年前的博客,整理了一下思路,將代碼搬到項目中,發現了一些坑。修修補補,把坑都填了。

試想一下,如果當初沒有將原理記錄下來,這個效果,真的挺難實現的。如果你對 Coordinatorlayout , behavior,NestedScroll 機制這些不熟悉,你根本就無法實現。兩年前寫 自定義 Behavior -仿新浪微博發現頁的實現 這篇博客的時候,收到挺多私信的,有一些反饋說他們做這個效果做了兩個多星期還是無法實現,挺感謝我寫這篇博客的。因此,從現在起,不妨嘗試一下多做一下筆記。真的,好記性不如爛筆頭。

第二點感觸比較深的是,剛開始,我看了我兩年前寫的代碼,我一開始的反應,我去,這是什麼垃圾代碼。確實,很多地方寫得挺爛的,behavior 耦合業務邏輯,很難復用,也不好維護。因此,這一次,我在空閑的時間將 behavior 抽離出來,以後要實現類似的效果,輕鬆實現, biu biu biu。

說這麼多,總結如下

  • 遇到不會的多做筆記,尤其是涉及到原理的
  • 對代碼要有敬畏之心,不多說,自己領悟取
  • 保持一顆謙卑之心

CoordinatorLayoutExample

覺得效果還不錯的,可以動手掃一掃關注我的微信公眾號,或者到我的 github 上面 star,謝謝

Android 技術人