1. 程式人生 > >動手打造史上最簡單的 Recycleview 側滑選單

動手打造史上最簡單的 Recycleview 側滑選單

本文已授權微信公眾號:鴻洋(hongyangAndroid)原創首發。

在實現 Recycleview 側滑選單時起初使用了開源庫 SwipeRecyclerView ,此庫功能廣泛,但無法滿足個人需求,這是因為此庫中存在以下侷限性:

  1. 選單文字一旦確定將無法修改
  2. 側滑時整個 item 都會滑動
  3. 無法自定義選單樣式

只能自己實現了,查閱資料後發現,較多通過 DragHelper 實現的,它是一個手勢滑動輔助工具,使 item 可以滑動,然後…… , 等等!既然目的是讓 item 可以滑動,那為什麼不直接在 item 佈局中使用 HorizontalScrollView 呢?參考鴻神的

Android 自定義控制元件打造史上最簡單的側滑選單,標題致敬一波~

首先是自定義 SlidingMenu :

public class SlidingMenu extends HorizontalScrollView {

    private static final float radio = 0.3f;//選單佔螢幕寬度比
    private final int mScreenWidth;
    private final int mMenuWidth;
    private boolean once = true;

    public SlidingMenu(Context context, AttributeSet attrs) {
        super
(context, attrs); mScreenWidth = ScreenUtil.getScreenWidth(context); mMenuWidth = (int) (mScreenWidth * radio); setOverScrollMode(View.OVER_SCROLL_NEVER); setHorizontalScrollBarEnabled(false); } @Override protected void onMeasure(int widthMeasureSpec, int
heightMeasureSpec) { if (once) { LinearLayout wrapper = (LinearLayout) getChildAt(0); wrapper.getChildAt(0).getLayoutParams().width = mScreenWidth; wrapper.getChildAt(1).getLayoutParams().width = mMenuWidth; once = false; } super.onMeasure(widthMeasureSpec, heightMeasureSpec); } @Override public boolean onTouchEvent(MotionEvent ev) { case MotionEvent.ACTION_UP: int scrollX = getScrollX(); if (Math.abs(scrollX) > mMenuWidth / 2) { this.smoothScrollTo(mMenuWidth, 0); } else { this.smoothScrollTo(0, 0); } return true; } return super.onTouchEvent(ev); } }

在 item 佈局中應用 SlidingMenu,注意 SlidingMenu 中是按照順序區分 content 和 menu 的,所以佈局檔案中順序要對應一致 :

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:layout_marginTop="2dp"
              android:orientation="vertical"
    >
    <com.xmwj.slidingmenu.SlidingMenu
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">
        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            >
            <LinearLayout
                android:id="@+id/content"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                >
                <ImageView
                    android:id="@+id/imageView"
                    android:layout_width="match_parent"
                    android:layout_height="70dp"
                    android:background="#af6fe1"
                    />
            </LinearLayout>
            <LinearLayout
                android:id="@+id/menu"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:orientation="horizontal"
                >
                <TextView
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:text="置頂"
                    android:textColor="#fff"
                    android:gravity="center"
                    android:background="@color/colorAccent"
                    />
            </LinearLayout>
        </LinearLayout>
    </com.xmwj.slidingmenu.SlidingMenu>
</LinearLayout>

OK~ ,這就實現了具有側滑選單的 Recycleview 了!不需要 DragHelper !不需要自定義 Recycleview !不需要處理事件分發!是不是超級簡單?與其自定義 Recycleview 然後關聯 item 實現,為什麼不直接改變 item 佈局實現!

簡單使用一下這個側滑選單,首先是介面卡程式碼:

class MyAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {

    private List<String> mData;
    private Context mContext;

    MyAdapter(List<String> data, Context context) {
        mData = data;
        mContext = context;
    }

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

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {  }

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

    private class MyViewHolder extends RecyclerView.ViewHolder {
        MyViewHolder(View itemView) {
            super(itemView);
        }
    }

}

