RecyclerView-为Adapter增加滑动菜单支持(第4篇)

效果图

滑动菜单

简述

通过前面的文章已经看出现在的Adapter在功能上已经比较强大了,并且已经做好了一些随时可被扩展的准备,这篇就在现有功能的基础上增加滑动菜单的支持。
首先这一篇描述的功能都是针对非header和footer的。

有了这些前提后我们开始思考,滑动菜单需要什么样的表现形式以及需要哪些功能:

  1. 客户端的使用上不能,我们只针对Adapter,不针对RecyclerView,不强制客户端使用自定义控件总是好的
  2. 滑动的效果尽可能的向Ios的效果去靠,免得测试说为什么和Ios不一样呢(谁让Android这方面比较那啥呢)
  3. 允许item菜单在打开时,其他被打开菜单的item是否需要关闭菜单要有开关功能
  4. 要支持左菜单、右菜单(目前的实现上两者不能同时存在)
  5. 菜单的个数要灵活,对数量不做限制
  6. 菜单要有事件回调
  7. 其他菜单操作api

有了上述需求之后,我们开始着手开发,首先肯定少不了事件处理,同时我们也不应该去自定义RecyclerView。那么接下来的操作,也就是实现这个功能不可缺少的工具ViewDragHelper出场。这个是Android系统增加的针对简化事件处理的工具类,这里不过多介绍,不了解的自行google。

注:一定要确保上面提到的功能和知识点彻底明确后再接着往下读

1. 菜单相关处理接口声明

我们先定义操作接口。

菜单包装类

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;
    }
}

菜单创建接口

为了不同需求,我们提供两个方法,需要注意的是,如果两个方法都有数据返回,则会进行组合,所以建议根据场合不同,选用其中一个就好

public interface ICreateMenus {
    /**
     * 创建多个菜单
     * @param viewType  可以针对不同的item类型创建不同的菜单
     * @return
     */
    List<MenuItem> onCreateMultiMenuItem(int viewType);
    /**
     * 创建单个菜单
     * @param viewType  可以针对不同的item类型创建不同的菜单
     * @return
     */
    MenuItem onCreateSingleMenuItem(int viewType);
}

菜单关闭接口以及配置接口

public interface ICloseMenus {
    /**
     * 关闭菜单
     */
    void closeMenuItem();
    /**
     * 关闭其他打开菜单的item
     */
    void closeOtherMenuItems();
    /**
     * 是否有其他打开菜单的item项(不包含当前客户端触摸的item)
     * @return
     */
    boolean hasOpendMenuItems();
}
public interface IMenuSupport {
    /**
     * 是否关闭其他已经打开menu的items
     * @return
     */
    boolean isCloseOtherItemsWhenThisWillOpen();
}

菜单点击事件回调接口

public interface OnItemMenuClickListener {
    /**
     * 菜单点击回调
     * @param swipeItemView
     * @param itemView  客户端所创建的itemview
     * @param menuView
     * @param position  列表中item所在索引(数据区域)
     * @param menuId    客户端创建item时指定的id
     */
    void onMenuClick(SwipeLayout swipeItemView, View itemView, View menuView, int position, int menuId);
}

2. 开始扩展Adapter

操作接口已经定义完毕,开始扩展。我们取其名为SwipeAdapter,让继承自BaseAdapter,这样就拥有上几篇介绍的所有功能。
这样先考虑一下在这个Adapter我们需要做什么,目测需要着手处理以下两方面的内容,其他的都不需要:
a. viewHolder的创建我们需要去做,因为原来的item需要加菜单,所以item要动
b. 点击事件需要,菜单也需要点击事件,同时如果我们点击的这个item是菜单打开的状态,那么是需要关闭的,所以点击时间需要复写

这样一来我们就有了入手点,从复写public BaseViewHolder onCreateHolder(ViewGroup parent, int viewType)方法开始(看过之前文章的话会知道这个方法是怎么来的)。

创建viewHolder

