RecyclerView详解三,ItemTouchHelper分析

本文承接上一篇RecyclerView详解二

五、ItemTouchHelper

ItemTouchHelper 这个类是我们用来给表项添加各种修饰的帮助类,我们可以用它来实现表项的侧滑删除和拖拽等效果。对于这部分内容,我会先讲一点应用,然后从应用入手跟着源码逐步分析原理。

(1)ItemTouchHelper基本使用

首先,实现一个 Callback 继承自 ItemTouchHelper.Callback。重写它的几个重要函数。

class RecyclerTouchHelpCallBack(var onCallBack: OnHelperCallBack) : ItemTouchHelper.Callback() {
    
    var edit = false
    // 该函数是用来设置滑动方向的,比如下面的 dragFlags 和 swipeFlags
    override fun getMovementFlags(
		recyclerView: RecyclerView
    	viewHolder: RecyclerView.V
	): Int {
    	if (!edit) {
     	   return 0
    	}
    	// 拖拽方向(上下左右)
       	val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN 
        	or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT 
        
        // 侧滑删除(左右)
        val swipeFlags = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
        
        return makeMovementFlags(dragFlags, swipeFlags)
	}
    
    // 该方法主要用来通知你拖拽的item从哪里移动到了哪里
    override fun onMove(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder,
        target: RecyclerView.ViewHolder
    ): Boolean {
        // 如果当前拖拽的item与目标item的类型不一样,将无法交换位置
        if (viewHolder.itemViewType != target.itemViewType) return false

        val fromPosition = viewHolder.adapterPosition
        val targetPosition = target.adapterPosition

        onCallBack.onMove(fromPosition, targetPosition)
        return true
    }
    
    // 侧滑的部分我们以删除为例
    override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        // 调用Callback的remove将侧滑删除的item移出列表
        onCallBack.remove(viewHolder, direction, viewHolder.layoutPosition)
    }
    
    fun itemMove(adapter: RecyclerView.Adapter<RecyclerView.ViewHolder>, data: List<*>, fromPosition: Int, targetPosition: Int) {
        if (data.isEmpty()) {
            return
        }

        if (fromPosition < targetPosition) {
            for (i in fromPosition until targetPosition) {
                Collections.swap(data, i, i + 1)
            }
        } else {
            for (i in targetPosition until fromPosition) {
                Collections.swap(data, i, i + 1)
            }
        }

        adapter.notifyItemMoved(fromPosition, targetPosition)
    }
    ...
    // 后面还有一些功能函数我就不一一列举了,知道这么个流程就行
}

上述代码中,主要实现的有三个函数,第一个就是 getMovementFlags() ,该方法主要用来设置拖拽和侧滑的方向,第二个是 onMove() ,该方法用来设置拖拽的 item 的起始位置和目的地。然后通过 itemMove() 方法将拖拽的 item 移动到目的地,剩下的 item 依次前移或后移。第三个就是 onSwiped() 用于实现侧滑的方法。

接下里就是Activity中的代码了,我只展示其中主要的一些代码。

callback = RecyclerTouchHelpCallBack(object : RecyclerTouchHelpCallBack.OnHelperCallBack {
    override fun onMove(fromPosition: Int, targetPosition: Int) {
        // 移动item
        callback.itemMove(adapter, adapter.mData, fromPosition, targetPosition)
    }

    override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder, actionState: Int) {
        // 选中的改变样式
        viewHolder.itemView.alpha = 1f
        viewHolder.itemView.scaleX = 1.2f
        viewHolder.itemView.scaleY = 1.2f
    }

    override fun clearView(
        recyclerView: RecyclerView,
        viewHolder: RecyclerView.ViewHolder
    ) {
        callback.edit = false
        // 完成移动,选中的改变样式
        adapter.mData
        viewHolder.itemView.alpha = 1f
        viewHolder.itemView.scaleY = 1f
        viewHolder.itemView.scaleX = 1f
    }

    override fun remove(
        viewHolder: RecyclerView.ViewHolder,
        direction: Int,
        position: Int
    ) {
    	// 
        adapter.removeData(position)
    }

})

ItemTouchHelper(callback).attachToRecyclerView(binding.rvItem)