MainActivity 如下:

public class MainActivity extends AppCompatActivity {

    private RecyclerView mRecyclerView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mRecyclerView = (RecyclerView) findViewById(R.id.recyclerView);
        init();
    }

    private void init() {
        List<String> data = new ArrayList<>();
        for(int i=0;i<20;i++) {
            data.add(null);
        }
        mRecyclerView.setAdapter(new MyAdapter(data, this));
    }
}

效果如下:

這裡寫圖片描述

至此已經實現了最簡單的 Recycleview 側滑選單,但 item 的選單可以同時開啟,一般情況下,當觸控其他 item 時應該關閉已開啟的選單,我們可以將開啟的 item 引用記錄下來,方便及時關閉。改造 SlidingMenu,新增以下方法 :

    /**
     * 關閉選單
     */
    public void closeMenu() {
        this.smoothScrollTo(0, 0);
        isOpen = false;
    }

    /**
     * 選單是否開啟
     */
    public boolean isOpen() {
        return isOpen;
    }

    /**
     * 當開啟選單時記錄此 view ,方便下次關閉
     */
    private void onOpenMenu() {
        View view = this;
        while (true) {
            view = (View) view.getParent();
            if (view instanceof RecyclerView) {
                break;
            }
        }
        ((MyAdapter) ((RecyclerView) view).getAdapter()).holdOpenMenu(this);
        isOpen = true;
    }

    /**
     * 當觸控此 item 時,關閉上一次開啟的 item
     */
    private void closeOpenMenu() {
        if (!isOpen) {
            View view = this;
            while (true) {
                view = (View) view.getParent();
                if (view instanceof RecyclerView) {
                    break;
                }
            }
            ((MyAdapter) ((RecyclerView) view).getAdapter()).closeOpenMenu();
        }
    }

修改 onTouchEvent 方法:

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                closeOpenMenu();
                break;
            case MotionEvent.ACTION_UP:
                int scrollX = getScrollX();
                if (Math.abs(scrollX) > mMenuWidth / 2) {
                    this.smoothScrollTo(mMenuWidth, 0);
                    onOpenMenu();
                } else {
                    this.smoothScrollTo(0, 0);
                }
                return true;
        }
        return super.onTouchEvent(ev);
    }

顯然我們將已開啟的 item 記錄在介面卡中,在 Adapter 中新增記錄與關閉方法:

    private SlidingMenu mOpenMenu;

    public void holdOpenMenu(SlidingMenu slidingMenu) {
        mOpenMenu= slidingMenu;
    }

    public void closeOpenMenu() {
        if (mOpenMenu!= null && mOpenMenu.isOpen()) {
            mOpenMenu.closeMenu();
        }
    }

OK~ 非常簡單,看下效果,當觸控其他 item 時已開啟的 item 就會自動關閉啦:

這裡寫圖片描述

在實際使用時,一般需要監聽側滑選單的點選事件,此方法中的側滑選單是 item 的一部分,對其監聽也就非常簡單,同樣,修改選單文字也非常簡單。下面通過改變選單文字實現置頂/取消置頂功能。

改造介面卡,直接附上 MyAdapter 全部程式碼:

class MyAdapter extends RecyclerView.Adapter<MyAdapter.MyViewHolder> {


    private List<String> mData;
    private Context mContext;

    private SlidingMenu mOpenMenu;

    public void holdOpenMenu(SlidingMenu slidingMenu) {
        mOpenMenu = slidingMenu;
    }

    public void closeOpenMenu() {
        if (mOpenMenu != null && mOpenMenu.isOpen()) {
            mOpenMenu.closeMenu();
        }
    }

    MyAdapter(List<String> data, Context context) {
        mData = data;
        mContext = context;
    }