每个item都需要添加菜单,我们需要的效果是原始item在滑动的过程中菜单慢慢显现出来,本身菜单没有动,这样原始item完全覆盖在菜单的上面,所以我们这里用一个FrameLayout容器来包裹菜单和原始item控件,这个继承自FrameLayout的控件我们命名为SwipeLayout,我们将用它作为新的item来创建一个viewHolder。
因此在onCreateHolder()里我们需要处理的内容是:创建新的item控件、创建菜单以及菜单点击事件处理。SwipeLayout中的逻辑我们之后再说。
根据上面的描述看下面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);
    //客户端没有设置菜单支持
    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指的是客户端所创建的最原始的那个view,设为可点击是因为这个itemView我们一定要能消耗事件,不然该item就不能捕捉点击事件。
b. 对于有菜单的viewHolder我们用了new BaseViewHolder(swipeLayout, itemView);这样一个构造方法,为什么这样用呢,因为上面第一点也说了我们的点击事件是加载客户端创建的最原始的item上的,而不是新创建的SwipeLayout item,所以我们需要另外一个处理事件的view参数,这样一来我们必须更改两处地方:
BaseViewHolder的构造器需要这样改造

//事件(解决滑动时事件问题)
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的关联关系List<Pair<View, MenuItem>> menus = swipeLayout.getMenus();将会放到SwipeLayout中进行描述。