这里的 Callback 就是为了实现我们定义的接口,然后具体实现其功能,其实最主要的还是最后一行代码,这一行的主要作用我一会会通过源码进行分析。

这就是 ItemTouchHelper 的基本使用,其实挺简单的,作用就是辅助 RecyclerView 对其子视图添加一些额外的功能。但我们学习嘛,就要知其然还要知其所以然。接下来我会从 attachToRecyclerView() 入手,这个就是入口方法,我们逐步分析实现原理。

(2)ItemTouchHelper原理

public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
	if (mRecyclerView == recyclerView) {
        // 如果已经赋值过了,就直接返回,不执行后面的操作
    	return; // nothing to do
    }
    // 如果 mRecyclerView 不为 null 就摧毁重置
    if (mRecyclerView != null) {
    	destroyCallbacks();
    }
    mRecyclerView = recyclerView;
    if (recyclerView != null) {
    	final Resources resources = recyclerView.getResources();
        mSwipeEscapeVelocity = resources
        		.getDimension(R.dimen.item_touch_helper_swipe_escape_velocity);
        mMaxSwipeVelocity = resources
                .getDimension(R.dimen.item_touch_helper_swipe_escape_max_velocity);
        setupCallbacks();
    }
}

这个方法就是将我们自己的 RecyclerView 与源码中的 RV 绑定,然后对其进行一系列的操作。最后,该方法继续调用 setupCallbacks()

private void setupCallbacks() {
    ViewConfiguration vc = ViewConfiguration.get(mRecyclerView.getContext());
    // 注意这个变量 mSlop 后面会提
    mSlop = vc.getScaledTouchSlop();
    mRecyclerView.addItemDecoration(this);
    // 添加 Item 触摸监听器,后面会用到,里面包含 onInterceptTouchEvent()、onTouchEvent()
    mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
    mRecyclerView.addOnChildAttachStateChangeListener(this);
    // 启动手势检测,检测是不是长按操作
    startGestureDetection();
}

我们沿着调用链一步一步走,startGestureDetection() 启动手势检测

private void startGestureDetection() {
    // new 一个监听器对象,对用户手势进行监听
    mItemTouchHelperGestureListener = new ItemTouchHelperGestureListener();
    mGestureDetector = new GestureDetectorCompat(mRecyclerView.getContext(),
            mItemTouchHelperGestureListener);
}

这就是检测是否为长按动作的具体方法

private class ItemTouchHelperGestureListener extends GestureDetector.SimpleOnGestureListener {
    ...
    @Override
	public void onLongPress(MotionEvent e) {
    	if (!mShouldReactToLongPress) {
      		return;
    	}
        // 先找到子视图,onLongPress()在 GestureDetectorCompat类中调用,
        // 然后将e传进来,通过刚才的startGestureDetection方法
        // findChildView()就是通过获取event坐标定位到子视图的
    	View child = findChildView(e);
    	if (child != null) {
            // 获取子视图对应的ViewHolder,接下来对ViewHolder进行操作
        	ViewHolder vh = mRecyclerView.getChildViewHolder(child);
        	if (vh != null) {
         		if (!mCallback.hasDragFlag(mRecyclerView, vh)) {
         	    	return;
         	   	}
                // 对这个pointerId不了解的请移步最后的补充项
            	int pointerId = e.getPointerId(0);
            	// Long press is deferred.
            	// Check w/ active pointer id to avoid selecting after motion
            	// event is canceled.
            	if (pointerId == mActivePointerId) {
               		final int index = e.findPointerIndex(mActivePointerId);
               	 	final float x = e.getX(index);
               	 	final float y = e.getY(index);
                	mInitialTouchX = x;
                	mInitialTouchY = y;
                	mDx = mDy = 0f;
                	if (DEBUG) {
                    	Log.d(TAG,
                        		"onlong press: x:" + mInitialTouchX + ",y:" + mInitialTouchY);
                	}
                	if (mCallback.isLongPressDragEnabled()) {
                        // 通过select()方法选中当前ViewHolder,以便对其进行拖拽操作
                        // select()方法主要就是选中功能
                        // 将 FLAG = ACTION_STATE_DRAG 传入,代表是拖拽操作
                   		select(vh, ACTION_STATE_DRAG);
                	}
            	}
        	}
    	}
	}
}