    @Override
    public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return new MyViewHolder(LayoutInflater.from(mContext).inflate(R.layout.item, parent, false));
    }

    @Override
    public void onBindViewHolder(final MyViewHolder holder, final int position) {
        holder.imageView.setBackgroundColor(ContextCompat.getColor(mContext, R.color.colorItem));
        holder.menuText.setText(mData.get(position));
        holder.menuText.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                closeOpenMenu();
                boolean top;
                if (holder.menuText.getText().toString().equals("置頂")) {
                    holder.menuText.setText("取消置頂");
                    holder.imageView.setBackgroundColor(ContextCompat.getColor(mContext, R.color.colorTopItem));
                    top = true;
                }else{
                    holder.menuText.setText("置頂");
                    holder.imageView.setBackgroundColor(ContextCompat.getColor(mContext, R.color.colorItem));
                    top = false;
                }
                if (mOnMenuClickListener != null) {
                    mOnMenuClickListener.onClick(position, top);
                }
            }
        });
    }

    public interface OnMenuClickListener {
        void onClick(int position,boolean top);
    }

    private OnMenuClickListener mOnMenuClickListener;

    public void setOnMenuClickListener(OnMenuClickListener onMenuClickListener) {
        this.mOnMenuClickListener = onMenuClickListener;
    }


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

    class MyViewHolder extends RecyclerView.ViewHolder {
        TextView menuText;
        ImageView imageView;

        MyViewHolder(View itemView) {
            super(itemView);
            menuText = (TextView) itemView.findViewById(R.id.menuText);
            imageView = (ImageView) itemView.findViewById(R.id.imageView);
        }
    }
}

其中 imageView 為 item 佈局中的 item 內容,menuText 為選單文字控制元件,當置頂時 item 顏色變為黃色,選單文字變為“取消置頂”。選單點選事件以介面形式公開,程式碼十分簡單,下面是 MainActivity :


public class MainActivity extends AppCompatActivity {

    private RecyclerView mRecyclerView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mRecyclerView = (RecyclerView) findViewById(R.id.recyclerView);
        init();
    }

    private void init() {
        final List<String> data = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            data.add("置頂");
        }
        MyAdapter myAdapter = new MyAdapter(data, this);
        myAdapter.setOnMenuClickListener(new MyAdapter.OnMenuClickListener() {
            @Override
            public void onClick(int position, boolean top) {
                data.set(position, top ? "取消置頂" : "置頂");
            }
        });
        mRecyclerView.setAdapter(myAdapter);
    }
}

實現效果:

這裡寫圖片描述

OK~ 成功彌補第三方庫“選單文字一旦確定將無法修改”這個致命缺陷了。有的場景下,我們不希望整個 item 側滑,比如:

這裡寫圖片描述

使用第三庫時很難實現這樣的需求,而此方法就非常容易了,只需要把底部控制元件置於 SlidingMenu 外部就可以了,以下是 item 佈局:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:layout_marginTop="2dp"
              android:orientation="vertical"
    >

    <com.xmwj.slidingmenu.SlidingMenu
        android:layout_width="wrap_content"
        android:layout_height="wrap_content">

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            >

            <LinearLayout
                android:id="@+id/content"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                >
                <ImageView
                    android:id="@+id/imageView"
                    android:layout_width="match_parent"
                    android:layout_height="70dp"
                    />
            </LinearLayout>

            <LinearLayout
                android:id="@+id/menu"
                android:layout_width="match_parent"
                android:layout_height="match_parent"
                android:orientation="horizontal"
                >
                <TextView
                    android:id="@+id/menuText"
                    android:layout_width="match_parent"
                    android:layout_height="match_parent"
                    android:text="置頂"
                    android:textColor="#fff"
                    android:gravity="center"
                    android:background="@color/colorAccent"
                    />
            </LinearLayout>

        </LinearLayout>

    </com.xmwj.slidingmenu.SlidingMenu>

    <TextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="item 底部備註 ..."
        android:background="@color/colorAccent"
        android:textColor="#fff"
        android:gravity="center"
        />


</LinearLayout>

