RecyclerView 整理

  • 判断是否滑动到最后 / 前的 Item
  • 添加 HeaderView 和 FooterView
  • ScrollView 嵌套情况下的问题
  • 数据源刷新的问题
  • item 局部刷新
  • 平滑的滑动置顶
  • ItemTouchHelper

参考 :
Android RecyclerView 使用完全解析 体验艺术般的控件
深入理解 RecyclerView 系列之一:ItemDecoration

开篇先说明一下,现在 RecyclerView 想必都很熟悉,我在项目里也对这个控件做过一些封装、遇到过一些问题,这里仅做记录。因为这次时间还是比较多,所以打算深入一点研究学习、内容也比较多所以分出了几篇,链接在上面。

简单初始化

//设置布局管理器
mRecyclerView.setLayoutManager(layout);
//设置adapter
mRecyclerView.setAdapter(adapter)
//设置Item增加、移除动画
mRecyclerView.setItemAnimator(new DefaultItemAnimator());
//添加分割线
mRecyclerView.addItemDecoration(new DividerItemDecoration(
                getActivity(), DividerItemDecoration.HORIZONTAL_LIST));
  • 监听 Item 的点击事件
    因为 RecyclerView 没直接的事件可以监听,需要自己去设置,
    我选择在 RecyclerView.Adapter 中设置:
//在继承了 RecyclerView.Adapter  的内部类中
@Override
public void onBindViewHolder(ViewHolder holder, final int position) {
    if (holder instanceof ViewHolder){
        //设置 TextView
        holder.textView.setText(data.get(position).getTitle());
        //设置 Item 的点击事件
        holder.itemLayout.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                //自己定义的一个方法,将 position 传出去
                onItemClick(position);
            }
        });
    }
}                                                                 
//重写的 ViewHolder,使用了 ButterKnife,但逻辑上没有变化
class ViewHolder extends RecyclerView.ViewHolder{

    @BindView(R.id.news_item_title)
    TextView newsTitle;
    @BindView(R.id.news_item_img)
    ImageView newsImg;
    @BindView(R.id.news_item_layout)
    LinearLayout newsLayout;

    public ViewHolder(View itemView) {
        super(itemView);
        ButterKnife.bind(this,itemView);
    }
}

Item 的布局:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="match_parent"
              android:layout_height="wrap_content">

    <LinearLayout
        android:id="@+id/news_item_layout"
        android:layout_width="match_parent"
        android:layout_height="110dp"
        android:layout_marginBottom="2.5dp"
        android:layout_marginTop="2.5dp"
        android:elevation="4dp"
        android:background="@drawable/news_item_bg">
        <TextView
            android:textColor="#000"
            android:id="@+id/news_item_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="17dip"
            android:layout_marginTop="10dp"
            android:layout_marginBottom="5dp"
            />
    </LinearLayout>
</LinearLayout>

还有一种方法就是使用 addOnItemTouchListener,通过触摸坐标来判断
可以参考: RecyclerView 无法添加 onItemClickListener 最佳的高效解决方案

监听滑动


//OnScrollListener  是继承自 RecyclerView.OnScrollListener 的内部类,见下文
mNewsRecyclerView.addOnScrollListener(new OnScrollListener());

监听是否滑动到底部
继承 RecyclerView.OnScrollListener,
onScrolled 获取 RecyclerView 的最后一个 Item 的位置,
onScrollStateChanged 判断当前是否滑动到最后一个 Item。

/**
  * 监听 RecyclerView 判断是否滑到最后一个 Item
  */
class OnScrollListener extends RecyclerView.OnScrollListener{

  private int lastVisibleItem;

  /**
    * 判断是否滑动了最后一个 Item
    * @param recyclerView
    * @param newState
    */
  @Override
  public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
   super.onScrollStateChanged(recyclerView, newState);
   if( (newState == RecyclerView.SCROLL_STATE_IDLE)
     &&(lastVisibleItem + 1 == mRecyclerViewAdapter.getItemCount())){
    
     //newState 为 0:当前屏幕停止滚动;
     //1:屏幕在滚动且用户仍在触碰或手指还在屏幕上;
     //2:随用户的操作,屏幕上产生的惯性滑动;
     
    //在这里执行刷新/加载更多的操作
    loadMore();
}

