動手打造史上最簡單的 Recycleview 側滑選單
本文已授權微信公眾號:鴻洋(hongyangAndroid)原創首發。
在實現 Recycleview 側滑選單時起初使用了開源庫 SwipeRecyclerView ,此庫功能廣泛,但無法滿足個人需求,這是因為此庫中存在以下侷限性:
- 選單文字一旦確定將無法修改
- 側滑時整個 item 都會滑動
- 無法自定義選單樣式
只能自己實現了,查閱資料後發現,較多通過 DragHelper 實現的,它是一個手勢滑動輔助工具,使 item 可以滑動,然後…… , 等等!既然目的是讓 item 可以滑動,那為什麼不直接在 item 佈局中使用 HorizontalScrollView 呢?參考鴻神的
首先是自定義 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 側滑選單了,希望能給你帶來幫助。
嗯~重要的事情說三遍。