RecyclerView-為Adapter增加滑動菜單支持(第4篇)

效果圖

滑動菜單

簡述

通過前面的文章已經(jīng)看出現(xiàn)在的Adapter在功能上已經(jīng)比較強大了,并且已經(jīng)做好了一些隨時可被擴展的準(zhǔn)備,這篇就在現(xiàn)有功能的基礎(chǔ)上增加滑動菜單的支持。
首先這一篇描述的功能都是針對非header和footer的。

有了這些前提后我們開始思考,滑動菜單需要什么樣的表現(xiàn)形式以及需要哪些功能:

  1. 客戶端的使用上不能,我們只針對Adapter,不針對RecyclerView,不強制客戶端使用自定義控件總是好的
  2. 滑動的效果盡可能的向Ios的效果去靠,免得測試說為什么和Ios不一樣呢(誰讓Android這方面比較那啥呢)
  3. 允許item菜單在打開時,其他被打開菜單的item是否需要關(guān)閉菜單要有開關(guān)功能
  4. 要支持左菜單、右菜單(目前的實現(xiàn)上兩者不能同時存在)
  5. 菜單的個數(shù)要靈活,對數(shù)量不做限制
  6. 菜單要有事件回調(diào)
  7. 其他菜單操作api

有了上述需求之后,我們開始著手開發(fā),首先肯定少不了事件處理,同時我們也不應(yīng)該去自定義RecyclerView。那么接下來的操作,也就是實現(xiàn)這個功能不可缺少的工具ViewDragHelper出場。這個是Android系統(tǒng)增加的針對簡化事件處理的工具類,這里不過多介紹,不了解的自行g(shù)oogle。

注:一定要確保上面提到的功能和知識點徹底明確后再接著往下讀

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項(不包含當(dāng)前客戶端觸摸的item)
     * @return
     */
    boolean hasOpendMenuItems();
}
public interface IMenuSupport {
    /**
     * 是否關(guān)閉其他已經(jīng)打開menu的items
     * @return
     */
    boolean isCloseOtherItemsWhenThisWillOpen();
}

菜單點擊事件回調(diào)接口

public interface OnItemMenuClickListener {
    /**
     * 菜單點擊回調(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. 開始擴展Adapter

操作接口已經(jīng)定義完畢,開始擴展。我們?nèi)∑涿麨?code>SwipeAdapter,讓繼承自BaseAdapter,這樣就擁有上幾篇介紹的所有功能。
這樣先考慮一下在這個Adapter我們需要做什么,目測需要著手處理以下兩方面的內(nèi)容,其他的都不需要:
a. viewHolder的創(chuàng)建我們需要去做,因為原來的item需要加菜單,所以item要動
b. 點擊事件需要,菜單也需要點擊事件,同時如果我們點擊的這個item是菜單打開的狀態(tài),那么是需要關(guān)閉的,所以點擊時間需要復(fù)寫

這樣一來我們就有了入手點,從復(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)建菜單以及菜單點擊事件處理。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;
}

對于上面這部分代碼的處理,需要做兩點補充:
a. itemView.setClickable(true); 這里的itemView指的是客戶端所創(chuàng)建的最原始的那個view,設(shè)為可點擊是因為這個itemView我們一定要能消耗事件,不然該item就不能捕捉點擊事件。
b. 對于有菜單的viewHolder我們用了new BaseViewHolder(swipeLayout, itemView);這樣一個構(gòu)造方法,為什么這樣用呢,因為上面第一點也說了我們的點擊事件是加載客戶端創(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;
}

HeaderFooterAdapterinitItemListener的事件處理中處理點擊事件的不再是itemView,而是eventItemView,偽代碼如下:

protected void initItemListener(final BaseViewHolder holder/*, final int viewType*/){
       holder.eventItemView.setOnClickListener(xxxx);
       holder.eventItemView.setOnLongClickListener(xxxx);
}

</p>

菜單事件處理

下面只需要知道菜單的點擊事件是怎么添加的就可以了,菜單與item的關(guān)聯(lián)關(guān)系List<Pair<View, MenuItem>> menus = swipeLayout.getMenus();將會放到SwipeLayout中進(jì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)

SwipeAdapterAdapter的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)行處理,這里的邏輯是這樣的:

  1. 在手指按下(ACTION_DOWN操作)的時候,之前有說過setIsCloseOtherItemsWhenThisWillOpen()這樣一個接口方法,就是說如果我們希望這時候關(guān)閉掉其他的打開菜單的item的話,那么這個事件中我們就需要做關(guān)閉的操作,注:這個事件不做攔截。部分代碼如下:
if (isCloseOtherItemsWhenThisWillOpen) {
    if (MotionEvent.ACTION_DOWN == action) {
        if (hasOpendMenuItems()) {
            closeOtherMenuItems();
        }
    }
}
  1. 在菜單打開的過程中(ACTION_MOVE操作)我們不需要攔截事件,這些事件需要交給ViewDragHelper處理
  2. 在手指抬起(ACTION_UP操作)的時候,這里比較復(fù)雜,我們期望這樣的效果:
a. 菜單在關(guān)閉的時候,希望能正常響應(yīng)item的其他事件(點擊、長按等),包括子view
b. 菜單在打開的時候,如果點擊的是原始item以及其子view,希望能關(guān)閉菜單,就算原始item中有button等能消耗事件的控件也要能關(guān)閉菜單,并且這樣能消耗事件的控件不能讓其響應(yīng)事件
c. 同樣菜單在打開的時候,如果點擊的是菜單項,該菜單一定能夠響應(yīng)事件
d. 我們還需要知道ACTION_UP這個點作用在哪個區(qū)域

因為我們知道這里事件處理的控件是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;
}

滑動邊界

我們需要通過滑動操作處理控件的真實行為,以保證在我們預(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)在運行demo會發(fā)現(xiàn)在可滑動的邊界內(nèi)滑到哪就停到哪,這顯然也不是我們期望的,我們對這里的效果做如下定義:

  1. 當(dāng)手指松開時,如果滑動之前菜單是關(guān)閉的,那么這時候如果滑動的距離超過了菜單寬度的20%,則直接打開菜單,否則認(rèn)為用戶不想打開菜單,則關(guān)閉菜單
  2. 當(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;
    //計算偏移量
    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();
}

這里在補充一句,在上面的代碼里用了this.helper.settleCapturedViewAt(left, 0); this.swipeLayout.invalidate();進(jìn)行位置的設(shè)置,內(nèi)部其實用的是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)閉菜單項的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)該是比較模糊,只看的話估計也很難全面了解,畢竟這個功能需要處理的細(xì)節(jié)很多,所以首先一定要知道所要實現(xiàn)的效果是什么,然后再讀。
最后還是移駕到源碼中,應(yīng)該一看就懂了。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容