仿ListView优雅实现RecyclerView的OnItemClickListener

1,333 阅读4分钟

在之前使用RecyclerView的时候,实现对RecyclerView中每一个item的点击事件一直是在Adapter的onBindViewHolder中为其itemView添加onClickListener来实现的,这种实现方式有以下问题:

  • 不够优雅:这种方式需要使用adapter来set点击事件,但是理应是由RecyclerView来添加监听器才符合认知,因此这种方式太难看;
  • 性能问题:最大的问题处在于这是为每一个item都添加了一个OnClickListener,虽然RecyclerView中有缓存机制,但还是会产生多个OnClickListener。

为了解决这个问题,阅读ListView的实现发现,在ListView中,通过以下方式确定点击的位置:

点击区域 -> 定位child -> 对应在Adapter中的position

在View的TouchEvent过程中,第一个处理的总是DOWN事件,因此首先来看看ListView是如何处理DOWN事件的:

// AbsListView.java
private void onTouchDown(MotionEvent ev) {
        ..

        if (mTouchMode == TOUCH_MODE_OVERFLING) {
            ...
        } else {
            final int x = (int) ev.getX();
            final int y = (int) ev.getY();
            
            // 正是通过该关键方法来定位到点击区域所对应哪一个Child 的
            int motionPosition = pointToPosition(x, y);


            ...
        }

        ...
    }

上面注释的方法正式根据MotionEvent来定位child 的关键方法:

// AbsListView.java
public int pointToPosition(int x, int y) {
        Rect frame = mTouchFrame;
        if (frame == null) {
            mTouchFrame = new Rect();
            frame = mTouchFrame;
        }

        final int count = getChildCount();
        for (int i = count - 1; i >= 0; i--) {
            final View child = getChildAt(i);
            if (child.getVisibility() == View.VISIBLE) {
                child.getHitRect(frame);
                if (frame.contains(x, y)) {
                    
                    // AbsListView的mFirstPosition很关键
                    return mFirstPosition + i;
                }
            }
        }
        return INVALID_POSITION;
    }

我去,原来这么简单就能找到对应的child,值得注意的是,这里该方法直接返回的是该child在Adapter中对应的position,得益于AbsListView维护的mFirstPosition属性,改属性表示当前屏幕所展示的item中第一个item对应在Adapter中的position。即在第一屏数据加载好时该属性为0,当向上滑动手指,列表下滚导致滚出屏幕3个(对应0,1,2),那么此时mFirstPosition就为3。

紧接着,处理UP事件的方法如下:

//AbsListView.java

private void onTouchUp(MotionEvent ev) {
        switch (mTouchMode) {
            case TOUCH_MODE_DOWN:
            case TOUCH_MODE_TAP:
            case TOUCH_MODE_DONE_WAITING:
                
                // 获取在DOWN事件时得到的mMotionPosition
                final int motionPosition = mMotionPosition;
                
                    ...
                    if (inList && !child.hasExplicitFocusable()) {
                        if (mPerformClick == null) {
                            mPerformClick = new PerformClick();
                        }

                        final AbsListView.PerformClick performClick = mPerformClick;
                        
                        // 将motionPosition保存了下来
                        performClick.mClickMotionPosition = motionPosition;
                        ...
                            
                        else if (!mDataChanged && mAdapter.isEnabled(motionPosition)) {
                            // 执行
                            performClick.run();
                        }
                    }
                }
                ...

        ...
    }

该方法对UP事件的处理也很清晰明了,就是分两步:

  • PerformClick记录一下motionPosition;
  • 调用performClick.run执行点击事件

接下来就看看PerformClick:

// AbsListView.java
private class PerformClick extends WindowRunnnable implements Runnable {
        int mClickMotionPosition;

        @Override
        public void run() {
            // The data has changed since we posted this action in the event queue,
            // bail out before bad things happen
            if (mDataChanged) return;

            final ListAdapter adapter = mAdapter;
            final int motionPosition = mClickMotionPosition;
            if (adapter != null && mItemCount > 0 &&
                    motionPosition != INVALID_POSITION &&
                    motionPosition < adapter.getCount() && sameWindow() &&
                    adapter.isEnabled(motionPosition)) {
                
                // 根据position得到定位到的child
                final View view = getChildAt(motionPosition - mFirstPosition);
                if (view != null) {
                    
                    // 该方法内部会回调mOnItemClickListener的方法
                    performItemClick(view, motionPosition, adapter.getItemId(motionPosition));
                }
            }
        }
    }

可以发先,其原理就是根据position得到child,然后内部调用OnItemClickListener完成。

小结

可以发现ListView实现Item Click监听分如下几步:

  • 使用for循环对children进行遍历,找到MotionEvent点击区域所属的child;
  • 得到child所对应Adapter的position;
  • 调用OnItemClickListener监听。

为此,我们在RecyclerView也可以如法炮制。

Q1:如何定位child?

在ListView中,定位child的前提是能够捕捉到MotionEvent,根据点击事件的x和y确定child。这...难道要重写onTouchEvent?虽然只需要进行一点逻辑的添加,然后直接super.onTouchEvent就行了,但是再怎么省事儿终归要定义一个RecyclerView的子类来干这些啊?为了实现ItemClickListener这未必有些麻烦了。

唉,我们可以使用onTouchListener啊,这里也可以获取到MotionEvent,并且onTouchListener返回false的话,RecyclerView就会自动调用其完备的onTouchEvent处理事件。嗯,可行。