   /**
     * 在这里获取到 RecyclerView 的最后一个 Item 的位置
     * @param recyclerView
     * @param dx
     * @param dy
     */
   @Override
   public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    super.onScrolled(recyclerView, dx, dy);
    lastVisibleItem = mLinearLayoutManager.findLastVisibleItemPosition();
  }
}

特别的是,当 RecyclerView 实现了瀑布流
就是使用了 StaggeredGridLayoutManager 的时候,因为 Item 是交错的,
所以不能使用上面的方法,而是要判断 findLastVisibleItemPosition 返回的数组的最大值,上面的 onScrolled 方法要修改为:

@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    super.onScrolled(recyclerView, dx, dy);

    int [] lasPositions = new int[mStaggeredGridLayoutManager.getSpanCount()];
    int [] all = mStaggeredGridLayoutManager
                      .findLastVisibleItemPositions(lasPositions);
    lastVisibleItem = findMax(all);

}

private int findMax(int[] lastPositions) {
    int max = lastPositions[0];
    for (int value : lastPositions) {
        if (value > max) {
            max = value;
        }
    }
    return max;
}

添加 HeaderView 和 FooterView

  • 在 ViewHolder 中添加 HeaderView 和 FooterView
//当前 item 的类型,0 为头布局,1 为底部布局
public static final int TYPE_HEADER = 0;
public static final int TYPE_FOOTER = 1;
public static final int TYPE_NORMAL = 2;
//
private View mHeaderView;
private View mFooterView;
//
public void addHeaderView(View mHeaderView) {
    this.mHeaderView = mHeaderView;
}
public void addFooterView(View mFooterView) {
    this.mFooterView = mFooterView;
}
  • 通过设置 ItemViewType 来区分 HeaderView 和 FooterView
/**
 * 为每个 Item 设置类型
 * 如果头布局不为空,则将第一个 Item 设为头布局
 * @param position
 * @return
 */
@Override
public int getItemViewType(int position) {
    if (mHeaderView == null && mFooterView == null)
        return TYPE_NORMAL;
    if (mHeaderView != null && position == 0)
        return TYPE_HEADER;
    if (mFooterView != null && position == getItemCount() - 1)
        return TYPE_FOOTER;

    return TYPE_NORMAL;
}

如果 mHeaderView 不为空,则将第一个 ItemView 设为 HeaderView,
如果 mFooterView 不为空,将最后一个 ItemView 设为 FooterView。

  • 创建 ItemView
/**
 * 创建布局的时候如果发现是头布局直接返回
 * @param parent
 * @param viewType
 * @return
 */
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
    if (mHeaderView != null && viewType == TYPE_HEADER){
        return new ViewHolder(mHeaderView);
    }
    if (mFooterView != null && viewType == TYPE_FOOTER){
        return new ViewHolder(mFooterView);
    }
    View v = LayoutInflater.from(getActivity())
            .inflate(R.layout.news_list_item,parent,false);
    ViewHolder viewHolder = new ViewHolder(v);
    return viewHolder;
}
/**
 * 只有当前的 Item 的类型是 normal 时才执行
 *  realPosition = position -1 是因为 0 被头布局占用了
 * @param holder
 * @param position
 */
@Override
public void onBindViewHolder(ViewHolder holder, final int position) {
    final int realPosition;
    if (mHeaderView != null)
        realPosition = position -1;
    else
        realPosition = position;

    if (getItemViewType(position) == TYPE_NORMAL){
        if (holder instanceof ViewHolder){
            holder.newsTitle.setText(data.get(realPosition).getTitle());
            Glide.with(getActivity()).load(data.get(realPosition).getImages())
                    .into(holder.newsImg);

            //设置 Item 的点击事件
            holder.newsLayout.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    OnItemClick(realPosition);
                }
            });
        }
    }
}
  • 相应的修改
/**
 * 根据是否有设置头布局等,判断返回的个数
 * @return
 */
