RecyclerView细节研究-RecyclerView点击错位问题的探讨与修复

先描述一下这个问题。举个例子,我有一个列表,其条目的顺序从上到下应该是0,1,2…;然后当我删除第二个条目后,原来的第三个条目就应该要变成现在的第二个条目,那我点击现在的第二个条目,就应该得到的位置为1,但是实际得到的位置确是2:
点击错位修复
出现这个问题还是比较蛋疼的,因为别的功能都是好好的,只有这个位置错位了。要想解决这个问题,我们还得弄清另外一个问题:ViewHolder的getPosition、getAdapterPosition和getLayoutPosition你搞清楚了没?

为什么我会提出这个问题,让我们回顾一下我们的写法(参考本人另一篇文章RecyclerView高级使用(一)-侧滑删除的简单实现):

我们在自定义的CallBack里面的onSwiped里面是这样写的:

@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
    recyclerItemList.remove(viewHolder.getAdapterPosition());
    recyclerView.getAdapter().notifyItemRemoved(viewHolder.getAdapterPosition());
}

我们在取位置的时候,用的是getAdapterPosition。其实获取position一共有四个方法:

/**
 * @deprecated This method is deprecated because its meaning is ambiguous due to the async
 * handling of adapter updates. You should use {@link #getLayoutPosition()} or
 * {@link #getAdapterPosition()} depending on your use case.
 *
 * @see #getLayoutPosition()
 * @see #getAdapterPosition()
 */
@Deprecated
public final int getPosition() {
    return mPreLayoutPosition == NO_POSITION ? mPosition : mPreLayoutPosition;
}

/**
 * Returns the position of the ViewHolder in terms of the latest layout pass.
 * <p>
 * This position is mostly used by RecyclerView components to be consistent while
 * RecyclerView lazily processes adapter updates.
 * <p>
 * For performance and animation reasons, RecyclerView batches all adapter updates until the
 * next layout pass. This may cause mismatches between the Adapter position of the item and
 * the position it had in the latest layout calculations.
 * <p>
 * LayoutManagers should always call this method while doing calculations based on item
 * positions. All methods in {@link RecyclerView.LayoutManager}, {@link RecyclerView.State},
 * {@link RecyclerView.Recycler} that receive a position expect it to be the layout position
 * of the item.
 * <p>
 * If LayoutManager needs to call an external method that requires the adapter position of
 * the item, it can use {@link #getAdapterPosition()} or
 * {@link RecyclerView.Recycler#convertPreLayoutPositionToPostLayout(int)}.
 *
 * @return Returns the adapter position of the ViewHolder in the latest layout pass.
 * @see #getAdapterPosition()
 */
public final int getLayoutPosition() {
    return mPreLayoutPosition == NO_POSITION ? mPosition : mPreLayoutPosition;
}

/**
 * Returns the Adapter position of the item represented by this ViewHolder.
 * <p>
 * Note that this might be different than the {@link #getLayoutPosition()} if there are
 * pending adapter updates but a new layout pass has not happened yet.
 * <p>
 * RecyclerView does not handle any adapter updates until the next layout traversal. This
 * may create temporary inconsistencies between what user sees on the screen and what
 * adapter contents have. This inconsistency is not important since it will be less than
 * 16ms but it might be a problem if you want to use ViewHolder position to access the
 * adapter. Sometimes, you may need to get the exact adapter position to do
 * some actions in response to user events. In that case, you should use this method which
 * will calculate the Adapter position of the ViewHolder.
 * <p>
 * Note that if you've called {@link RecyclerView.Adapter#notifyDataSetChanged()}, until the
 * next layout pass, the return value of this method will be {@link #NO_POSITION}.
 *
 * @return The adapter position of the item if it still exists in the adapter.
 * {@link RecyclerView#NO_POSITION} if item has been removed from the adapter,
 * {@link RecyclerView.Adapter#notifyDataSetChanged()} has been called after the last
 * layout pass or the ViewHolder has already been recycled.
 */
public final int getAdapterPosition() {
    if (mOwnerRecyclerView == null) {
        return NO_POSITION;
    }
    return mOwnerRecyclerView.getAdapterPositionFor(this);
}

/**
 * When LayoutManager supports animations, RecyclerView tracks 3 positions for ViewHolders
 * to perform animations.
 * <p>
 * If a ViewHolder was laid out in the previous onLayout call, old position will keep its
 * adapter index in the previous layout.
 *
 * @return The previous adapter index of the Item represented by this ViewHolder or
 * {@link #NO_POSITION} if old position does not exists or cleared (pre-layout is
 * complete).
 */