Q2:如何获取position?

在ListView中,position得益于AbsListView维护了mFirstPosition这个变量,那么结合for循环的index就可以得到child对应的position。同样类似,在RecyclerView.ViewHolder中也提供了相应的api -- getAdapterPosition()。同时,RecyclerView提供了将View转化为ViewHolder的api -- getChildViewHolder(View)。因此,也能够获取position了。

有了上述两个支持,开干!!

fun RecyclerView.setOnItemClickListener(onItemClickListener: (View, Int) -> Unit) {
    this.setOnTouchListener { v, event ->
        if (event.action == MotionEvent.ACTION_UP) {
            var frame = Rect()
            for (child in (v as RecyclerView).children) {
                if (child.visibility != View.VISIBLE)
                    continue
                
                // 模仿AbsListView定位hild
                child.getHitRect(frame)
                if(!frame.contains(event.x.toInt(), event.y.toInt()))
                    continue

                // 获取position
                val pos = this.getChildViewHolder(child).adapterPosition
                
                // 调用点击事件
                onItemClickListener(this, pos)
                break
            }
        }
                             
        // 返回false很关键,我们只是处理item点击,滚动等其他逻辑还要委托回onTouchEvent方法处理
        false
    }
}

得益于Kotlin对扩展的支持,上述函数定义使用起来就优雅多了:

recycler = findViewById(R.id.recycler)
recycler.adapter = TestAdapter()
recycler.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
recycler.setOnItemClickListener { v, pos -> 
         		Log.d("MainActivity", "========结果是:${position}")                       
         }

经过使用,上述代码确实可以完成效果,但是又产生了一个问题,就是当发生滚动、长按事件情况下,手指抬起也会出发,这就不应该了,为此,只能舍弃上面那种优雅的实现方式,专门定义一个Listener类来实现对时间类型的过滤,修改后的代码如下:

abstract class OnItemClickListener {
    var downX: Float = -1f
    var downY: Float = -1f
    var downTime: Long = 0L

    private fun reset() {
        downTime = 0L
        downX = -1f
        downY = -1f
    }
    
    // 用来记录down事件装填
    fun record(event: MotionEvent) {
        downX = event.x
        downY = event.y
        downTime = event.downTime
    }
    
    // 用来过滤掉长按事件与move可能产生的滚动事件
    fun performClick(event: MotionEvent, recyclerView: View, position: Int) {
        if (Math.abs(downX - event.x) < 8 && Math.abs(downY - event.y) < 8 && event.eventTime - downTime < 500)
            onItemClick(recyclerView, position)
        reset()
    }

    abstract fun onItemClick(recyclerView: View, position: Int)
}

// 实现类
class OnItemClickListenerImpl: OnItemClickListener() {
    override fun onItemClick(recyclerView: View, position: Int) {
        Log.d("MainActivity", "========结果是:${position}")
    }

}

扩展方法定义与使用如下:

fun RecyclerView.setOnItemClickListener(onItemClickListener: OnItemClickListener) {
    this.setOnTouchListener { v, event ->
        if (event.action == MotionEvent.ACTION_DOWN)
            onItemClickListener.record(event)
        else if (event.action == MotionEvent.ACTION_UP) {
            var frame = Rect()
            for (child in (v as RecyclerView).children) {
                if (child.visibility != View.VISIBLE)
                    continue
                child.getHitRect(frame)
                if(!frame.contains(event.x.toInt(), event.y.toInt()))
                    continue

                val pos = this.getChildViewHolder(child).adapterPosition
                onItemClickListener.performClick(event, this, pos)
                break
            }
        }
        false
    }
}

------------ 分割线 ----------------

recycler = findViewById(R.id.recycler)
recycler.adapter = TestAdapter()
recycler.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
recycler.setOnItemClickListener(OnItemClickListenerImpl())

这样一来,就完善了功能。

总结

通过参考ListView的OnItemClickListener设计,完成了对RecyclerView的OnItemClickListener设计,但是还有不足:

  • 对事件类型的过滤还不够严谨,这里只是最简单情况下的过滤;

另外,由于使用的是kotlin,所以可以通过函数扩展实现Recycler.setOnItemClickListener这种表现形式的方法调用,如果是使用Java的话,建议将相关方法设置到自定义Adapter中:

public class MyTestAdapter extends RecyclerView.Adapter<TestHolder> {

    private OnItemClickListener onItemClickListener;
    public void setOnItemClickListener(OnItemClickListener listener) {
        this.onItemClickListener = listener;
    }
    @Override
    public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
        super.onAttachedToRecyclerView(recyclerView);
        recyclerView.setOnTouchListener(new View.OnTouchListener() {
            @Override
            public boolean onTouch(View v, MotionEvent event) {
                if (event.getAction() == MotionEvent.ACTION_DOWN)
                    onItemClickListener.record(event);
                else if (event.getAction() == MotionEvent.ACTION_UP) {
                    Rect frame = new Rect();
                    for (int i = 0; i < recyclerView.getChildCount(); i ++) {
                        View child = recyclerView.getChildAt(i);
                        if (child.getVisibility() != View.VISIBLE)
                            continue;
                        child.getHitRect(frame);
                        if(!frame.contains((int)event.getX(), (int)event.getY()))
                            continue;
                        int pos = recyclerView.getChildViewHolder(child).getAdapterPosition();
                        onItemClickListener.performClick(event, recyclerView, pos);
                        break;
                    }
                }
                return false;
            }
        });
    }
    
    ....
}