輕鬆解決了 “選單文字一旦確定將無法修改“,“側滑時整個 item 都會滑動”這兩個缺陷,最後一個“無法自定義選單樣式”,第三方庫中高度集成了選單的生成方式,只能設定單一的文字和圖示,有時無法滿足需求,而此方法實現自定義選單樣式十分簡單,在 item 中直接編寫 menu 佈局即可。

更新(2017.09.07)

案例寫的十分簡陋,主要是想分享一下思路,另外這種方案跟 item 佈局完全耦合,不適合抽離成共用元件,所以並沒有去完善功能,比如評論提到的是否允許側滑、複用機制導致的檢視混亂等等。

而現在,我意識到當初的想法是多麼的狹隘,這種方案或許可以抽離成共用元件;有些功能是必需的,比如解決複用機制導致的檢視混亂,這不叫“完善”,而是“完成”,下面開始新增這些功能。

新增item點選事件

你可能會想到直接給 content 新增 setOnClickListener 方法,但這是不行的,content 作為 SlidingMenu 的子佈局,一旦為 content 設定點選監聽,SlidingMenu 將無法響應 ACTION_DOWN 事件,而在 ACTION_DOWN 中做了一個很重要的工作:關閉上次開啟選單的 item 。也就是說如果為 content 新增 setOnClickListener 方法,將導致無法自動關閉已開啟的選單。

既然 setOnClickListener 會攔截 ACTION_DOWN 事件,那我們可以在 onTouchEvent 中產生點選事件,改造 SlidingMenu 的 onTouchEvent 方法,使其產生點選事件並以介面形式公開,程式碼如下:

long downTime = 0;

@Override
public boolean onTouchEvent(MotionEvent ev) {
     switch (ev.getAction()) {
         case MotionEvent.ACTION_DOWN:
             downTime = System.currentTimeMillis();
             closeOpenMenu();
             break;
         case MotionEvent.ACTION_UP:
             int scrollX = getScrollX();
             if (System.currentTimeMillis() - downTime <= 100 && scrollX ==0) {
                 if (mCustomOnClickListener != null) {
                     mCustomOnClickListener.onClick();
                 }
                 return false;
             }
             if (Math.abs(scrollX) > mMenuWidth / 2) {
                 this.smoothScrollTo(mMenuWidth, 0);
                 onOpenMenu();
             } else {
                 this.smoothScrollTo(0, 0);
             }
             return true;
     }
     return super.onTouchEvent(ev)
}

interface CustomOnClickListener {
     void onClick();
}

private CustomOnClickListener mCustomOnClickListener;

void setCustomOnClickListener(CustomOnClickListener listener) {
     this.mCustomOnClickListener = listener;
}

改造 MyAdapter :

// 將原來的選單監聽器:
public interface OnMenuClickListener {
    void onClick(int position,boolean top);
}

private OnMenuClickListener mOnMenuClickListener;

public void setOnMenuClickListener(OnMenuClickListener onMenuClickListener) {
    this.mOnMenuClickListener = onMenuClickListener;
}

// 修改為內容&選單監聽器:
interface OnClickListener {
    void onMenuClick(int position, boolean top);

    void onContentClick(int position);
}

private OnClickListener mOnClickListener;

void setOnClickListener(OnClickListener onClickListener) {
    this.mOnClickListener = onClickListener;
}

然後在 onBindViewHolder 中新增 item 點選事件監聽即可:

holder.slidingMenu.setCustomOnClickListener(new SlidingMenu.CustomOnClickListener() {
    @Override
    public void onClick() {
        if (mOnClickListener != null) {
            mOnClickListener.onContentClick(position);
        }
    }
});

多指觸控將同時開啟多個item

感謝評論區的小夥伴提出的這個問題,首先展示一下”事故現場”:

這裡寫圖片描述

