用 CoordinatorLayout 處理滾動

Android_開發者發表於2017-12-06

總覽

CoordinatorLayout 擴充套件了完成 Google's Material Design 中的多種滾動效果的能力。目前,此框架提供了幾種不需要寫任何自定義動畫程式碼就可以(使動畫)工作的方式。這些效果包括:

  • 上下滑動 Floating Action Button 以給 Snackbar 提供空間。

  • 將 Toolbar 或 header 展開或者收起從而為主內容區提供空間。

  • 控制哪一個 view 以何種速率進行展開或收起,包括視差滾動效果動畫。

程式碼示例

來自 Google 的 Chris Banes 將 CoordinatorLayoutdesign support library 中其他的特性放在一起做了一個酷炫的 demo。

在 github 上可以檢視完整原始碼。這個專案是最容易理解 CoordinatorLayout 的方式之一。

設定

首先要確保遵循 Design Support Library 的說明。

Floating Action Button 和 Snackbar

CoordinatorLayout 可以通過使用 layout_anchorlayout_gravity 屬性來建立懸浮效果。更多資訊請參見 Floating Action Buttons 指南。

當渲染一個 Snackbar 時,它通常出現在可見螢幕的底部。Floating action button 必須上移以便騰出空間。

只要 CoordinatorLayout 被用作主佈局,這個動畫效果就會自動出現。Float action button 有一個預設的 behavior 可以在檢測到 Snackbar 被加入的同時將這個 button 向上移動 Snackbar 的高度。

 <android.support.design.widget.CoordinatorLayout
        android:id="@+id/main_content"
        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.support.v7.widget.RecyclerView
         android:id="@+id/rvToDoList"
         android:layout_width="match_parent"
         android:layout_height="match_parent"/>

   <android.support.design.widget.FloatingActionButton
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|right"
        android:layout_margin="16dp"
        android:src="@mipmap/ic_launcher"
        app:layout_anchor="@id/rvToDoList"
        app:layout_anchorGravity="bottom|right|end"/>
 </android.support.design.widget.CoordinatorLayout>
複製程式碼

展開與收起 Toolbar

首先確保你使用的不是過時的 ActionBar。並確保遵循了 將 ToolBar 用作 ActionBar 指南。還要確保的是以 oordinatorLayout 作為主佈局容器。

<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
 xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/main_content"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true">

      <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

</android.support.design.widget.CoordinatorLayout>
複製程式碼

響應滾動事件

接下來,我們必須使用一個叫做 AppBarLayout 的容器佈局來使 ToolBar 響應滾動事件:

<android.support.design.widget.AppBarLayout
        android:id="@+id/appbar"
        android:layout_width="match_parent"
        android:layout_height="@dimen/detail_backdrop_height"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"
        android:fitsSystemWindows="true">

  <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

 </android.support.design.widget.AppBarLayout>
複製程式碼

注意:根據官方的 Google 文件,目前 AppBarLayout 需要作為直接子元素被嵌入 CoordinatorLayout 中。

然後,我們需要在 AppBarLayout 和 期望被滾動的 View 之間定義一個關聯。在 RecyclerView 或其他類似 NestedScrollView 這樣的可以巢狀滾動的 View 中加入 app:layout_behavior。支援庫中有一個對映到 AppBarLayout.ScrollingViewBehavior 的特殊字串資源 @string/appbar_scrolling_view_behavior,它可以在某個特定的 view 上發生滾動事件時通知 AppBarLayout。Behavior 必須建立在觸發(滾動)事件的 view 上。

 <android.support.v7.widget.RecyclerView
        android:id="@+id/rvToDoList"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">
複製程式碼

當 CoordinatorLayout 發現 RecyclerView 中宣告瞭這一屬性,它就會搜尋包含在其下的其他 view 看有沒有與這個 behavior 關聯的任何相關 view。在這種特殊情況下 AppBarLayout.ScrollingViewBehavior 描述了 RecyclerView 和 AppBarLayout 之間的依賴關係。RecyclerView 上的任何滾動事件都將觸發 AppBarLayout 或任何包含在其中的 view 的佈局發生變化。

RecyclerView 的滾動事件觸發了 AppBarLayout 中用 app:layout_scrollFlags 屬性宣告的 view 發生變化:

    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:fitsSystemWindows="true"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_scrollFlags="scroll|enterAlways"/>

 </android.support.design.widget.AppBarLayout>
複製程式碼