@Override
public int getItemCount() {
    if (news == null)
        return 0;
    else if (mHeaderView == null && mFooterView == null)
        return news.getStories().size();
    else if (mHeaderView == null && mFooterView != null)
        return news.getStories().size() + 1;
    else if (mHeaderView != null && mFooterView == null)
        return news.getStories().size() + 1;
    else if (mHeaderView != null && mFooterView != null)
        return news.getStories().size() + 2;

    return 0;
}
//继承 RecyclerView.ViewHolder,使用 ButterKnife
class ViewHolder extends RecyclerView.ViewHolder{

    @BindView(R.id.news_item_title)
    TextView newsTitle;
    @BindView(R.id.news_item_img)
    ImageView newsImg;
    @BindView(R.id.news_item_layout)
    LinearLayout newsLayout;

    public ViewHolder(View itemView) {
        super(itemView);
        if (itemView == mHeaderView)
            return;
        if (itemView == mFooterView)
            return;
        ButterKnife.bind(this,itemView);
    }
}
  • 在 Activity 中调用
View banner = ......
mRecyclerViewAdapter.addHeaderView(banner);

关于滑动

  • 简单的直接定位:
mLayoutManager.scrollToPositionWithOffset(position, 0);

缺点是体验不好,没有滑动的过程。

  • RecyclerView 的滑动方法
rv.scrollToPosition(index);
rv.scrollBy(int x, int y);

scrollToPosition() 将对应的 item 滑动到屏幕内,当 item 变为可见时则停止滑动。
所以当指定的 item 在当前屏幕的下方时,滑动后目标 item 会出现屏幕的最低下;当指定 item 在屏幕可见时,则完全没有滑动。
很多时候这个方法是不符合我们预期的,一般是希望能将指定的 item 滑动到当前的屏幕顶端或中间。
这时候可以配合 scrollBy() 来做一个判断:

private void setSelectPosition(int index) {
    //当前可见的第一项和最后一项
    int firstItem = linearLayoutManager.findFirstVisibleItemPosition();
    int lastItem = linearLayoutManager.findLastVisibleItemPosition();
    if (index <= firstItem) {
        //当要置顶的项在当前显示的第一个项的前面时,直接调用没有问题
        rv.scrollToPosition(index);
    } else if (index <= lastItem) {
        //当要置顶的项已经在屏幕上显示时,计算需要滑动的距离
        int top = rv.getChildAt(index - firstItem).getTop();
        rv.scrollBy(0, top);
    } else {
        //当指定的 item 在当前显示的最后一项的后面时
        //这时候一次的滑动不足以将指定 item 放到顶端
        rv.scrollToPosition(index);
        //记录当前需要在RecyclerView滚动监听里面继续第二次滚动
        move = true;
    }
}
class RecyclerViewListener extends RecyclerView.OnScrollListener{
        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy);
            //在这里进行第二次滚动
            if (move ){
                move = false;
                int n = mIndex - mLinearLayoutManager.findFirstVisibleItemPosition();
                if ( 0 <= n && n < mRecyclerView.getChildCount()){
                    //要移动的距离
                    int top = mRecyclerView.getChildAt(n).getTop();
                    mRecyclerView.scrollBy(0, top);
                }
            }
        }
    }

上面这个方面也是看到别人的实现的思路,虽然没什么问题不过还是算是取巧的一种方法,仅记录。
其实 Stack Overflow 上已经有了更好的答案 :RecyclerView - How to smooth scroll to top of item on a certain position?

  • 重写 LinearLayoutManager
public class LinearLayoutManagerWithSmoothScroller extends LinearLayoutManager {

    public LinearLayoutManagerWithSmoothScroller(Context context) {
        super(context, VERTICAL, false);
    }

    public LinearLayoutManagerWithSmoothScroller(Context context, int orientation, boolean reverseLayout) {
        super(context, orientation, reverseLayout);
    }

    //重点方法
    @Override
    public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state,
                                       int position) {

        //也就是说重点在于重写 SmoothScroller,而滑动的调用为 startSmoothScroll()
        RecyclerView.SmoothScroller smoothScroller = new  TopSnappedSmoothScroller(recyclerView.getContext());
        smoothScroller.setTargetPosition(position);
        startSmoothScroll(smoothScroller);
    }

    private class TopSnappedSmoothScroller extends LinearSmoothScroller {
        public TopSnappedSmoothScroller(Context context) {
            super(context);
        }

        @Override
        public PointF computeScrollVectorForPosition(int targetPosition) {
            return LinearLayoutManagerWithSmoothScroller.this
                    .computeScrollVectorForPosition(targetPosition);
        }

        @Override
        protected int getVerticalSnapPreference() {
            //将指定的 item 滑动至与屏幕的顶端对齐
            return SNAP_TO_START;
        }
    }
}