我们可以看一下 findChildView() 是怎么进行寻找子视图的。

View findChildView(MotionEvent event) {
    // first check elevated views, if none, then call RV
    // 获取触摸点相对于view的坐标
    final float x = event.getX();
    final float y = event.getY();
    // mSelected != null 即当前选中了一个子视图
    if (mSelected != null) {
    	// 获取选中的ViewHolder对应的View,然后将其返回,这样就找到的View实例
        // mSelected是一个ViewHolder对象
        final View selectedView = mSelected.itemView;
        if (hitTest(selectedView, x, y, mSelectedStartX + mDx, mSelectedStartY + mDy)) {
            return selectedView;
        }
    }
    ...
    // 我们先只考虑选中的情况
}

上面的 onLongPress() 是我们用来监听手指是否进行了长按的操作,同样的,肯定还有方法可以监听手指是否进行了侧滑操作,从源码中看,我们发现是 checkSelectForSwipe() 进行侧滑操作的判断。

void checkSelectForSwipe(int action, MotionEvent motionEvent, int pointerIndex) {
    // 一些判断是否是侧滑操作的代码,无关紧要,如果不是就返回
    if (mSelected != null || action != MotionEvent.ACTION_MOVE
    		|| mActionState == ACTION_STATE_DRAG || !mCallback.isItemViewSwipeEnabled()) {
    	return;
    }
    if (mRecyclerView.getScrollState() == RecyclerView.SCROLL_STATE_DRAGGING) {
    	return;
    }
    ...
    // mDx and mDy are only set in allowed directions. We use custom x/y here instead of
    // updateDxDy to avoid swiping if user moves more in the other direction
    final float x = motionEvent.getX(pointerIndex);
    final float y = motionEvent.getY(pointerIndex);

    // Calculate the distance moved -> 计算滑动距离
    final float dx = x - mInitialTouchX;
    final float dy = y - mInitialTouchY;
    // swipe target is chose w/o applying flags so it does not really check if swiping in that
    // direction is allowed. This why here, we use mDx mDy to check slope value again.
    final float absDx = Math.abs(dx);
    final float absDy = Math.abs(dy);
	
    // 这里的 mSlop 在上面提到的 setupCallbacks() 中被赋值
    // 主要用于解决滑动冲突,如果我们侧滑的距离不够则不进行下一步操作(如删除),直接返回
    // 只有滑动的距离大于 mSlop 值,才继续执行
    // 说起来,和我们的日常习惯对上了
    if (absDx < mSlop && absDy < mSlop) {
        return;
    }
    // 下面一部分代码也是判断滑动距离的
    ...
    // 这里也通过 select() 方法进行选中操作
    // 将 FLAG = ACTION_STATE_SWIPE 传入,代表是侧滑操作
    select(vh, ACTION_STATE_SWIPE);
}

好了,了解了如何判断手势进行了何种操作,接下来我们该重点研究 select() 方法了,这是一个十分重要的方法,我们需要通过它来选中我们想要进行拖拽或侧滑操作的View。然后对其进行具体的操作。

