RecyclerView-为Adapter添加头部、尾部及事件响应(第2篇)

效果图

添加 头部 尾部 事件

简述

这篇文章主要描述如何扩展原生的Adapter,让其支持添加header、footer,以及item的事件处理,这将会是一篇很长的文章。

首先的考虑是我需要一个支持添加任意数量的header和footer,并且互相不干扰的Adapter。主要表现在客户端的调用上数据是数据,header是header,footer是footer,而且他们的顺序关系也是很显然的。另外再将header和footer进行一下扩展,将给客户端使用的和库内部本身需要扩展的header、footer进行区分,这样就产生了下面这个顺序关系(已经在第一篇进行了阐述):

  {
      item_sys_header - item_header - 
      item_data - 
      item_footer - item_sys_footer
  }

这个顺序以及每个域的数量都是很关键。
有了上面这个顺序的关系图,这时候数据结构也就定下来了:

protected List<View> headerViews = new ArrayList<>();
protected List<View> footerViews = new ArrayList<>();
//逻辑上设计为系统头部也可以是多个 ,但是实现上系统头部实现为仅有一个
private List<View> sysHeaderViews = new ArrayList<>();
private View sysFooterView;
//真实的数据部分
protected List<T> datas;

这里需要对上面的数据结构补充说明一下:
数据结构的访问权限是为了扩展用;系统header有计划进行扩展(声明中可以看到),但是目前来说只支持一个(将会在稍后的实现中看到);系统footer没有打算扩展(声明中可以看到),所以只支持一个。
既然有了上面这个关系图,那么接下来的思路就很明显了,我们按照这个顺序进行。

1. 系统头部

这部分对应到item_sys_header部分,起初的考虑是用于内部扩展用。

数据操作

首先目测需要这么几个方法对系统头部的操作

 int getSysHeaderViewCount();
 void setSysHeaderView(View view);
 void removeSysHeaderView(View view);

这里的setSysHeaderView()方法为什么用一个view参数而不是一个layoutId呢,我的考虑是如果用了id,那么内部就要去创建这个view以及viewHolder,这样客户端需要通过viewHolder去绑定数据,这样的操作不是我希望的。移除的操作removeSysHeaderView()与设置操作相对应。

前面已经说了,系统头部在设计的时候是支持任意数量的,并且系统头部的是在最顶层的。所以调用设置方法时将会清除所有的系统header(这么做的目的是可能在某个版本中将会进行多个的扩充),然后才进行添加(这时候一定只有一个,索引就是0),接着是局部刷新(这些是Adapter的常规操作)。下面就是相关代码:

final
public int getSysHeaderViewCount(){
    return sysHeaderViews.size();
}
final
public void setSysHeaderView(View view) {
    if (null == view) {
        return;
    }
    int index = getSysHeaderViewCount();
    if (index > 0) {
        sysHeaderViews.clear();
        notifyItemRangeRemoved(0, index);
    }
    sysHeaderViews.add(view);
    notifyItemInserted(0);
}

对于移除操作就不太一样,因为面对的是多个,所以具体要删除哪一个需要先检索出在sysHeaderViews中的位置index,然后进行相应索引位置的移除操作,接着还是局部刷新。下面就是相关代码:

final
public void removeSysHeaderView(View view) {
    if (null == view || !sysHeaderViews.contains(view)) {
        return;
    }
    int index = sysHeaderViews.indexOf(view);
    sysHeaderViews.remove(view);
    notifyItemRemoved(index);
}

onCreateViewHolder过程

系统头部数据的操作已经结束,我们再调用了刷新操作后,Adapter将会处理一系列操作,首先我们看看onCreateViewHolder(),这里将会根据viewType创建对应的viewHolder。在我们这个Adapter的扩展中很明显是item的类型已经不止一种了,所以我们需要复写getItemViewType()方法为系统头部关联一个类型。系统header可能很多,每一个都是单独的view对象,在创建viewHolder的时候也是需要区分的,因此每一个系统header都是一个单独的类型,所以我们这里采取了将view的hashCode作为与之对应的类型。

@Override
final
public int getItemViewType(int position) {
    int shc = getSysHeaderViewCount();
    //处理系统头部
    if (shc > 0 && position < shc) {
        return sysHeaderViews.get(position).hashCode();
    }
    return 0;
}

onCreateViewHolder()的过程中我们需要根据viewType创建viewHolder,首先检查viewType是否是系统header以及是哪一个系统header