public final int getOldPosition() {
    return mOldPosition;
}

一共有getPositiongetLayoutPositiongetAdapterPositiongetOldPosition。因此,我们为什么在取position的时候为什么就取了getAdapterPosition呢,网上的文章都没有给出解释,都是一概而论。但是现在问题出现了,我们就要研究一下了。

首先从注释上看,getOldPosition是和动画相关的,我们这里暂不讨论。那么就剩下三个了。
getPosition使用了**@Deprecated**注解,表明它是一个废弃的方法。为什么要废弃呢。
英文翻译为:*此方法已被弃用,因为它的含义由于适配器更新的异步处理而不明确.*让我们改用getLayoutPosition或者getAdapterPosition。再从源码上看,getPosition和getLayoutPosition的代码是一样的,我们可以认为getLayoutPosition就是以前的getPosition。那么就剩下getLayoutPosition和getAdapterPosition的对比了。而我们的问题也出现在了我们使用了getAdapterPosition。

getLayoutPosition的注释意思是返回ViewHolder在最新布局过程中的位置。
getAdapterPosition的注释意思是返回此ViewHolder表示的item的适配器位置。

光看意思是不是比较拗口。我这里找来了StackFlow上某位工程师的总结:
https://stackoverflow.com/questions/29684154/recyclerview-viewholder-getlayoutposition-vs-getadapterposition
我简单地总结了一下:
1、一般情况下,二者相同。
2、getAdapterPosition如名,获取的是适配器上的位置。因为RecyclerView比ListView进化了局部刷新功能,所以在ListView时代的统一刷新和getPosition方式要变一下了。RecylerView在本质上是有两个列表的,一个存储数据源,一个存储视图ViewHolder。通过观察者模式,以数据源驱动视图更新。因此,可以简单认为getAdapterPosition是获取的数据源上的位置。
3、getLayoutPostion是获取上一次绘制后的列表的视图位置。尤其在使用局部刷新(只要不是notifyDataSetChanged)这些操作的时候,推荐使用这个方法来获取位置。相比getAdapterPosition,能立即获取到改变后的位置。

有了上面的总结,那么我们解决这个问题就有思路了,将代码改为:

@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
    recyclerItemList.remove(viewHolder.getLayoutPosition());
    recyclerView.getAdapter().notifyItemRemoved(viewHolder.getLayoutPosition());
}

然后再试,结果发现还是不行。
问题又在哪呢?
我们再看看设置条目点击事件的地方:

public class SimpleRecyclerListAdapter extends RecyclerView.Adapter<SimpleRecyclerViewHolder> {

    private List<RecyclerItem> recyclerItemList;
    private OnItemCLickListener onItemCLickListener;

    public void setOnItemCLickListener(OnItemCLickListener onItemCLickListener) {
        this.onItemCLickListener = onItemCLickListener;
    }

    public SimpleRecyclerListAdapter(List<RecyclerItem> recyclerItemList) {
        this.recyclerItemList = recyclerItemList;
    }

    @NonNull
    @Override
    public SimpleRecyclerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View v = LayoutInflater.from(parent.getContext()).inflate(getItemLayoutRes(), parent, false);
        return new SimpleRecyclerViewHolder(v);
    }

    @Override
    public void onBindViewHolder(@NonNull SimpleRecyclerViewHolder holder, int position) {
        RecyclerItem item = recyclerItemList.get(position);
        holder.iconView.setImageResource(item.getIcon());
        holder.textView.setText(item.getText());
        holder.itemView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (onItemCLickListener != null) {
                    onItemCLickListener.onItemClick(item, holder, position);
                }
            }
        });
    }

    @Override
    public int getItemCount() {
        return recyclerItemList == null ? 0 : recyclerItemList.size();
    }

    public interface OnItemCLickListener {
        void onItemClick(RecyclerItem recyclerItem, SimpleRecyclerViewHolder holder, int position);
    }

    protected @LayoutRes int getItemLayoutRes(){
        return R.layout.item_simple_list;
    }

}

其余代码可以不用看,就看onBindViewHolder里面设置onClickListener的地方。这里面传的position直接从外部的position获取了。但是实际上外部的position依旧还是adapterPosition,为什么要这样说呢:

/**
  * Called by RecyclerView to display the data at the specified position. This method
  * should update the contents of the {@link ViewHolder#itemView} to reflect the item at
  * the given position.
  * <p>
  * Note that unlike {@link android.widget.ListView}, RecyclerView will not call this method
  * again if the position of the item changes in the data set unless the item itself is
  * invalidated or the new position cannot be determined. For this reason, you should only
  * use the <code>position</code> parameter while acquiring the related data item inside
  * this method and should not keep a copy of it. If you need the position of an item later
  * on (e.g. in a click listener), use {@link ViewHolder#getAdapterPosition()} which will
  * have the updated adapter position.
  * <p>
  * Partial bind vs full bind:
  * <p>
  * The payloads parameter is a merge list from {@link #notifyItemChanged(int, Object)} or
  * {@link #notifyItemRangeChanged(int, int, Object)}.  If the payloads list is not empty,
  * the ViewHolder is currently bound to old data and Adapter may run an efficient partial
  * update using the payload info.  If the payload is empty,  Adapter must run a full bind.
  * Adapter should not assume that the payload passed in notify methods will be received by
  * onBindViewHolder().  For example when the view is not attached to the screen, the
  * payload in notifyItemChange() will be simply dropped.
  *
  * @param holder The ViewHolder which should be updated to represent the contents of the
  *               item at the given position in the data set.
  * @param position The position of the item within the adapter's data set.
  * @param payloads A non-null list of merged payloads. Can be empty list if requires full
  *                 update.
  */
 public void onBindViewHolder(@NonNull VH holder, int position,
         @NonNull List<Object> payloads) {
     onBindViewHolder(holder, position);
 }

看到注释,说这个postion within the adapter’s data set。因此,我们点击事件就不能透传这个position.要改成下面的写法:

@Override
public void onBindViewHolder(@NonNull SimpleRecyclerViewHolder holder, int position) {
    RecyclerItem item = recyclerItemList.get(holder.getLayoutPosition());
    holder.iconView.setImageResource(item.getIcon());
    holder.textView.setText(item.getText());
    holder.itemView.setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            if (onItemCLickListener != null) {
                onItemCLickListener.onItemClick(item, holder, holder.getLayoutPosition());
            }
        }
    });
}

再测试:
点击错位修复成功
完美解决。

那么除了上面这种比较源码流的解决办法,还有别的解决办法吗?
答案是有的,而且还有两种:
1、不采用getLayoutPosition,依旧使用getAdapterPosition.那么我们的onSwiped方法里面需要修改为:

@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
    //1、删除源数据
    int pos = viewHolder.getAdapterPosition();
    RecyclerItem item = recyclerItemList.get(pos);
    recyclerItemList.remove(item);
    //2、通知视图刷新
    recyclerView.getAdapter().notifyItemRemoved(viewHolder.getAdapterPosition());
    //3、更正position,防止点击时,position错乱
    recyclerView.getAdapter().notifyItemRangeChanged(0, recyclerItemList.size());
}

新增了第三步,调用了notifyItemRangeChanged,让我们看看这个方法:

/**
  * Notify any registered observers that the <code>itemCount</code> items starting at
  * position <code>positionStart</code> have changed.
  * Equivalent to calling <code>notifyItemRangeChanged(position, itemCount, null);</code>.
  *
  * <p>This is an item change event, not a structural change event. It indicates that
  * any reflection of the data in the given position range is out of date and should
  * be updated. The items in the given range retain the same identity.</p>
  *
  * @param positionStart Position of the first item that has changed
  * @param itemCount Number of items that have changed
  *
  * @see #notifyItemChanged(int)
  */
 public final void notifyItemRangeChanged(int positionStart, int itemCount) {
     mObservable.notifyItemRangeChanged(positionStart, itemCount);
 }

通知所有注册的被观察条目更新位置。第一个参数传我们需要更新的起始位置,在这里我们从第一个开始更新(其实可以算一下,从点击的那个位置开始).第二个,需要更新的条目个数,既然一开始我们从第一个开始更新,那么个数就是所有条目,变相为全量更新。
既然想到了全量更新,那么自然就会想到第三种解决办法。

2、采用notifyDataSetChanged全量更新,将代码改为:

@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
   recyclerItemList.remove(viewHolder.getAdapterPosition());
   recyclerView.getAdapter().notifyDataSetChanged();
}

其实notifyDataSetChanged可以看作notifyItemRemoved(notifyItemInserted)和notifyItemRangeChanged的集合。代码量最少,但是属于全量更新,且没有了notifyItemRemoved(notifyItemInserted)的动画

源码地址:GitHub
更多RecyclerView的探讨文章,可关注本人其他博客。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值