若要使任一滾動效果生效,必須啟用 app:layout_scrollFlags 屬性中的 scroll 標誌。這個標誌必須與enterAlwaysenterAlwaysCollapsedexitUntilCollapsed 或者 snap 一同使用:

  • enterAlways:向上滾動時 view 變得可見。此標誌在從一個列表的底部滑動並且希望只要一向上滑動 Toolbar 就顯示這種情況下是很有用的。

    Ps:這裡所說的 scrolling up 應該指的是 list 的滾動條向上滑動而不是上滑的手勢。

    通常,只有當 list 滑到頂部的時候 Toolbar 才會顯示,如下所示:

  • enterAlwaysCollapsed:通常只有當使用了 enterAlwaysToolbar 才會在你向下滑的時候繼續展開:

    假設你宣告瞭 enterAlways 並且已經設定了一個 minHeight,你也可以使用 enterAlwaysCollapsed。如果這樣設定了,你的 view 只會顯示出這個最低高度。只有當滑到頭的時候那個 view 才會展開到它的完全高度:

  • exitUntilCollapsed:當設定了 scroll 標誌時,下滑通常會引起全部內容的移動:

    通過指定 minHeightexitUntilCollapsed,剩餘內容開始滾動之前將首先達到 Toolbar 的最小高度,然後退出螢幕:

  • snap:使用這一選項將由其決定在 view 只有部分減時所執行的功能。如果滑動結束時 view 的高度減少的部分小於原始高度的 50%,那麼它將回到最初的位置。如果這個值大於它的 50%,它將完全消失。

注意:在你腦海中要將使用了 scroll 標誌位的 view 放在首位。這樣,被摺疊的 view 將會首先退出,留下在頂部固定著的元素。

至此,你應該意識到這個 ToolBar 響應了滾動事件。

建立摺疊效果

如果想建立摺疊 ToolBar 的效果,我們必須將 ToolBar 包含在 CollapsingToolbarLayout 中:

<android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:fitsSystemWindows="true"
        android:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar">
    <android.support.design.widget.CollapsingToolbarLayout
            android:id="@+id/collapsing_toolbar"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:fitsSystemWindows="true"
            app:contentScrim="?attr/colorPrimary"
            app:expandedTitleMarginEnd="64dp"
            app:expandedTitleMarginStart="48dp"
            app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_scrollFlags="scroll|enterAlways"></android.support.v7.widget.Toolbar>

    </android.support.design.widget.CollapsingToolbarLayout>
</android.support.design.widget.AppBarLayout>
複製程式碼

現在結果應該顯示為:

通常,我們會設定 Toolbar 的標題。現在,我們需要在 CollapsingToolBarLayout 而不是 Toolbar 上設定標題。

 CollapsingToolbarLayout collapsingToolbar =
              (CollapsingToolbarLayout) findViewById(R.id.collapsing_toolbar);
 collapsingToolbar.setTitle("Title");
複製程式碼

注意,在使用 CollapsingToolbarLayout 的時候,應該如此文件所述,將狀態列設定成半透明(API 19)或者透明(API 21)的。特別是,應該在 res/values-xx/styles.xml 中設定以下樣式:

<!-- res/values-v19/styles.xml -->
<style name="AppTheme" parent="Base.AppTheme">
    <item name="android:windowTranslucentStatus">true</item>
</style>

<!-- res/values-v21/styles.xml -->
<style name="AppTheme" parent="Base.AppTheme">
    <item name="android:windowDrawsSystemBarBackgrounds">true</item>
    <item name="android:statusBarColor">@android:color/transparent</item>
</style>
複製程式碼

通過像上面那樣啟用系統欄的半透明效果,你的佈局會將內容填充到系統欄後面,因此你還必須在那些不想被系統欄覆蓋的佈局上使用 android:fitsSystemWindow 。另外一種為 API 19 新增內邊距來避免系統欄覆蓋 view 的方案可以在這裡檢視。

建立視差動畫

CollapsingToolbarLayout 可以讓我們做出更高階的動畫,例如使用一個在摺疊的同時可以漸隱的 ImageView。在使用者滑動時,標題的高度也可以改變。

要想建立這種效果的話,我們需要新增一個 ImageView 並在 ImageView 標籤中宣告 app:layout_collapseMode="parallax" 屬性。

<android.support.design.widget.CollapsingToolbarLayout
    android:id="@+id/collapsing_toolbar"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    app:contentScrim="?attr/colorPrimary"
    app:expandedTitleMarginEnd="64dp"
    app:expandedTitleMarginStart="48dp"
    app:layout_scrollFlags="scroll|exitUntilCollapsed">

            <android.support.v7.widget.Toolbar
                android:id="@+id/toolbar"
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_scrollFlags="scroll|enterAlways" />
            <ImageView
                android:src="@drawable/cheese_1"
                app:layout_scrollFlags="scroll|enterAlways|enterAlwaysCollapsed"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:scaleType="centerCrop"
                app:layout_collapseMode="parallax"
                android:minHeight="100dp" />