仔细看可以发现我们也可以选择不重写整个 LinearLayoutManager,只要将 LinearSmoothScroller 的 getVerticalSnapPreference() 重写也可以达到目的。

关于 getVerticalSnapPreference () 的源码与注释:

    /**
     * When scrolling towards a child view, this method defines whether we should align the top
     * or the bottom edge of the child with the parent RecyclerView.
     *
     * @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector
     * @see #SNAP_TO_START
     * @see #SNAP_TO_END
     * @see #SNAP_TO_ANY
     */
    protected int getVerticalSnapPreference() {
        return mTargetVector == null || mTargetVector.y == 0 ? SNAP_TO_ANY :
                mTargetVector.y > 0 ? SNAP_TO_END : SNAP_TO_START;
    }

返回的值决定了指定 item 的对齐方式,与顶部对齐 / 底部对齐。
所以最终代码:

//初始化过程
mLayoutManager= new LinearLayoutManager(getActivity());
mSmoothScroller = new LinearSmoothScroller(getActivity()) {
    @Override protected int getVerticalSnapPreference() {
        return LinearSmoothScroller.SNAP_TO_START;
     }

    @Override
    public PointF computeScrollVectorForPosition(int targetPosition) {
        return mLayoutManager.computeScrollVectorForPosition(targetPosition);
    }
};
mRvRetail.setLayoutManager(mLayoutManager);

//移动
mSmoothScroller.setTargetPosition(position);
mLayoutManager.startSmoothScroll(mSmoothScroller);

这样就可以实现平滑的滑动了。

与 ScrollView 嵌套时惯性滑动失效

猜测是由于滑动的时候,ScrollView 将事件分发给了 RecyclerView,所以这个时候是 rv 在滑动;
当快速滑动并离开屏幕的时候,按预期是应该有一段惯性般滑动、缓慢停止的过程,然而这时候应该是 ScrollView 拦截了这个 ACTION_UP 的事件,导致 RecyclerView 无法继续滑动。
解决这个问题可以从 RecyclerView 入手,若 RecyclerView 不再响应事件,将事件交给 ScrollView 即可:

mLayoutManager = new LinearLayoutManager(mContext){
    @Override
    public boolean canScrollVertically() {
        return false;
    }
};
mRv.setLayoutManager(mLayoutManager);

刷新数据源导致的问题

比较常见是在删除 item 的时候,可能会出现 item 的下标没有刷新、位置错位的问题。
更新了数据源之后最保险的方法是调用 notifyDataSetChanged() 去刷新所有的数据,问题是如果只更新了少量的数据或者想要保留删除/添加 item 的动画,这个方法都不能满足要求。
所以我们需要调用 notifyItemRemoved(position)notifyItemInserted(position),这时候 item 的内容已经刷新、并且带有动画效果。但是如果是删除 item 的操作,上面说的下标没有刷新、错位的问题就出现了,所以接下来要调用 notifyItemRangeChanged(position, itemSize) 将可能出现问题的 item 都刷一遍,所以 itemSize 常取 list.size()

item 的局部刷新

说起 RecyclerView 的局部刷新,一般都是说到 notifyItemChanged(int positon) 即只刷新指定的 item 的布局,但是有时候需要针对 item 的部分控件进行刷新。
比如一个 item 里有一张图片,单调用 notifyItemChanged 的时候图片会去重新加载,就导致了重复加载同样的图片而出现图片闪烁的问题,所以这时候我们需要针对 item 去刷新改变的部分、保留不变的部分。
这里用上了

@Override
public void onBindViewHolder(ViewHolder holder, int position, List<Object> payloads) {
  super.onBindViewHolder(holder, position, payloads);
}

notifyItemChanged(int position, Object payload)
看到这两个方法其实方法已经很明显了,调用 notifyItemChanged()可以传递一个 payload 到 onBindViewHolder 方法里面,这时候就可以通过传递过来的数据类型去刷新不同的控件,没有刷新到的控件将会用 ViewHolder 的实例来展示。