void select(@Nullable ViewHolder selected, int actionState) {
    // 如果已经选中就直接返回
    if (selected == mSelected && actionState == mActionState) {
        return;
    }
    ...
    // 赋值操作,之前的状态
    final int prevActionState = mActionState;
    mActionState = actionState;
    // 执行当前状态为'拖拽'的操作
    if (actionState == ACTION_STATE_DRAG) {
        // 如果没有选中一个item,就抛出异常
        if (selected == null) {
            throw new IllegalArgumentException("Must pass a ViewHolder when dragging");
        }
		// 为选中的子视图设置绘制监听,主要为了设置绘制的顺序
        mOverdrawChild = selected.itemView;
        addChildDrawingOrderCallback();
    }
    ...
    if (mSelected != null) {
        final ViewHolder prevSelected = mSelected;
        if (prevSelected.itemView.getParent() != null) {
            // 如果之前的状态是拖拽,则给swipeDir赋值为0,否则执行swipeIfNecessary()操作
            final int swipeDir = prevActionState == ACTION_STATE_DRAG ? 0
                    : swipeIfNecessary(prevSelected);
            int animationType;
            ...
            // 执行一系列判断操作,代码简单我就不过多解释了
            if (prevActionState == ACTION_STATE_DRAG) {
                animationType = ANIMATION_TYPE_DRAG;
            } else if (swipeDir > 0) {
                animationType = ANIMATION_TYPE_SWIPE_SUCCESS;
            } else {
                animationType = ANIMATION_TYPE_SWIPE_CANCEL;
            }
            ...
        } else {
            removeChildDrawingOrderCallbackIfNecessary(prevSelected.itemView);
            mCallback.clearView(mRecyclerView, prevSelected);
        }
        // 将选中状态置为空
        mSelected = null;
    }
    if (selected != null) {
        mSelectedFlags =
                (mCallback.getAbsoluteMovementFlags(mRecyclerView, selected) & actionStateMask)
                        >> (mActionState * DIRECTION_FLAG_COUNT);
        mSelectedStartX = selected.itemView.getLeft();
        mSelectedStartY = selected.itemView.getTop();
        mSelected = selected;

        if (actionState == ACTION_STATE_DRAG) {
            mSelected.itemView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
        }
    }
    ...
    // 这是一个ItemTouchUIUtil的一个接口,需要我们自己来实现具体功能,比如选中改变item的样式
    mCallback.onSelectedChanged(mSelected, mActionState);
    // 刷新拖拽或侧滑更新后的位置
    mRecyclerView.invalidate();
}

我们自己实现的 onSelectedChanged() 如下:

// 长按时调用
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
	super.onSelectedChanged(viewHolder, actionState)
    viewHolder?.let {
    	onCallBack.onSelectedChanged(viewHolder, actionState)
    }
}

override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder, actionState: Int) {
	// 选中的改变样式
	viewHolder.itemView.alpha = 1f
	viewHolder.itemView.scaleX = 1.2f
	viewHolder.itemView.scaleY = 1.2f
}

好了,看了这么多源码以及我对部分源码的讲解,我们现在来总结一下整个流程,这个过程也是在教大家如何看源码,在源码中找到关键之处。

我们通过ItemTouchHelper实现拖拽,侧滑功能,为了实现这两个功能,我们得先要将我们自己的RecyclerView和源码中的绑定,然后进行手势操作的监听,监听我们对item进行了何种操作,是侧滑操作还是长按拖拽操作。我们记得是通过 onLongPress()checkSelectForSwipe() 两个方法来判断的,判断之后,两个方法都调用了 select() 来选中执行的 Item。关于 select() 方法呢,我还有一点补充:

  1. 如果处于手势开始阶段,即selected不为null,那么会通过getAbsoluteMovementFlags方法来获取执行我们设置的flag,这个方法就是我们通过 checkSelectForSwipe()onLongPress() 传进来的,上面也讲到了。这样我们就知道执行哪些行为(侧滑或者拖动)和方向(上、下、左和右)。同时还会记录下被选中ItemView的位置。简而言之,就是一些变量的初始化。

  2. 如果处于手势释放阶段,即selected为null,同时mSelected不为null,那么此时需要做的事情就稍微有一点复杂。手势释放之后,需要做的事情无非有两件:1. 相关的ItemView到正确的位置,就比如说,如果滑动条件不满足,那么就返回原来的位置,这个就是一个动画;2. 清理操作,比如说将mSelected重置为null之类的。

到了这里,我们已经可以对自己的 RecyclerView 的 Item 选中并进行下一步操作了。我们先带着一个疑问来继续分析,那就是我们怎么让选中的 Item 跟随我们的手指进行移动呢?

我们知道,View 的 onTouchEvent() 方法是专门对触摸事件进行操作的,那我们就从源码中找到这个方法。