@Override
final
public BaseViewHolder onCreateViewHolder(ViewGroup parent, final int viewType) {
    //处理系统头部
    View sysHeaderView = getSysFooterViewByHashCode(viewType);
    if (null != sysHeaderView) {
        return new BaseViewHolder(sysHeaderView);
    }
    return null;
}
private View getSysFooterViewByHashCode(int hashCode) {
    return this.getViewByHashCodeFromList(sysHeaderViews, hashCode);
}
private View getViewByHashCodeFromList(List<View> views, int hashCode) {
    if (null == views) {
        return null;
    }
    for (View v : views) {
        if (v.hashCode() == hashCode) {
            return v;
        }
    }
    return null;
}

onBindViewHolder过程

viewHolder创建完毕之后,按照流程就到了onBindViewHolder进行数据的绑定操作,但是看看我们向客户端提供的设置方法,传入的是一个view,这就意味着系统header数据的绑定操作是在客户端进行的,换句话说就是在这里针对系统header没有要数据绑定的操作(传入的position在系统头部范围内不做任何处理)。那么代码就是下面这个样子:

@Override
final
public void onBindViewHolder(BaseViewHolder holder, int position) {
    int shc = getSysHeaderViewCount();
    //item_sys_header
    if (0 != shc && position < shc) {
        return;
    }
}

到这里,系统header的处理部分就结束了,这时候已经可以添加和删除一个系统header了。这里可能有的同学就会问了,最重要的三个方法为什么都加了final,这样不是都没法操作了吗,至于为什么要这样做,将会在稍后慢慢说明。

2. 用户头部

这部分对应到item_header部分,是提供给客户端使用的。
流程仍然按照上面系统header部分。

数据操作

首先对于这部分功能提供一下部分几个方法进行头部的操作

int getHeaderViewCount();
void addHeaderView(View view);
void removeHeaderView(View view);

以上参数的说明与系统header一致,这里不再赘述。
这部分在按顺序在系统header(item_sys_header)后面,所以在数据操作上需要考虑item_sys_header部分。下面是具体的操作代码:

final
public int getHeaderViewCount() {
    return headerViews.size();
}
final
public void addHeaderView(View view) {
    if (null != view && headerViews.contains(view)) {
        headerViews.remove(view);
    }
    headerViews.add(view);
    int shc = getSysHeaderViewCount();
    int index = shc + headerViews.indexOf(view);
    notifyItemInserted(index);
}
final
public void removeHeaderView(View view) {
    if (null == view || !headerViews.contains(view)) {
        return;
    }
    int shc = getSysHeaderViewCount();
    int index = shc + headerViews.indexOf(view);
    headerViews.remove(view);
    notifyItemRemoved(index);
}

onCreateViewHolder过程

这部分仍然与系统header一样,item类型也是view对应的hashCode,这里对getItemViewType()onCreateViewHolder()进行完善,添加对用户header的支持。代码如下:

@Override
final
public int getItemViewType(int position) {
    int shc = getSysHeaderViewCount();
    int hc = getHeaderViewCount();
    int hAll = shc + hc;
    //处理系统头部
    if (shc > 0 && position < shc) {
        return sysHeaderViews.get(position).hashCode();
    }
    //处理头部
    if (hc > 0 && position >= shc && position < hAll) {
        position = position - shc;
        return headerViews.get(position).hashCode();
    }
    return 0;
}
@Override
final
public BaseViewHolder onCreateViewHolder(ViewGroup parent, final int viewType) {
    //处理系统头部
    View sysHeaderView = getSysFooterViewByHashCode(viewType);
    if (null != sysHeaderView) {
        return new BaseViewHolder(sysHeaderView);
    }
    //处理头部
    View headerView = getHeaderViewByHashCode(viewType);
    if (null != headerView) {
        return new BaseViewHolder(headerView);
    }
    return null;
}

onBindViewHolder过程

用户header的数据绑定处理仍然与处理系统header一致,增加了用户header处理的代码如下:

@Override
final
public void onBindViewHolder(BaseViewHolder holder, int position) {
    int shc = getSysHeaderViewCount();
    //item_sys_header 
   if (0 != shc && position < shc) {
        return;
    }
    int hc = getHeaderViewCount();
    int hAll = shc + hc;
    //处理用户header
    if (0 != hc && position < hAll) {
        return;
    }
}

3. 正常的数据部分

这部分是除header、footer外客户端处理的真实数据部分。
对于这部分的实现有以下几方面的考虑:

多item类型支持

这个扩展库一定是支持多种item类型的,但是对于header和footer的类型已经在内部做了处理(本应该是这样),从上面来看多类型的支持的重载方法被设置为final,所以需要额外提供一个方法来只针对数据部分多item类型的支持。在安全起见和必要性方面将getItemViewType()方法做了屏蔽。额外增加的方法如下:

  /**
    * 初始化数据域类型,总是从0开始,已经除去头部
    * @param positionOffsetHeaders 
    * @return 
    */
  protected int mapDataSectionItemViewTypeToItemLayoutId(int positionOffsetHeaders) {
        return 0;
  } 