/**
 * 添加菜单点击监听器
 * @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());
            }
        });
    }
}

同时需要复写父类中的事件响应处理,只有在当前item的菜单是关闭的情况下才可以去响应事件,代码就不贴出来了。

3. SwipeLayout

在上面的描述里面已经知道这个SwipeLayout就是新的item view了(承载原始item view和菜单),同时滑动的操作也是作用在它上面(少不了对事件的处理)。
另外之前也说了我们这里用ViewDragHelper来处理事件。
那么SwipeLayout大概需要完成下面这些工作:

添加菜单以及原始item view并关联

SwipeAdapterAdapter的onCreateHolder中,我们调用了swipeLayout.setUpView(parent, itemView, menuItems);
进行SwipeLayout的初始化
这里对菜单的操作做了简单的优化,前面说过菜单支持多个,那么这里菜单控件的添加操作是这样处理的,如果是只有一个菜单那么直接添加,如果是多个菜单,那么在菜单外层包装了一个线性容器。
菜单处理的这部分代码比较多,太占篇幅且没什么技术含量,所以就不都贴出来了,只是贴下流程吧:

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

同样在初始化方法中进行初始化,每一个SwipeLayout都需要处理手势操作,所以必须关联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进行处理,这里的逻辑是这样的:

  1. 在手指按下(ACTION_DOWN操作)的时候,之前有说过setIsCloseOtherItemsWhenThisWillOpen()这样一个接口方法,就是说如果我们希望这时候关闭掉其他的打开菜单的item的话,那么这个事件中我们就需要做关闭的操作,注:这个事件不做拦截。部分代码如下:
if (isCloseOtherItemsWhenThisWillOpen) {
    if (MotionEvent.ACTION_DOWN == action) {
        if (hasOpendMenuItems()) {
            closeOtherMenuItems();
        }
    }
}
  1. 在菜单打开的过程中(ACTION_MOVE操作)我们不需要拦截事件,这些事件需要交给ViewDragHelper处理
  2. 在手指抬起(ACTION_UP操作)的时候,这里比较复杂,我们期望这样的效果:
a. 菜单在关闭的时候,希望能正常响应item的其他事件(点击、长按等),包括子view
b. 菜单在打开的时候,如果点击的是原始item以及其子view,希望能关闭菜单,就算原始item中有button等能消耗事件的控件也要能关闭菜单,并且这样能消耗事件的控件不能让其响应事件
c. 同样菜单在打开的时候,如果点击的是菜单项,该菜单一定能够响应事件
d. 我们还需要知道ACTION_UP这个点作用在哪个区域

因为我们知道这里事件处理的控件是SwipeLayout,子view能不能响应事件一方面取决于自身是否有能力消耗事件,另一方面取决于父控件是否拦截了控件。
经过上面的描述,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后,通过回调处理view的操作。
我假设在读的你已经知道这个类怎么用(还不太清楚的可自行google)。
照样先描述下我们期望的效果:

滑动控件设置

我们期望SwipeLayout中原始item view进行滑动而不是菜单控件,因此复写下面的方法就有了这样的逻辑,其中swipeLayout.getItemView();获取的就是客户端创建最原始item view

@Override
public boolean tryCaptureView(View child, int pointerId) {
    final View itemView = swipeLayout.getItemView();
    if (null != itemView && itemView == child) {
        return true;
    }
    return false;
}

滑动边界

我们需要通过滑动操作处理控件的真实行为,以保证在我们预期的范围内。比如我们拿右菜单举例,能滑动的最大距离就是菜单的宽度,纵向是不可以滑动的。

@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;
}

滑动行为

可滑动的边界设置了以后,我们现在运行demo会发现在可滑动的边界内滑到哪就停到哪,这显然也不是我们期望的,我们对这里的效果做如下定义:

  1. 当手指松开时,如果滑动之前菜单是关闭的,那么这时候如果滑动的距离超过了菜单宽度的20%,则直接打开菜单,否则认为用户不想打开菜单,则关闭菜单
  2. 当手指松开时,如果滑动之前菜单是打开的,那么这时候直接关闭菜单
//打开菜单所滑动的边界百分比,超过将打开菜单,否在则不打开
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();进行位置的设置,内部其实用的是Scroller,所以需要在SwipeLayout需要复写以下方法配合使用:

@Override
public void computeScroll() {
    super.computeScroll();
    if (helper.continueSettling(true)) {
        invalidate();
    }
}

根据上面的描述以及代码中都知道,在滑动的时候是有边界条件(这里指的是左右边界)限制的,也就是说在滑动时我们要知道最近到达过哪个边界。

@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();
    }
    //记录拖动时到达过的边界状态
    if (left == 0) {
        this.menuBoundaryStatusOfBeenTo = MenuStatus.CLOSED;    }else if (Math.abs(left) >= menuWidth) {
        this.menuBoundaryStatusOfBeenTo = MenuStatus.OPEN;
    }
    //记录打开关闭菜单项的item
    if (left == 0) {
        this.openView.remove(this.swipeLayout);
    }else if (0 != menuWidth && left == menuWidth) {
        if (!openView.contains(swipeLayout)) {
            openView.add(swipeLayout);
        }
    }
}
//记录拖动之前达到过的状态(只要到达过菜单开的状态,此时再次移动将会关闭菜单)
@MenuBoundaryStatusOfBeenToWhereprivate int menuBoundaryStatusOfBeenTo = MenuStatus.CLOSED;
@Retention(RetentionPolicy.SOURCE)
@IntDef({MenuStatus.OPEN, MenuStatus.CLOSED})
private @interface MenuBoundaryStatusOfBeenToWhere {}
/**
 * 菜单状态
 */
public interface MenuStatus{
    int CLOSED = -1;
    int DRAGING = 0;
    int OPEN = 1;
}

滑动区域

在有可消耗事件的view存在时,我们的滑动效果就失效了,这时候鉴于菜单只定义了横向滑动,所以我们复写下面这个方法来限定可拖动的区域(至于为什么请自行google):

@Override
public int getViewHorizontalDragRange(View child) {
    return swipeLayout.getItemView() == child ? child.getWidth() : 0;
}

滑动菜单的支持到这里就介绍完了,写的是云里雾里的,有些地方应该是比较模糊,只看的话估计也很难全面了解,毕竟这个功能需要处理的细节很多,所以首先一定要知道所要实现的效果是什么,然后再读。
最后还是移驾到源码中,应该一看就懂了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容