</android.support.design.widget.CollapsingToolbarLayout>
複製程式碼

底部表

在 support design library 的 v23.2 版本中已經支援底部表了。支援的底部表有兩種型別:persistentmodal。Persistent 型別的底部表顯示應用內的內容,而 modal 型別的則顯示選單或者簡單的對話方塊。

Persistent 形式的底部表

有兩種方法來建立 Persistent 形式的底部表。第一種是用 NestedScrollView,然後就簡單地將內容嵌到裡面。第二種是額外建立一個嵌入 CoordinatorLayout 中的 RecyclerView。如果 layout_behavior 是預定義好的 @string/bottom_sheet_behavior,那麼這個 RecyclerView 預設是隱藏的。還要注意的是 RecyclerView 應該使用 wrap_content 而不是 match_parent,這是一個新修改,為的是讓底部欄只佔用必要的而不是全部空間:

<CoordinatorLayout>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/design_bottom_sheet"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:layout_behavior="@string/bottom_sheet_behavior">
</CoordinatorLayout>
複製程式碼

下一步是建立 RecyclerView。我們可以建立一個簡單的只包含一張圖片和文字的 Item,和一個可以填充這些 items 的介面卡。

public class Item {

    private int mDrawableRes;

    private String mTitle;

    public Item(@DrawableRes int drawable, String title) {
        mDrawableRes = drawable;
        mTitle = title;
    }

    public int getDrawableResource() {
        return mDrawableRes;
    }

    public String getTitle() {
        return mTitle;
    }

}
複製程式碼

接著,建立介面卡:

public class ItemAdapter extends RecyclerView.Adapter<ItemAdapter.ViewHolder> {

    private List<Item> mItems;

    public ItemAdapter(List<Item> items, ItemListener listener) {
        mItems = items;
        mListener = listener;
    }

    public void setListener(ItemListener listener) {
        mListener = listener;
    }

    @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return new ViewHolder(LayoutInflater.from(parent.getContext())
                .inflate(R.layout.adapter, parent, false));
    }

    @Override
    public void onBindViewHolder(ViewHolder holder, int position) {
        holder.setData(mItems.get(position));
    }

    @Override
    public int getItemCount() {
        return mItems.size();
    }

    public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {

        public ImageView imageView;
        public TextView textView;
        public Item item;

        public ViewHolder(View itemView) {
            super(itemView);
            itemView.setOnClickListener(this);
            imageView = (ImageView) itemView.findViewById(R.id.imageView);
            textView = (TextView) itemView.findViewById(R.id.textView);
        }

        public void setData(Item item) {
            this.item = item;
            imageView.setImageResource(item.getDrawableResource());
            textView.setText(item.getTitle());
        }

        @Override
        public void onClick(View v) {
            if (mListener != null) {
                mListener.onItemClick(item);
            }
        }
    }

    public interface ItemListener {
        void onItemClick(Item item);
    }
}
複製程式碼

底部表預設是被隱藏的。我們需要用一個點選事件來觸發顯示和隱藏。注意:由於這個已知的 issue,因此不要嘗試在OnCreate() 方法中展開底部表。

RecyclerView recyclerView = (RecyclerView) findViewById(R.id.design_bottom_sheet); 

// Create your items
ArrayList<Item> items = new ArrayList<>();
items.add(new Item(R.drawable.cheese_1, "Cheese 1"));
items.add(new Item(R.drawable.cheese_2, "Cheese 2"));

// Instantiate adapter
ItemAdapter itemAdapter = new ItemAdapter(items, null);
recyclerView.setAdapter(itemAdapter);

// Set the layout manager
recyclerView.setLayoutManager(new LinearLayoutManager(this));

CoordinatorLayout coordinatorLayout = (CoordinatorLayout) findViewById(R.id.main_content);
final BottomSheetBehavior behavior = BottomSheetBehavior.from(recyclerView);

fab.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
       if(behavior.getState() == BottomSheetBehavior.STATE_COLLAPSED) {
         behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
       } else {
         behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
       }
    }
});
複製程式碼

你可以設定佈局屬性 app:behavior_hideable=true 來允許使用者也可以通過滑動而隱藏底部表。還有一些其他的屬性,包括:STATE_DRAGGINGSTATE_SETTLING,和 STATE_HIDDEN。更多內容,請看 底部表的另一篇教程

Modal 形式的底部表

Modal 形式的底部表基本上是從底部滑入的 Dialog Fragments。關於如何建立這種型別的 fragment 可以檢視本文。你應該繼承 BottomSheetDialogFragment 而不是 DialogFragment

高階的底部表示例