public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent event) {
    ...
    // 如果速度轨迹不为空,就是当前有触摸事件,将该event添加进去
    if (mVelocityTracker != null) {
        mVelocityTracker.addMovement(event);
    }
    // 当前的手势操作ID为NONE,直接返回
    if (mActivePointerId == ACTIVE_POINTER_ID_NONE) {
        return;
    }
    // 获取该event的Id,对MotionEvent不太懂的移步 -> '补充'
    final int action = event.getActionMasked();
    final int activePointerIndex = event.findPointerIndex(mActivePointerId);
    // 进行是否为侧滑事件的判断
    if (activePointerIndex >= 0) {
        checkSelectForSwipe(action, event, activePointerIndex);
    }
    ViewHolder viewHolder = mSelected;
    // 如果没选中 ViewHolder 直接返回
    if (viewHolder == null) {
        return;
    }
    switch (action) {
        case MotionEvent.ACTION_MOVE: {
            // Find the index of the active pointer and fetch its position
            if (activePointerIndex >= 0) {
                updateDxDy(event, mSelectedFlags, activePointerIndex);
                moveIfNecessary(viewHolder);
                mRecyclerView.removeCallbacks(mScrollRunnable);
                mScrollRunnable.run();
                mRecyclerView.invalidate();
            }
            break;
        }
        // 被其他事件拦截的处理,将轨迹清除
        case MotionEvent.ACTION_CANCEL:
            if (mVelocityTracker != null) {
                mVelocityTracker.clear();
            }
        // fall through
        // 手指抬起,将Id置为NONE,退出switch
        case MotionEvent.ACTION_UP:
            select(null, ACTION_STATE_IDLE);
            mActivePointerId = ACTIVE_POINTER_ID_NONE;
            break;
        ...
    }
}

对 onTouchEvent() 我对这个方法讲的很清楚,也相信大家明白了大致流程,其中对我们来说最重要的就是 ACTION_MOVE ,在这里面的就是我们手指滑动过程中进行的。我们对这里面的代码一行一行分析。

case MotionEvent.ACTION_MOVE: {
    // Find the index of the active pointer and fetch its position
    if (activePointerIndex >= 0) {
        updateDxDy(event, mSelectedFlags, activePointerIndex);
        moveIfNecessary(viewHolder);
        mRecyclerView.removeCallbacks(mScrollRunnable);
        mScrollRunnable.run();
        mRecyclerView.invalidate();
    }
    break;
}

第一步:更新mDxmDy的值。mDxmDy表示手指在x轴和y轴上分别滑动的距离,将mSelectedFlagsactivePointerIndex作为参数传过去,第一个代表选中的操作,是侧滑还是拖拽;第二个是当前触摸事件的Id,唯一标识。

第二步:如果需要,移动其他ItemView的位置。这个主要针对拖动行为,我们具体来看看这部分的源码。

void moveIfNecessary(ViewHolder viewHolder) {
    // 下面这些代码都是不符合 move 的条件
    ...
    // 找可能会交换位置的ItemView
    List<ViewHolder> swapTargets = findSwapTargets(viewHolder);
    if (swapTargets.size() == 0) {
    	return;
    }
    // may swap. -> 找到符合条件交换的ItemView
    ViewHolder target = mCallback.chooseDropTarget(viewHolder, swapTargets, x, y);
    if (target == null) {
        mSwapTargets.clear();
        mDistances.clear();
        return;
    }
    final int toPosition = target.getAdapterPosition();
    final int fromPosition = viewHolder.getAdapterPosition();
    // 回调Callback里面的onMove方法,这个方法需要我们手动实现,在基本使用里有,大家可以上去翻翻
    if (mCallback.onMove(mRecyclerView, viewHolder, target)) {
        // keep target visible
        mCallback.onMoved(mRecyclerView, viewHolder, fromPosition,
                target, toPosition, x, y);
    }
}

总的来说,分为三步:

  1. 调用findSwapTarget方法,寻找可能会跟选中的Item交换位置的Item。这里判断的条件是只要选中的Item跟某一个Item重叠,那么这个Item可能会跟选中的Item交换位置。

  2. 调用Callback的chooseDropTarget方法来找到符合交换条件的Item。这里符合的条件是指,选中的Itembottom大于目标Itembottom或者Itemtop大于目标Itemtop。一般我们可以重写chooseDropTarget方法,来定义什么条件下就交换位置。

  3. 回调CallbackonMove方法,这个方法需要我们自己实现。这里需要注意的是,如果onMove方法返回为true的话,会调用Callback另一个onMove方法来保证target可见。为什么必须保证target可见呢?从官方文档上来看的话,如果target不可见,在某些滑动的情形下,target会被remove掉。

