Material Design Patterns 教學 (4) - RecyclerView

本來想寫 AppBarLayout,不過發現會牽涉到 RecyclerView,所以決定先寫 RecyclerView

RecyclerView 就像 ListView,都是透過 scrolling 的動作來顯示一個清單,不過它更具彈性更自由。RecyclerView 可以很簡單的將它設為橫向或直向,或者以格仔形式顯示,而且設定加減項目的動畫也很容易。它的定位是 ListView 的後繼者,之前介紹過的 CoordinatorLayout 也只支援 RecyclerView 而不支援 LisView,所以大家還是用一用 RecyclerView 吧。

![RecyclerView 架構](/content/images/2015/11/RecyclerView.png)
RecyclerView 架構,比 `ListView` 多了一個 `LayoutManager`

此教學分為以下幾步:

  1. 安裝
  2. 設定 layout
  3. 編寫 adapter
  4. 給合 adapterRecyclerView
  5. 更多設定

1. 安裝

安裝 RecyclerView,在 gradle 加入以下設定即可。

compile 'com.android.support:recyclerview-v7:+'

2. 設定 layout

RecyclerView 的 layout 是這樣。

<android.support.v7.widget.RecyclerView
    android:id="@+id/recyclerView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    />

將其加進 layout_activity.xml 即可。

新增 item_contact.xml,它將會是 RecyclerView 中每一個項目的 view。為示範,我們只用一個 TextView

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:id="@+id/tv_name"
        android:layout_width="match_parent"
        android:layout_height="30dp"
        />
</LinearLayout>

3. 編寫 Adapter

RecyclerView 需要一個 Adapter,但它不像 ListView 有內建的 adapter (如 ArrayAdapter) 可供使用,每次也要自行 extends RecyclerView.ViewHolder

為示範,我們先建立一個 model Contract

public class Contact {
    private String name;

    public Contact() {
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public static List<Contact> generateSampleList(){
        List<Contact> list = new ArrayList<>();
        for(int i=0; i < 30; i++){
            Contact contact = new Contact();
            contact.setName("Name - " + i);
            list.add(contact);
        }
        return list;
    }
}

最後的 generateSmapleList() 只是隨便建位一個 list 來模擬資料。

接著開始建立 ContactsAdapter

先在 ContactsAdapter 中加入 ViewHolder

public class ContactsAdapter extends RecyclerView.Adapter<ContactsAdapter.ViewHolder> {
    public static class ViewHolder extends RecyclerView.ViewHolder{
        public TextView nameTextView;
        public ViewHolder(View itemView){
            super(itemView);

            nameTextView = (TextView) itemView.findViewById(R.id.tv_name);
        }
    }
}

其實 ViewHolder pattern 在 ListView 時已有,不過可選擇不使用,而 RecyclerView.Adapter 強制使用。

接著加入 constructor 和 getItemCount()

public class ContactsAdapter extends RecyclerView.Adapter<ContactsAdapter.ViewHolder> {
    //View holder code 

    private List<Contact> mContacts;

    public ContactsAdapter(List<Contact> contacts){
        mContacts = contacts;
    }

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

然後是戲肉:onCreateViewHolder()onBindViewHolder()。前者建立 view,並將 view 轉成 ViewHolder,後者將 Contact 顯示在 view 中。

public class ContactsAdapter extends RecyclerView.Adapter<ContactsAdapter.ViewHolder> {
    //view holder code

    // constructor & getItemCount()

    @Override
    public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
        Context context = viewGroup.getContext();
        View contactView = LayoutInflater.from(context).inflate(R.layout.item_contact, viewGroup, false);

        ViewHolder viewHolder = new ViewHolder(contactView);

        return viewHolder;
    }

    @Override
    public void onBindViewHolder(ViewHolder viewHolder, int position) {
        Contact contact = mContacts.get(position);
        TextView nameTextView = viewHolder.nameTextView;
        nameTextView.setText(contact.getName());
    }
}

這樣 ContactsAdapter 便完成。

細心的你可以發現,以往在 ArrayAdaptergetview(),現在 RecyclerView 中拆散為 onCreateViewHolder()onBindViewHolder()。建立 view 和 更新 view 的動作分為兩個 methods ,code 變得更易讀。

4. 給合 adapterRecyclerView

最後在 Activity 中將 RecyclerViewContactsAdapter 結合:

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_appbar);

    RecyclerView rvContacts = (RecyclerView) findViewById(R.id.recyclerView);
    ContactsAdapter adapter = new ContactsAdapter(Contact.generateSampleList());
    rvContacts.setAdapter(adapter);
    rvContacts.setLayoutManager(new LinearLayoutManager(this));
}

這樣便大功告成。

5. 更多設定

LayoutManager

RecyclerView 不一定要垂直一個個的顯示項目。透過設定不同的 LayoutManager, 我們可以改變它的 layout。要用 GridLayoutManager 便可變成 GridView一樣:

recyclerView.setLayoutManager(new GridLayoutManager(this, 3));
![Different layout](/content/images/2015/11/different_layout.jpg)
橫向和格狀顯示

Support library 已內建三款不同的 LayoutManagerLinearLayoutManager, GridLayoutManagerStaggeredGridLayoutManager 。當然,自行編寫一個也絕對沒有問題。

想了解如何行編寫的 ItemDecoration 的話請看最底的相關連結吧。

click, click, click

處理 click 是 RecyclerViewListView 麻煩的地方,因為 RecyclerView 沒有如 ListView.setItemClickListener() 一樣的 method,要處理 click event 變得很自由,也很麻煩。

方法一:使用 OnClickListener

可以在 ViewHolder 加入 OnClickListener 去處理:

public class ContactsAdapter extends RecyclerView.Adapter<ContactsAdapter.ViewHolder> {
    public static class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener{
        public TextView nameTextView;
        public MyViewHolderClick mListener;
        public ViewHolder(View itemView, MyViewHolderClick listener){
            super(itemView);
            mListener = listener;

            nameTextView = (TextView) itemView.findViewById(R.id.tv_name);
            itemView.setOnClickListener(this);
        }

        @Override
        public void onClick(View v) {
            mListener.clickOnView(v, getLayoutPosition());
        }

        public interface MyViewHolderClick {
            void clickOnView(View v, int position);
        }
    }

    @Override
    public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int viewType) {
        Context context = viewGroup.getContext();
        View contactView = LayoutInflater.from(context).inflate(R.layout.item_contact, viewGroup, false);

        ViewHolder viewHolder = new ViewHolder(contactView, new ViewHolder.MyViewHolderClick() {
            @Override
            public void clickOnView(View v, int position) {
                Contact contact = mContacts.get(position);
                Snackbar.make(v, contact.getName(), Snackbar.LENGTH_LONG).show();
            }
        });

        return viewHolder;
    }

還有,要將 item_contact 的 background 改為 ?android:attr/selectableItemBackground,才會有 click 下去變色的效果。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="?android:attr/selectableItemBackground"
    >
<!--.... -->
</LinearLayout>

注意的是,以上的 code 是在 ViewHolder 中將整個 itemView 執行 setOnClickListener() 的,所以 background 要設在整個 layout 最外層,則 LinearLayout 上。若只需要某一個 child view 處理 click 的話,要將對應的 view background 設為 ?android:attr/selectableItemBackground 才行。

使用 OnClickListener 的好處是:若你有多於一個 view 需要處理 click,可以在 MyViewHolderClick 加進新的 method ,並在 onClick 中根據 view 來決定執行那個 method。

方法二:使用 3rd party library

若你純綷只有一個 view 又不想麻煩的話,可以用別人寫的 library ,然後便可以:

ItemClickSupport.addTo(mRecyclerView).setOnItemClickListener(new ItemClickSupport.OnItemClickListener() {
    @Override
    public void onItemClicked(RecyclerView recyclerView, int position, View v) {
        // do it
    }
});

方法三:使用 onItemTouchListener

當然,想自行完全控制的話,可使用 onItemTouchListener() 處理所有 touch event

recyclerView.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {

    @Override
    public void onTouchEvent(RecyclerView recycler, MotionEvent event) {
        // Handle on touch events here
    }

    @Override
    public boolean onInterceptTouchEvent(RecyclerView recycler, MotionEvent event) {
        return false;
    }
   
});

為什麼會這麼麻煩?

其實 ListView 的做法反而是有問題,因為你可以在 ArrayAdapter.getView() 中自行對各 child viewsetOnClickListner(),同時在 ListView 執行 setItemClickListener(),那麼結果會如何呢? touch event 會如何處埋? 這方面沒有什麼文件說明 Android 會怎樣處理。這是一個容易引起誤會的地方。

加減項目

加減項目可以直接修改 data source ,然後通知 adapter

mContacts.add(0, newContact);
adapter.notifyItemInserted(0);

mContacts.remove(0);
adapter.notifyItemRemoved(0);

為了效能,不建議如 listView 一樣執行 notifyDataSetChanged()RecyclerView.Adapter 提供 notifyItemInserted(), notifyItemChanged()notifyItemRemoved() 等以處理加減項目。而且需要使用相關的 notify method 才會有加減項目的動畫。另外,在 data source 的頭尾加新減項目是不會有動畫的。

動畫

RecyclerView 其中一個強項是動畫的支援,我們可改變捲動時或加減項目的動畫。不過最簡的做法使用 open source library:RecyclerView-animators。

要改變加減項目的動畫,只需使用 recyclerView.setItemAnimator() 便可:

recyclerView.setItemAnimator(new SlideInUpAnimator())

有多種動畫可供選擇:

如想捲動時有動畫,便要用它的 AnimationAdapter 包著原本的 adapter

recyclerView.setAdapter(new AlphaInAnimationAapter(adapter));

項目分隔線

RecyclerView 是沒有內建項目之間的分隔線的。要加上的話,需要自行使用 ItemDecoration,編寫對應的 class,這樣你可以自行決定項目間的間距和分隔線的外觀等。就算只想如 ListView 般加一條橫線作分隔,也需自行寫一段 code 去做。幸好,我們活在共享的世代,來使用 Android Support Demo 的 DivierItemDecoration

RecyclerView.ItemDecoration itemDecoration = new DividerItemDecoration(this, DividerItemDecoration.VERTICAL_LIST);
recyclerView.addItemDecoration(itemDecoration);

多項選擇

支援多項選擇不是這麼簡單,請自行查閱

嗯,我承認,到這裏我已經不想再寫下去了。

結語

有了 RecyclerView,我們可以完全放棄 ListView 了嗎?不,上面也可看到, RecyclerView 雖然很強大,但同時也有點複雜。在 ListView 很簡單的設定,到 RecyclerView 便需要編寫一大段 code 或使用 3rd party library。缺少內建 default 設定,實在是 Google 的失職。

用不用 RecyclerView 要看你的需要。若想要動畫,或要動態地轉變顯示格式的話,便用 RecyclerView,只需直排或橫排簡單顯示的話,便用 ListView 吧。畢竟雖然 RecyclerView 不算複雜,但也比不上 ListView 的簡單。

不過為了美好的 Material Design ,建議大家儘量使用。

以上的程式碼已放上 Github:

https://github.com/goofyz/android-material-design-tutorial/tree/part4_recyclerview

相關連結