从方法名来看,除了header和footer外的数据域部分item类型对应的都是item的布局文件id,这样在onCreateViewHolder()内部封装是有好处的,再一次说明viewHolder的创建对于客户端直接使用api是屏蔽的。

onCreateViewHolder

如果就单一般的展示用,那么这个方法也可以被屏蔽了,但是为了更好的扩展(在后面的滑动菜单以及粘性头部时),还是向上面一样再提供一个下面这样的方法供子类使用:

abstract
public BaseViewHolder onCreateHolder(ViewGroup parent, int viewType);

onBindViewHolder

同上面一样,我们需要保护非数据域的逻辑,但是数据的绑定操作一定是客户端来做的,所以我们还是需要额外提供一个下面这样的方法供客户端使用:

 /** 
    * 绑定数据
    * @param holder
    * @param position  数据域索引从0开始,已经除去头部
    */
abstract
public void convert(BaseViewHolder holder, int position);

仅在这篇文章中提及的功能而言,对于onBindViewHolder()的操作我们只是又提取了一个convert()方法,但是如果翻看源代码的话会发现对这块的提取操作是下面这样的(在粘性头部的扩展中会找到原因):

  /**     
     * 为了子类扩展     
     * @param holder     
     * @param position     
     */    
@CallSuper    
protected void onBindHolder(BaseViewHolder holder, int position) {
        this.convert(holder, position); 
  }    
  /**     
     * 绑定数据    
     * @param holder     
     * @param position  数据域索引从0开始,已经除去头部     
     */    
abstract    
public void convert(BaseViewHolder holder, int position);

经过上面的篇幅阐述,已经简单描述了header的添加删除以及数据域部分的处理,屏蔽了部分非必要方法以及为进一步扩展等额外添加了一些处理方法,这么做的目的就是为了简单、易扩展。
大概总结一下就是:

  1. 多类型的支持分为两类:数据部分映射为item布局id;非数据部分映射为view的hashCode
  2. viewHolder创建分为两种情况:对于客户端使用直接屏;对于扩展则有间接的映射方法
  3. 数据绑定分为两类:数据域对于客户端有必须实现的间接映射方法;非数据域则直接屏蔽

相信看到这里已经知道footer怎么加进去了,与header和数据域都是类似的,主要抓住一点就是正确的计算索引,具体的就不在往下阐述了,可以去翻看源代码。

4. 为数据域的item添加事件

我们知道RecyclerView对于viewHolder有特殊的要求,必须是RecyclerView.ViewHolder的子类才行,而它内部有个itemView属性,指的就是我们创建的item根视图,有了这个万事已经具备。还记得代码是这样子写的:

@Override
public BaseViewHolder onCreateHolder(ViewGroup parent, int viewType) {
    View itemView = inflater.inflate(viewType, parent, false);
    return new BaseViewHolder(itemView);
}

我们仿照ListView添加以下两种事件。

/** 
  * 点击事件 
  */
public interface OnItemClickListener {    
    void onItemClick(View itemView, int position);
}
/** 
  * 长按事件 
  */
public interface OnItemLongClickListener {
    void onItemLongClick(View itemView, int position);
}

接下来只需要在onCreateViewHolder()里面添加事件回调就可以了,这里需要说明两点:1. 写这么复杂的原因是还是为了扩展 2. 计算正确的索引

@Override
final
public BaseViewHolder onCreateViewHolder(ViewGroup parent, final int viewType) {
    //省略其他操作...

    //事件只针对正常数据项
    final BaseViewHolder holder = onCreateHolder(parent, viewType);
    this.initItemListener(holder/*, viewType*/);
    return holder;
}

protected void initItemListener(final BaseViewHolder holder/*, final int viewType*/){
    if (null == holder) {
        return;
    }
    holder.itemView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            HeaderFooterAdapter.this.onItemClick(holder, v);
        }
    });

    holder.itemView.setOnLongClickListener(new View.OnLongClickListener() {
        @Override
        public boolean onLongClick(View v) {
            return HeaderFooterAdapter.this.onItemLongClick(holder, v);
        }
    });
}

protected void onItemClick(final BaseViewHolder holder, View view){
    if (null == onItemClickListener) {
        return;
    }
    int hAll = getHeaderViewCount() + getSysHeaderViewCount();
    onItemClickListener.onItemClick(view, holder.getAdapterPosition() - hAll);
}

protected boolean onItemLongClick(final BaseViewHolder holder, View view){
    if (null == onItemLongClickListener) {
        return false;
    }
    int hAll = getHeaderViewCount() + getSysHeaderViewCount();
    onItemLongClickListener.onItemLongClick(view, holder.getAdapterPosition() - hAll);
    return true;
}