怎麼禁止同時開啟多個 item 呢?首先要明白所謂的“同時”並不可能真正同時,肯定有先後之分,我們可以像記錄已開啟的 item 一樣,在 Adapter 中記錄第一個開始滑動的 item ,而之後的 item 滑動前先判斷一下 Adapter 中是否有記錄 ,如果有正在滑動的 item 就禁止滑動,正在滑動 item 的記錄週期從 ACTION_DOWN 開始,至 ACTION_UP 結束。首先是 SlidingMenu 的 onTouchEvent 方法:

    @Override
    public boolean onTouchEvent(MotionEvent ev) {

        // 當有正在滑動的item且不是自身則禁止滑動
        if (getScrollingMenu() != null && getScrollingMenu() != this) {
            return false;
        }
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                downTime = System.currentTimeMillis();
                closeOpenMenu();
                setScrollingMenu(this);//記錄正在滑動item
                break;
            case MotionEvent.ACTION_UP:
                setScrollingMenu(null);//清空記錄
                int scrollX = getScrollX();
                if (System.currentTimeMillis() - downTime <= 100 && scrollX == 0) {
                    if (mCustomOnClickListener != null) {
                        mCustomOnClickListener.onClick();
                    }
                    return false;
                }
                if (Math.abs(scrollX) > mMenuWidth / 2) {
                    this.smoothScrollTo(mMenuWidth, 0);
                    onOpenMenu();
                } else {
                    this.smoothScrollTo(0, 0);
                }
                return false;
        }
        return super.onTouchEvent(ev);
    }

setScrollingMenu 和 getScrollingMenu 方法如下:


    public SlidingMenu getScrollingMenu() {
        return getAdapter().getScrollingMenu();
    }

    public void setScrollingMenu(SlidingMenu scrollingMenu) {
        getAdapter().setScrollingMenu(scrollingMenu);
    }

與上文中已開啟 item 一樣,需要記錄到 Adapter 中,由於在 SlidingMenu 中多次獲取 Adapter ,所以將其封裝成一個方法:

    private MyAdapter getAdapter() {
        View view = this;
        while (true) {
            view = (View) view.getParent();
            if (view instanceof RecyclerView) {
                break;
            }
        }
        return (MyAdapter) ((RecyclerView) view).getAdapter();
    }

然後在 MyAdapter 中新增對應欄位及其 set/get 方法即可,這基本上就解決了這個問題,在除錯過程中還發現一個問題:當上下滾動 Recycleview 時,有時無法響應 SlidingMenu 的 ACTION_UP 事件,從而無法及時清空正在滑動 item 記錄,暫且在 MainActivity 中解決這個問題:

        mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
            @Override
            public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
                super.onScrollStateChanged(recyclerView, newState);
                myAdapter.setScrollingMenu(null);
            }
        });

至此成功解決 。

另外在 QQ、微信上也有這種現象:

這裡寫圖片描述

這算是 BUG 嗎?對於 QQ、微信來說不算是 BUG,但對於本案例來說是。因為同時開啟多個選單後,再觸控未開啟 item ,QQ 、微信會自動關閉所有開啟的選單,而本案例無法實現,因為本案例中只記錄了上次開啟的 item ,所以只能禁止同時開啟多個 item 。

這是兩種不同的實現方案,一個是允許同時開啟多個側滑選單 ,並記錄所有已開啟的選單;一個是不允許同時開啟多個選單 ,只需記錄上次開啟的。而我認為後者較好,首先同時開啟在互動上是不符合使用者習慣的,另外後者不需要遍歷操作,效能上更優。

程式碼已同步更新到 github。

總結

Recycleview 側滑選單大多的實現思路是:通過自定義 Recycleview 或 Adapter 提供建立選單方法,然後內部再關聯到各個 item 改變其佈局,從而使 item 具有側滑功能,優點是使用簡單,但是不夠靈活,比如開始提到的三個侷限性。本文實現方法直接在 item 佈局中進行設定,使 item 具有側滑功能,實現過程及其簡單,易於理解,應該是最簡單的 Recycleview 側滑選單了,希望能給你帶來幫助。

嗯~重要的事情說三遍。

參考文章