有很多複雜的使用了 floating action button 的底部表的例子,button 隨著使用者滑動或展開或收縮或改變表狀態。最著名的例子就是使用了多階表的 Google Maps:

下述教程和程式碼示例可以幫助你實現這些更加複雜的效果:

為了得到預期的效果可能需要相當多的實驗。對於某些特定的用例,你可能會發現下面列出的第三方庫是一種更簡單的選擇。

可選的第三方底部表

除了 design support library 中提供的官方底部表,有幾個可選的非常流行的第三方庫,他們在某些特定用法下更容易配置和使用:

以下是最常見的選擇和相關的例子:

在官方的 persistent modal 表和這些第三方的替代方案之間,你應該可以通過足夠的實驗來實現任何想要的效果。

CoordinatorLayout 故障解決

CoordinatorLayout 非常強大但容易出錯。如果你在使用 behavior 時遇到了問題,請檢視下面的建議:

  • 關於如何高效使用 CoordinatorLayout 的例子請仔細參考 cheesesquare 原始碼。這個倉庫是一個被 Google 持續更新的示例倉庫,反映了 behavior 的最佳實踐。尤其是 layout for a tabbed ViewPager listthis for a layout for a detail view 這兩個。可以仔細比較一下你的程式碼與 cheesesquare 的原始碼。
  • 確保在 CoordinatorLayout 的直接子 view 上使用了 app:layout_behavior="@string/appbar_scrolling_view_behavior" 屬性。例如,在一個下拉重新整理的例子中,這個屬性應該放在包含了 RecyclerViewSwipeRefreshLayout 中而不是第二層以下的後代中。
  • 在一個使用了內部有 items 列表的 ViewPager 的 fragment 和一個父 activity 之間使用協調時,你想像這裡描述的那樣在ViewPager 上新增 app:layout_behavior 屬性,認為這樣就可以將 pager 中的滾動事件向上傳遞然後就可以被CoordinatorLayout 管理。但是,記住,你不應該app:layout_behavior 屬性放到 fragment 或者它內部列表上的任何一個位置。
  • 謹記 ScrollView 不能與 CoordinatorLayout 一起使用。你將需要像這個示例中展示的那樣用 NestedScrollView 來代替。將你的內容包含在 NestedScrollView 中,然後在其上新增 app:layout_behavior 就會使你的滾動行為預期工作。
  • 確保你的 activity 或者 fragment 的根佈局是 CoordinatorLayout。滾動事件不會響應其他任何佈局。

使用 CoordinatorLayout 時出錯的方式有很多種,當你發現出錯時可以在這裡新增提示。

自定義 Behavior

CoordinatorLayout with Floating Action Buttons 這篇文章中討論了一個自定義 behavior 例子。

CoordinatorLayout 的工作方式是通過搜尋所有在 XML 中靜態地使用 app:layout_behavior 標籤或者以程式設計的方式在 View 類中使用 @DefaultBehavior 註解裝飾而定義 CoordinatorLayout Behavior 的子 View。當滾動事件發生時,CoorinatorLayout 嘗試去觸發那些被宣告為依賴項的子 View。

為了定義你自己的 CoordinatorLayout Behavior,你應該實現 layoutDependsOn() 和 onDependentViewChanged() 這兩個方法。例如 AppBarLayout.Behavior 就定義了這兩個關鍵方法。此 behavior 用來在滾動事件發生時觸發 AppBarLayout 上的改變。

public boolean layoutDependsOn(CoordinatorLayout parent, View child, View dependency) {
          return dependency instanceof AppBarLayout;
      }

 public boolean onDependentViewChanged(CoordinatorLayout parent, View child, View dependency) {
          // check the behavior triggered
          android.support.design.widget.CoordinatorLayout.Behavior behavior = ((android.support.design.widget.CoordinatorLayout.LayoutParams)dependency.getLayoutParams()).getBehavior();
          if(behavior instanceof AppBarLayout.Behavior) {
          // do stuff here
          }
 }       
複製程式碼

理解如何實現這些自定義的 behavior 最好方法是研究 AppBarLayout.BehaviorFloatingActionButtion.Behavior 這兩個示例。

第三方滾動和視差效果庫

除了使用上述的 CoordinatorLayout,還可以檢視這些流行的第三方庫來實現 ScrollViewListViewViewPagerRecyclerView 間的滾動和視差效果。

將 Google Map 嵌入 AppBarLayout

由於這個已被確認的 issue,目前在 AppBarLayout 中還不支援使用 Google Map。在 v23.1.0 版本的 support design library 的更新中提供了一個 setOnDragListener() 方法,如果在此佈局中需要拖拽效果的話,這個方法將非常有用。然而,它似乎不影響滾動,如這篇博文所述。

參考

掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄


相關文章