5. 支持GridLayoutManager与StaggeredGridLayoutManager

写到这里我们扩展的Adapter已经支持header、footer的增删操作、更加简化的api处理以及为数据域添加事件监听响应。
写一个demo测试后发现一切正常,但是这仅限于LayoutManagerLinearLayoutManager的情况,其他情况会发现是失效的状态。我们需要的是在任何情况下我们加进去的header和footer一定要是横向填充的,显然还需要处理。
发现Adapter中并没有提供相关可以使用的方法,其实这种布局的处理是交给LayoutManager处理的,所以我们需要从LayoutManager着手处理这些问题,最后发现不同的LayoutManager处理的方式不同,那么我们需要分别对待。首先明确一点LayoutManager是设置在RecyclerView而不是Adapter,所以第一点我们需要在Adapter中获取RecyclerView对象。

@Override
public void onAttachedToRecyclerView(RecyclerView recyclerView) {
    super.onAttachedToRecyclerView(recyclerView);
    if (this.recyclerView == recyclerView) {
        return;
    }
    this.recyclerView = recyclerView;
}
@Override
public void onDetachedFromRecyclerView(RecyclerView recyclerView) {
    super.onDetachedFromRecyclerView(recyclerView);
    this.recyclerView = null;
}

获取到RecyclerView也就获取到了设置的LayoutManager对象,这样我们就可以针对不同的LayoutManager进行处理。在处理之前,首先有以下两点需要考虑:

  1. 除了数据域(header和footer)一定是横跨的,并且这部分处理需要内部搞定且对外屏蔽
  2. 客户端设置的数据域有可能有些item也是需要横跨的,这时候我们需要额外提供一个方法供客户端使用。很简单方法,如下:
  /**
     * 设置是否横跨
     * @param position
     * @return
     */
protected boolean isFullSpanWithItemView(int position) {
      return false;
}

所以我们就有了以下的代码来分别针对GridLayoutManagerStaggeredGridLayoutManager进行处理。

private void adapterGridLayoutManager() {
    final RecyclerView.LayoutManager layoutManager = null == recyclerView ? null : recyclerView.getLayoutManager();
    if (null == layoutManager) {
        return;
    }
    if (layoutManager instanceof GridLayoutManager) {
        final GridLayoutManager glm = (GridLayoutManager) layoutManager;
        final GridLayoutManager.SpanSizeLookup ssl = glm.getSpanSizeLookup();
        glm.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
            @Override
            public int getSpanSize(int position) {
                return !isDataItemView(position) ? glm.getSpanCount() : ssl.getSpanSize(position);
            }
        });
    }}

private void adapterStaggeredGridLayoutManager(BaseViewHolder holder) {
    final RecyclerView.LayoutManager layoutManager = null == recyclerView ? null : recyclerView.getLayoutManager();
    if (null == layoutManager) {
        return;
    }
    if (layoutManager instanceof StaggeredGridLayoutManager) {
        ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
        int position = holder.getAdapterPosition();
        if (null != lp && lp instanceof StaggeredGridLayoutManager.LayoutParams && !isDataItemView(position)) {
            ((StaggeredGridLayoutManager.LayoutParams) lp).setFullSpan(true);
        }
    }
}

/**
 * 用来判断item是否为真实数据项,除了头部、尾部、系统尾部等非真实数据项,结构为: * item_header - item_data - item_footer - item_sys_footer
 * @param position
 * @return true:将保留LayoutManager的设置  false:该item将会横跨整行(对GridLayoutManager,StaggeredLayoutManager将很有用)
 */
private boolean isDataItemView(int position) {
    int shc = getSysHeaderViewCount();
    int hc = shc + getHeaderViewCount();
    int dc = getDataSectionItemCount();
    boolean isHeaderOrFooter = position >= 0 && position >= hc && position < (hc + dc);
    if (!isHeaderOrFooter) {
        return isHeaderOrFooter;
    }
    return this.isFullSpanWithItemView(position - (shc + hc));
}

最后将以上处理加进去,注意一下两种方式在注册的位置不太一样:

@Override
public void onAttachedToRecyclerView(RecyclerView recyclerView) {
    super.onAttachedToRecyclerView(recyclerView);
    if (this.recyclerView == recyclerView) {
        return;
    }
    this.recyclerView = recyclerView;
    this.adapterGridLayoutManager();
}
@Override
public void onViewAttachedToWindow(BaseViewHolder holder) {
    super.onViewAttachedToWindow(holder);
    this.adapterStaggeredGridLayoutManager(holder);
}

到这里,为Adapter添加header、footer、事件以及适配LayoutManager就结束了,下一篇将阐述下数据域的操作。

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

推荐阅读更多精彩内容