ItemTouchHelper

ItemTouchHelper 是 RecyclerView 中辅助滑动和拖拽的实用工具类,一般用来做拖拽改变排序、滑动删除功能。

ItemTouchHelper helper = new ItemTouchHelper(callback);
helper.attachToRecyclerView(recyclerView);

ItemTouchHelper 的构造方法需要一个传入 ItemTouchHelper.Callback 的实例,所以重点就在于这个 Callback 里的几个方法。

class TestHelperCallback extends ItemTouchHelper.Callback{

  private ItemMoveListener mListener;

  public TestHelperCallback(){}

  public TestHelperCallback(ItemMoveListener listener){
    mListener = listener;
  }

  @Override
  public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
    //两个 flags 为 0 则无法拖动
    int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;//允许上下滑动
    int swipeFlags = ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;//允许左右滑动
    //生成并返回
    int flags = makeMovementFlags(dragFlags, swipeFlags);
    return flags;
  }

  @Override
  public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
    //拖拽的回调
    if (mListener != null){
      return mListener.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition());
    }
    return false;
  }


  @Override
  public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
    //左右滑动的回调
    int position = viewHolder.getAdapterPosition();
    if (mListener != null){
      mListener.onItemDelete(position);
    }
  }

  @Override
  public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
    //在滑动和拖拽的过程中会被调用去绘制动画,重写这里可以实现自己的动画
    super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
    if(actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
         //左右滑动的同时改变 item 的透明度
         final float alpha = 1 - Math.abs(dX) / (float)viewHolder.itemView.getWidth();
         viewHolder.itemView.setAlpha(alpha);//透明度
         viewHolder.itemView.setTranslationX(dX);//滑动
     }
  }

  @Override
  public boolean isLongPressDragEnabled() {
    //是否可以长按拖拽
    return true;
  }
}

public interface ItemMoveListener {
   boolean onItemMove(int fromPosition, int toPosition);
   boolean onItemDelete(int position);
}

通过接口 ItemMoveListener 把回调放出去,接下来用 Adapter 实现并处理回调:

class ItemMoveRecyclerAdapter extends CommonRecyclerAdapter<String> implements ItemMoveListener{

  public ItemMoveRecyclerAdapter(Context context, List dataList, int layoutId) {
    super(context, dataList, layoutId);
  }

  @Override
  public void convert(CommonRecyclerViewHolder holder, int position, String o) {
  }

  @Override
  public void onItemClick(CommonRecyclerViewHolder holder, int position, String o) {
  }

  @Override
  public boolean onItemMove(int fromPosition, int toPosition) {
    //交换数据
    Collections.swap(mDataList, fromPosition, toPosition);
    //刷新
    notifyItemMoved(fromPosition, toPosition);
    notifyItemRangeChanged(Math.min(fromPosition,toPosition),getItemCount());
    return true;
  }

  @Override
  public boolean onItemDelete(int position) {
    mDataList.remove(position);
    notifyItemRemoved(position);
    notifyItemRangeChanged(position,getItemCount());
    return false;
  }
}

CommonRecyclerAdapter 是项目封装的 Adapter,这里不是重点。
onItemMove()中依次调用了 notifyItemMoved()notifyItemRangeChanged(),防止在移动过后 item 的下标错乱。
onItemDelete () 中同理。
上面代码基本就是拖拽排序和滑动删除的代码,因为很简单就没有多分析,因为一开始是想用 ItemTouchHelper 仿照 QQ 的侧滑按钮,但是看了一圈发现好像没办法用 ItemTouchHelper 实现。

clipToPadding 属性

android:clipToPadding 属性决定子 View 是否可以在 父布局的 padding 中绘制。
在 RecyclerView 中子 View 即 item,该属性为false 时,RecyclerView 的 padding 会被 item 遮住。
比如我们在 RecyclerView 的底部上悬浮一个按钮,按钮会挡住下方的 item,clipToPadding + paddingBottom 可以实现在 RecyclerView 拉倒底部的时候为悬浮按钮留出空间。

推荐阅读更多精彩内容