效果圖
簡述
通過前面的文章已經(jīng)看出現(xiàn)在的Adapter
在功能上已經(jīng)比較強(qiáng)大了,并且已經(jīng)做好了一些隨時可被擴(kuò)展的準(zhǔn)備搔啊,這篇就在現(xiàn)有功能的基礎(chǔ)上增加滑動菜單的支持。
首先這一篇描述的功能都是針對非header和footer的缕题。
有了這些前提后我們開始思考绕辖,滑動菜單需要什么樣的表現(xiàn)形式以及需要哪些功能:
- 客戶端的使用上不能脓诡,我們只針對
Adapter
,不針對RecyclerView
媒役,不強(qiáng)制客戶端使用自定義控件總是好的 - 滑動的效果盡可能的向Ios的效果去靠祝谚,免得測試說為什么和Ios不一樣呢(誰讓Android這方面比較那啥呢)
- 允許item菜單在打開時,其他被打開菜單的item是否需要關(guān)閉菜單要有開關(guān)功能
- 要支持左菜單酣衷、右菜單(目前的實(shí)現(xiàn)上兩者不能同時存在)
- 菜單的個數(shù)要靈活交惯,對數(shù)量不做限制
- 菜單要有事件回調(diào)
- 其他菜單操作api
有了上述需求之后,我們開始著手開發(fā)穿仪,首先肯定少不了事件處理席爽,同時我們也不應(yīng)該去自定義RecyclerView
。那么接下來的操作啊片,也就是實(shí)現(xiàn)這個功能不可缺少的工具ViewDragHelper
出場只锻。這個是Android系統(tǒng)增加的針對簡化事件處理的工具類,這里不過多介紹钠龙,不了解的自行g(shù)oogle炬藤。
注:一定要確保上面提到的功能和知識點(diǎn)徹底明確后再接著往下讀
1. 菜單相關(guān)處理接口聲明
我們先定義操作接口御铃。
菜單包裝類
public class MenuItem {
//菜單布局
private int menuLayoutId;
//菜單方向
@MenuItem.EdgeTrackWhere
private int edgeTrack;
//菜單id
private int menuId;
@Retention(RetentionPolicy.SOURCE)
@IntDef({EdgeTrack.LEFT, EdgeTrack.RIGHT})
public @interface EdgeTrackWhere {}
/**
* 菜單打開方向
*/
public interface EdgeTrack{
int LEFT = 0;
int RIGHT = 1;
}
}
菜單創(chuàng)建接口
為了不同需求碴里,我們提供兩個方法,需要注意的是上真,如果兩個方法都有數(shù)據(jù)返回咬腋,則會進(jìn)行組合,所以建議根據(jù)場合不同睡互,選用其中一個就好
public interface ICreateMenus {
/**
* 創(chuàng)建多個菜單
* @param viewType 可以針對不同的item類型創(chuàng)建不同的菜單
* @return
*/
List<MenuItem> onCreateMultiMenuItem(int viewType);
/**
* 創(chuàng)建單個菜單
* @param viewType 可以針對不同的item類型創(chuàng)建不同的菜單
* @return
*/
MenuItem onCreateSingleMenuItem(int viewType);
}
菜單關(guān)閉接口以及配置接口
public interface ICloseMenus {
/**
* 關(guān)閉菜單
*/
void closeMenuItem();
/**
* 關(guān)閉其他打開菜單的item
*/
void closeOtherMenuItems();
/**
* 是否有其他打開菜單的item項(xiàng)(不包含當(dāng)前客戶端觸摸的item)
* @return
*/
boolean hasOpendMenuItems();
}
public interface IMenuSupport {
/**
* 是否關(guān)閉其他已經(jīng)打開menu的items
* @return
*/
boolean isCloseOtherItemsWhenThisWillOpen();
}
菜單點(diǎn)擊事件回調(diào)接口
public interface OnItemMenuClickListener {
/**
* 菜單點(diǎn)擊回調(diào)
* @param swipeItemView
* @param itemView 客戶端所創(chuàng)建的itemview
* @param menuView
* @param position 列表中item所在索引(數(shù)據(jù)區(qū)域)
* @param menuId 客戶端創(chuàng)建item時指定的id
*/
void onMenuClick(SwipeLayout swipeItemView, View itemView, View menuView, int position, int menuId);
}
2. 開始擴(kuò)展Adapter
操作接口已經(jīng)定義完畢根竿,開始擴(kuò)展。我們?nèi)∑涿麨?code>SwipeAdapter就珠,讓繼承自BaseAdapter
寇壳,這樣就擁有上幾篇介紹的所有功能。
這樣先考慮一下在這個Adapter
我們需要做什么妻怎,目測需要著手處理以下兩方面的內(nèi)容壳炎,其他的都不需要:
a. viewHolder的創(chuàng)建我們需要去做,因?yàn)樵瓉淼膇tem需要加菜單逼侦,所以item要動
b. 點(diǎn)擊事件需要匿辩,菜單也需要點(diǎn)擊事件,同時如果我們點(diǎn)擊的這個item是菜單打開的狀態(tài)榛丢,那么是需要關(guān)閉的铲球,所以點(diǎn)擊時間需要復(fù)寫
這樣一來我們就有了入手點(diǎn),從復(fù)寫public BaseViewHolder onCreateHolder(ViewGroup parent, int viewType)
方法開始(看過之前文章的話會知道這個方法是怎么來的)晰赞。
創(chuàng)建viewHolder
每個item都需要添加菜單稼病,我們需要的效果是原始item在滑動的過程中菜單慢慢顯現(xiàn)出來选侨,本身菜單沒有動,這樣原始item完全覆蓋在菜單的上面然走,所以我們這里用一個FrameLayout
容器來包裹菜單和原始item控件侵俗,這個繼承自FrameLayout
的控件我們命名為SwipeLayout
,我們將用它作為新的item來創(chuàng)建一個viewHolder丰刊。
因此在onCreateHolder()
里我們需要處理的內(nèi)容是:創(chuàng)建新的item控件隘谣、創(chuàng)建菜單以及菜單點(diǎn)擊事件處理。SwipeLayout
中的邏輯我們之后再說啄巧。
根據(jù)上面的描述看下面onCreateHolder()
的邏輯:
@Override
public BaseViewHolder onCreateHolder(ViewGroup parent, int viewType) {
View itemView = inflater.inflate(viewType, parent, false);
MenuItem mi = this.onCreateSingleMenuItem(viewType);
List<MenuItem> mm = this.onCreateMultiMenuItem(viewType);
//客戶端沒有設(shè)置菜單支持
if (null == mi && (null == mm || mm.isEmpty())) {
return new BaseViewHolder(itemView);
}
List<MenuItem> menuItems = new ArrayList<>();
if (null != mi) {
menuItems.add(mi);
}
if (null != mm && !mm.isEmpty()) {
menuItems.addAll(mm);
}
final SwipeLayout swipeLayout = new SwipeLayout(context);
swipeLayout.setUpView(parent, itemView, menuItems);
swipeLayout.setIsCloseOtherItemsWhenThisWillOpen(this.isCloseOtherItemsWhenThisWillOpen());
itemView.setClickable(true);
BaseViewHolder holder = new BaseViewHolder(swipeLayout, itemView);
this.initMenusListener(holder);
return holder;
}
對于上面這部分代碼的處理寻歧,需要做兩點(diǎn)補(bǔ)充:
a. itemView.setClickable(true);
這里的itemView指的是客戶端所創(chuàng)建的最原始的那個view,設(shè)為可點(diǎn)擊是因?yàn)檫@個itemView我們一定要能消耗事件秩仆,不然該item就不能捕捉點(diǎn)擊事件码泛。
b. 對于有菜單的viewHolder我們用了new BaseViewHolder(swipeLayout, itemView);
這樣一個構(gòu)造方法,為什么這樣用呢澄耍,因?yàn)樯厦娴谝稽c(diǎn)也說了我們的點(diǎn)擊事件是加載客戶端創(chuàng)建的最原始的item上的噪珊,而不是新創(chuàng)建的SwipeLayout item位衩,所以我們需要另外一個處理事件的view參數(shù)肴捉,這樣一來我們必須更改兩處地方:
BaseViewHolder
的構(gòu)造器需要這樣改造
//事件(解決滑動時事件問題)
public View eventItemView;
public BaseViewHolder(View itemView) {
super(itemView);
this.eventItemView = itemView;
}
public BaseViewHolder(View itemView, View eventItemView) {
super(itemView);
this.eventItemView = eventItemView;
}
HeaderFooterAdapter
中initItemListener
的事件處理中處理點(diǎn)擊事件的不再是itemView
,而是eventItemView
,偽代碼如下:
protected void initItemListener(final BaseViewHolder holder/*, final int viewType*/){
holder.eventItemView.setOnClickListener(xxxx);
holder.eventItemView.setOnLongClickListener(xxxx);
}
</p>
菜單事件處理
下面只需要知道菜單的點(diǎn)擊事件是怎么添加的就可以了冤吨,菜單與item的關(guān)聯(lián)關(guān)系List<Pair<View, MenuItem>> menus = swipeLayout.getMenus();
將會放到SwipeLayout
中進(jìn)行描述选酗。
/**
* 添加菜單點(diǎn)擊監(jiān)聽器
* @param holder
*/
private void initMenusListener(final BaseViewHolder holder) {
if (! (holder.itemView instanceof SwipeLayout)) {
return;
}
final SwipeLayout swipeLayout = (SwipeLayout) holder.itemView;
List<Pair<View, MenuItem>> menus = swipeLayout.getMenus();
if (null == menus || menus.isEmpty()) {
return;
}
if (null == this.onItemMenuClickListener) {
return;
}
for (final Pair<View, MenuItem> pair:menus) {
pair.first.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
int hAll = getHeaderViewCount() + getSysHeaderViewCount();
final int position = holder.getAdapterPosition() - hAll;
onItemMenuClickListener.onMenuClick(swipeLayout, holder.eventItemView, v, position, pair.second.getMenuId());
}
});
}
}
同時需要復(fù)寫父類中的事件響應(yīng)處理阵难,只有在當(dāng)前item的菜單是關(guān)閉的情況下才可以去響應(yīng)事件,代碼就不貼出來了芒填。
3. SwipeLayout
在上面的描述里面已經(jīng)知道這個SwipeLayout
就是新的item view了(承載原始item view和菜單)呜叫,同時滑動的操作也是作用在它上面(少不了對事件的處理)。
另外之前也說了我們這里用ViewDragHelper
來處理事件殿衰。
那么SwipeLayout
大概需要完成下面這些工作:
添加菜單以及原始item view并關(guān)聯(lián)
在SwipeAdapter
Adapter的onCreateHolder
中朱庆,我們調(diào)用了swipeLayout.setUpView(parent, itemView, menuItems);
進(jìn)行SwipeLayout
的初始化
這里對菜單的操作做了簡單的優(yōu)化,前面說過菜單支持多個闷祥,那么這里菜單控件的添加操作是這樣處理的娱颊,如果是只有一個菜單那么直接添加,如果是多個菜單蜀踏,那么在菜單外層包裝了一個線性容器维蒙。
菜單處理的這部分代碼比較多,太占篇幅且沒什么技術(shù)含量果覆,所以就不都貼出來了颅痊,只是貼下流程吧:
private List<Pair<View, MenuItem>> leftMenus;
private List<Pair<View, MenuItem>> rightMenus;
public void setUpView(ViewGroup viewGroup, View itemView, List<MenuItem> menuItems) {
this.viewGroup = viewGroup;
this.itemView = itemView;
if (null == menuItems || menuItems.isEmpty()) {
return;
}
//省略菜單處理邏輯
//1. 左右菜單分組
//2. 菜單添加
//3. 原始item view添加
....
}
初始化
ViewDragHelper
同樣在初始化方法中進(jìn)行初始化,每一個SwipeLayout
都需要處理手勢操作局待,所以必須關(guān)聯(lián)ViewDragHelper
斑响,同時針對左右菜單做了ViewDragHelper
邊界處理
public void setUpView(ViewGroup viewGroup, View itemView, List<MenuItem> menuItems) {
//省略其他代碼
...
delegate = new SwipeDragHelperDelegate(this);
this.helper = ViewDragHelper.create(this, 1.0f, delegate);
delegate.init(helper);
if (this.EdgeTracking == MenuItem.EdgeTrack.LEFT) {
helper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);
}else if (this.EdgeTracking == MenuItem.EdgeTrack.RIGHT) {
helper.setEdgeTrackingEnabled(ViewDragHelper.EDGE_RIGHT);
}
}
事件處理
我們需要將SwipeLayout
的事件委托給ViewDragHelper
進(jìn)行處理菱属,這里的邏輯是這樣的:
- 在手指按下(ACTION_DOWN操作)的時候,之前有說過
setIsCloseOtherItemsWhenThisWillOpen()
這樣一個接口方法舰罚,就是說如果我們希望這時候關(guān)閉掉其他的打開菜單的item的話纽门,那么這個事件中我們就需要做關(guān)閉的操作,注:這個事件不做攔截
营罢。部分代碼如下:
if (isCloseOtherItemsWhenThisWillOpen) {
if (MotionEvent.ACTION_DOWN == action) {
if (hasOpendMenuItems()) {
closeOtherMenuItems();
}
}
}
- 在菜單打開的過程中(ACTION_MOVE操作)我們不需要攔截事件赏陵,這些事件需要交給
ViewDragHelper
處理 - 在手指抬起(ACTION_UP操作)的時候,這里比較復(fù)雜饲漾,我們期望這樣的效果:
a. 菜單在關(guān)閉的時候蝙搔,希望能正常響應(yīng)item的其他事件(點(diǎn)擊、長按等)考传,包括子view
b. 菜單在打開的時候吃型,如果點(diǎn)擊的是原始item以及其子view,希望能關(guān)閉菜單僚楞,就算原始item中有button等能消耗事件的控件也要能關(guān)閉菜單勤晚,并且這樣能消耗事件的控件不能讓其響應(yīng)事件
c. 同樣菜單在打開的時候,如果點(diǎn)擊的是菜單項(xiàng)泉褐,該菜單一定能夠響應(yīng)事件
d. 我們還需要知道ACTION_UP這個點(diǎn)作用在哪個區(qū)域
因?yàn)槲覀冎肋@里事件處理的控件是SwipeLayout
,子view能不能響應(yīng)事件一方面取決于自身是否有能力消耗事件赐写,另一方面取決于父控件是否攔截了控件。
經(jīng)過上面的描述兴枯,SwipeLayout
對于事件的處理邏輯就很清晰了:
private boolean isCloseOtherItemsWhenThisWillOpen = false;
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
final int action = MotionEventCompat.getActionMasked(ev);
switch (action) {
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
helper.cancel();
RectF f = calcViewScreenLocation(itemView);
boolean isIn = f.contains(ev.getRawX(), ev.getRawY());
if (isIn && delegate.getMenuStatus() == SwipeDragHelperDelegate.MenuStatus.OPEN) {
delegate.closeMenuItem();
return true;
}
return false;
}
if (isCloseOtherItemsWhenThisWillOpen) {
if (MotionEvent.ACTION_DOWN == action) {
if (hasOpendMenuItems()) {
closeOtherMenuItems();
}
}
}
return helper.shouldInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
helper.processTouchEvent(event);
return true;
}
public static RectF calcViewScreenLocation(View view) {
int[] location = new int[2];
view.getLocationOnScreen(location);
return new RectF(location[0], location[1], location[0] + view.getWidth(), location[1] + view.getHeight());
}
4. SwipeDragHelperDelegate
我們將事件委托給ViewDragHelper
后血淌,通過回調(diào)處理view的操作矩欠。
我假設(shè)在讀的你已經(jīng)知道這個類怎么用(還不太清楚的可自行g(shù)oogle)财剖。
照樣先描述下我們期望的效果:
滑動控件設(shè)置
我們期望SwipeLayout
中原始item view進(jìn)行滑動而不是菜單控件,因此復(fù)寫下面的方法就有了這樣的邏輯癌淮,其中swipeLayout.getItemView();
獲取的就是客戶端創(chuàng)建最原始item view
@Override
public boolean tryCaptureView(View child, int pointerId) {
final View itemView = swipeLayout.getItemView();
if (null != itemView && itemView == child) {
return true;
}
return false;
}
滑動邊界
我們需要通過滑動操作處理控件的真實(shí)行為躺坟,以保證在我們預(yù)期的范圍內(nèi)。比如我們拿右菜單舉例乳蓄,能滑動的最大距離就是菜單的寬度咪橙,縱向是不可以滑動的。
@Override
public int clampViewPositionHorizontal(View child, int left, int dx) {
if (swipeLayout.getEdgeTracking() == MenuItem.EdgeTrack.RIGHT) {
int menuWidth = swipeLayout.getRightMenuWidth();
if (left > 0 && dx > 0) {
return 0;
}
if (left < -menuWidth && dx < 0) {
return -menuWidth;
}
}
return left;
}
滑動行為
可滑動的邊界設(shè)置了以后虚倒,我們現(xiàn)在運(yùn)行demo會發(fā)現(xiàn)在可滑動的邊界內(nèi)滑到哪就停到哪美侦,這顯然也不是我們期望的,我們對這里的效果做如下定義:
- 當(dāng)手指松開時魂奥,如果滑動之前菜單是關(guān)閉的菠剩,那么這時候如果滑動的距離超過了菜單寬度的20%,則直接打開菜單耻煤,否則認(rèn)為用戶不想打開菜單具壮,則關(guān)閉菜單
- 當(dāng)手指松開時准颓,如果滑動之前菜單是打開的,那么這時候直接關(guān)閉菜單
//打開菜單所滑動的邊界百分比,超過將打開菜單,否在則不打開
private float openMenuBoundaryPercent = 0.2f;
@Override
public void onViewReleased(View releasedChild, float xvel, float yvel) {
final View itemView = swipeLayout.getItemView();
if (releasedChild != itemView) {
return;
}
final int et = swipeLayout.getEdgeTracking();
final int l = Math.abs(itemView.getLeft());
final int menuWidth;
//獲取菜單寬度
if (et == MenuItem.EdgeTrack.LEFT) {
menuWidth = swipeLayout.getLeftMenuWidth();
}else if (et == MenuItem.EdgeTrack.RIGHT){
menuWidth = swipeLayout.getRightMenuWidth();
}else {
menuWidth = 0;
}
final float min = Math.abs(menuWidth * openMenuBoundaryPercent);
final int left;
//計(jì)算偏移量
if (l < min || (MenuStatus.OPEN == this.menuBoundaryStatusOfBeenTo && l < menuWidth)) {
left = 0;
} else {
if (et == MenuItem.EdgeTrack.LEFT) {
left = +1 * menuWidth;
}else if (et == MenuItem.EdgeTrack.RIGHT) {
left = -1 * menuWidth;
}else {
left = 0;
}
}
this.helper.settleCapturedViewAt(left, 0);
this.swipeLayout.invalidate();
}
這里在補(bǔ)充一句棺妓,在上面的代碼里用了this.helper.settleCapturedViewAt(left, 0); this.swipeLayout.invalidate();
進(jìn)行位置的設(shè)置攘已,內(nèi)部其實(shí)用的是Scroller
,所以需要在SwipeLayout
需要復(fù)寫以下方法配合使用:
@Override
public void computeScroll() {
super.computeScroll();
if (helper.continueSettling(true)) {
invalidate();
}
}
根據(jù)上面的描述以及代碼中都知道怜跑,在滑動的時候是有邊界條件(這里指的是左右邊界)限制的样勃,也就是說在滑動時我們要知道最近到達(dá)過哪個邊界。
@Override
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
super.onViewPositionChanged(changedView, left, top, dx, dy);
this.updateMenuStatus(left);
}
private void updateMenuStatus(int left) {
final int et = swipeLayout.getEdgeTracking();
int menuWidth = 0;
if(MenuItem.EdgeTrack.LEFT == et) {
menuWidth = swipeLayout.getLeftMenuWidth();
}else if (MenuItem.EdgeTrack.RIGHT == et) {
menuWidth = swipeLayout.getRightMenuWidth();
}
//記錄拖動時到達(dá)過的邊界狀態(tài)
if (left == 0) {
this.menuBoundaryStatusOfBeenTo = MenuStatus.CLOSED; }else if (Math.abs(left) >= menuWidth) {
this.menuBoundaryStatusOfBeenTo = MenuStatus.OPEN;
}
//記錄打開關(guān)閉菜單項(xiàng)的item
if (left == 0) {
this.openView.remove(this.swipeLayout);
}else if (0 != menuWidth && left == menuWidth) {
if (!openView.contains(swipeLayout)) {
openView.add(swipeLayout);
}
}
}
//記錄拖動之前達(dá)到過的狀態(tài)(只要到達(dá)過菜單開的狀態(tài)性芬,此時再次移動將會關(guān)閉菜單)
@MenuBoundaryStatusOfBeenToWhereprivate int menuBoundaryStatusOfBeenTo = MenuStatus.CLOSED;
@Retention(RetentionPolicy.SOURCE)
@IntDef({MenuStatus.OPEN, MenuStatus.CLOSED})
private @interface MenuBoundaryStatusOfBeenToWhere {}
/**
* 菜單狀態(tài)
*/
public interface MenuStatus{
int CLOSED = -1;
int DRAGING = 0;
int OPEN = 1;
}
滑動區(qū)域
在有可消耗事件的view存在時彤灶,我們的滑動效果就失效了,這時候鑒于菜單只定義了橫向滑動批旺,所以我們復(fù)寫下面這個方法來限定可拖動的區(qū)域(至于為什么請自行g(shù)oogle):
@Override
public int getViewHorizontalDragRange(View child) {
return swipeLayout.getItemView() == child ? child.getWidth() : 0;
}
滑動菜單的支持到這里就介紹完了幌陕,寫的是云里霧里的,有些地方應(yīng)該是比較模糊汽煮,只看的話估計(jì)也很難全面了解搏熄,畢竟這個功能需要處理的細(xì)節(jié)很多,所以首先一定要知道所要實(shí)現(xiàn)的效果是什么暇赤,然后再讀心例。
最后還是移駕到源碼中,應(yīng)該一看就懂了鞋囊。