刚才说,findSwapTargets() 是找到可能会交换位置的item,而chooseDropTarget()是找到会交换位置的item就直接交换,听起来好抽象,那二者具体有什么区别呢?其中findSwapTarget方法是找到可能会交换位置的ItemViewchooseDropTarget方法是找到一定会交换位置的ItemView,这是两个方法的不同点。同时,如果此时在拖动,但是拖动的ItemView还未达到交换条件,也就是跟另一个ItemView只是重叠了一小部分,这种情况下,findSwapTargets方法返回的集合不为空,但是chooseDropTarget方法寻找的ItemView为空。

第三步:如果当页展示的Item不符合条件,需要拖拽到更远的地方,这时就需要滑动RecyclerView。这个主要针对拖拽行为,此时如果拖动一个ItemView达到RecyclerView的底部或者顶部,会滑动RecyclerView

final Runnable mScrollRunnable = new Runnable() {
    @Override
    public void run() {
        if (mSelected != null && scrollIfNecessary()) {
            if (mSelected != null) { //it might be lost during scrolling
                moveIfNecessary(mSelected);
            }
            mRecyclerView.removeCallbacks(mScrollRunnable);
            ViewCompat.postOnAnimation(mRecyclerView, this);
        }
    }
};

run方法里面通过scrollIfNecessary方法来判断RecyclerView是否滚动,如果需要滚动,scrollIfNecessary方法会自动完成滚动操作。

第四步:这步就是更新侧滑或者拖拽完成之后的视图了。ItemView在随着手指移动时,变化的是translationXtranslationY两个属性,所以只需要调用invalidate方法就行。调用invalidate方法之后,相当于RecyclerView会重新绘制一次,那么所有ItemDecorationonDrawonDrawOver方法都会被调用,而恰好的是,ItemTouchHelper继承了ItemDecoration。而绘制的方法就是 onDraw() 。我们具体来看一下。

public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
    // we don't know if RV changed something so we should invalidate this index.
    mOverdrawChildPosition = -1;
    float dx = 0, dy = 0;
    if (mSelected != null) {
        getSelectedDxDy(mTmpPosition);
        dx = mTmpPosition[0];
        dy = mTmpPosition[1];
    }
    // 这里又调用了 Callback 的 onDraw 方法
    mCallback.onDraw(c, parent, mSelected,
            mRecoverAnimations, mActionState, dx, dy);
}
void onDraw(Canvas c, RecyclerView parent, ViewHolder selected,
        List<ItemTouchHelper.RecoverAnimation> recoverAnimationList,
        int actionState, float dX, float dY) {
    final int recoverAnimSize = recoverAnimationList.size();
    ...
    if (selected != null) {
        final int count = c.save();
        onChildDraw(c, parent, selected, dX, dY, actionState, true);
        c.restoreToCount(count);
    }
}

调用onChildDraw方法,将所有正在交换位置的ItemView和被选中的ItemView作为参数传递过去。而在onChildDraw方法里面,调用了ItemTouchUIUtilonDraw方法。我们从ItemTouchUiUtil的实现类BaseImpl找到答案:

@Override
public void onDraw(Canvas c, RecyclerView recyclerView, View view,
	float dX, float dY, int actionState, boolean isCurrentlyActive) {
    view.setTranslationX(dX);
    view.setTranslationY(dY);
}

在这里改变了每个ItemViewtranslationXtranslationY,从而实现了ItemView随着手指移动的效果。从这里,我们可以看出来,一旦调用RecyclerViewinvalidate方法,ItemTouchHelperonDraw方法和onDrawOver方法都会被执行。

六、补充

我找到了一些题外知识,但与RV有关的文章,对于前面有些内容不懂的小伙伴可以参考一下~

(1)MotionEvent详解

(2)ListView和RecyclerView缓存对比

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

挽弦慕笙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值