基于EdgeEffect实现RecyclerView列表阻尼滑动效果

5,395 阅读3分钟

探索EdgeEffect的花样玩法

1、EdgeEffect是什么

当用户在一个可滑动的控件内(如RecyclerView),滑动内容已经超过了内容边界时,RecyclerView通过EdgeEffect绘制一个边界图形来提醒用户,滑动已经到边界了,不要再滑动啦。

简言之:就是通过边界图形来提醒用户,没啥内容了,别滑了。

2、EdgeEffect在RecyclerView的现象是什么

1、到达边界后的阴影效果

在RecyclerView列表中,滑动到边界还继续滑动或者快速滑动到边界,则现象如下图中的到达边界后产生的阴影效果。

滑动到边界阴影效果

2、如何去掉阴影效果

在布局中,可以设置overScrollMode的属性值为never即可。

或者在代码中设置,即可取消

recyclerView?.overScrollMode = View.OVER_SCROLL_NEVER

3、EdgeEffect在RecyclerView的实现原理是什么

1、onMove事件对应EdgeEffect的onPull

EdgeEffect在RecyclerView中大致流程可以参考下面这个图,以onMove事件举例

EdgeEffect与RecyclerView交互图

通过上面这个图,并结合下面的源码,就能对这个流程有个大致的理解。

@Override
public boolean onTouchEvent(MotionEvent e) {
    ...
    switch (action) {
       ...
        case MotionEvent.ACTION_MOVE: {
          ...
          // (1) move事件
                if (scrollByInternal(
                        canScrollHorizontally ? dx : 0,
                        canScrollVertically ? dy : 0,
                        e, TYPE_TOUCH)) {
                    getParent().requestDisallowInterceptTouchEvent(true);
                }
               ...
            }
        }
        break;
     }
 }
 
 
boolean scrollByInternal(int x, int y, MotionEvent ev, int type) {
  ...
     // (2)判断是否设置了过度滑动,所以通过布局设置overScrollMode的属性值为never就走不进了分支逻辑中了
    if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
        if (ev != null && !MotionEventCompat.isFromSource(ev, InputDevice.SOURCE_MOUSE)) {
            pullGlows(ev.getX(), unconsumedX, ev.getY(), unconsumedY);
        }
        considerReleasingGlowsOnScroll(x, y);
    }
   ...
   
    if (!awakenScrollBars()) {
        // 刷新当前界面
        invalidate();
    }
    return consumedNestedScroll || consumedX != 0 || consumedY != 0;
}

private void pullGlows(float x, float overscrollX, float y, float overscrollY) {
    boolean invalidate = false;
   ...
   // 顶部边界
    if (overscrollY < 0) {
        // 构建顶部边界的EdgeEffect对象
        ensureTopGlow();
        // 调用EdgeEffect的onPull方法 设置些属性
        EdgeEffectCompat.onPull(mTopGlow, -overscrollY / getHeight(), x / getWidth());
        invalidate = true;
    } 
    ...

    if (invalidate || overscrollX != 0 || overscrollY != 0) {
        // 刷新界面
        ViewCompat.postInvalidateOnAnimation(this);
    }
}

void ensureTopGlow() {
   ...
    mTopGlow = mEdgeEffectFactory.createEdgeEffect(this, EdgeEffectFactory.DIRECTION_TOP);
    // 设置边界图形的大小
    if (mClipToPadding) {
        mTopGlow.setSize(getMeasuredWidth() - getPaddingLeft() - getPaddingRight(),
                getMeasuredHeight() - getPaddingTop() - getPaddingBottom());
    } else {
        mTopGlow.setSize(getMeasuredWidth(), getMeasuredHeight());
    }

}

// RecyclerView的绘制
@Override
public void draw(Canvas c) {
    super.draw(c);
    ...
    if (mTopGlow != null && !mTopGlow.isFinished()) {
        final int restore = c.save();
        if (mClipToPadding) {
            c.translate(getPaddingLeft(), getPaddingTop());
        }
        // 调用 EdgeEffect的draw方法
        needsInvalidate |= mTopGlow != null && mTopGlow.draw(c);
        c.restoreToCount(restore);
    }
    ...
}

// EdgeEffect的draw方法
public boolean draw(Canvas canvas) {
   ...
    update();
    final int count = canvas.save();
    final float centerX = mBounds.centerX();
    final float centerY = mBounds.height() - mRadius;
   
    canvas.scale(1.f, Math.min(mGlowScaleY, 1.f) * mBaseGlowScale, centerX, 0);

    final float displacement = Math.max(0, Math.min(mDisplacement, 1.f)) - 0.5f;
    float translateX = mBounds.width() * displacement / 2;

    canvas.clipRect(mBounds);
    canvas.translate(translateX, 0);
    mPaint.setAlpha((int) (0xff * mGlowAlpha));
    // 绘制扇弧
    canvas.drawCircle(centerX, centerY, mRadius, mPaint);
    canvas.restoreToCount(count);
        ...

同理:RecyclerView的 up 及Cancel事件对应调用EdgeEffect的onRelease;fling过度滑动对应EdgeEffect的onAbsorb方法

2、EdgeEffect的onPull、onRelease、onAbsorb方法

(1)onPull

对于RecyclerView列表而言,内容已经在顶部到达边界了,此时用户仍向下滑动时,会调用onPull方法及后续流畅,来更新当前视图,提示用户已经到边界了。

(2)onRelease

对于(1)的情况,用户松开了,不向下滑动了,此时释放拉动的距离,并刷新界面消失当前的图形界面。

(3)onAbsorb

用户过度滑动时,RecyclerView调用Fling方法,把内容到达边界后消耗不掉的距离传递给onAbsorb方法,让其显示图形界面提示用户已到达内容边界。

4、使用EdgeEffect在RecyclerView中实现列表阻尼滑动等效果

(1)先看下效果

EdgeEffect的录屏

上述gif图中展示了两个效果:RecyclerView的阻尼下拉 及 复位,这就是使用上面的EdgeEffect的三个方法可以实现。

上述的gif图中,使用MultiTypeAdapter实现RecyclerView的多类型页面(ViewModel、json数据源),可以参考这篇文章快速写个RecyclerView的多类型页面

下面主要展示如何构建一个EdgeEffect,充分地使用onPull、onRelease及onAbsorb能力

(2)代码示意

// 构建一个自定义的EdgeEffectFactory 并设置给RecyclerView
recyclerView?.edgeEffectFactory = SpringEdgeEffect()

// SpringEdgeEffect
class SpringEdgeEffect : RecyclerView\.EdgeEffectFactory() {

        override fun createEdgeEffect(recyclerView: RecyclerView, direction: Int): EdgeEffect {

            return object : EdgeEffect(recyclerView.context) {

                override fun onPull(deltaDistance: Float) {
                    super.onPull(deltaDistance)
                    handlePull(deltaDistance)
                }

                override fun onPull(deltaDistance: Float, displacement: Float) {
                    super.onPull(deltaDistance, displacement)
                    handlePull(deltaDistance)
                }

                private fun handlePull(deltaDistance: Float) {
                    val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
                    val translationYDelta =
                        sign * recyclerView.width * deltaDistance * 0.8f
                    Log.d("qlli1234-pull", "deltDistance: " + translationYDelta)
                    recyclerView.forEach {
                        if (it.isVisible) {
                        // 设置每个RecyclerView的子item的translationY属性
                            recyclerView.getChildViewHolder(it).itemView.translationY += translationYDelta
                        }
                    }
                }

                override fun onRelease() {
                    super.onRelease()
                    Log.d("qlli1234-onRelease", "onRelease")
                    recyclerView.forEach {
                        //复位
                        val animator = ValueAnimator.ofFloat(recyclerView.getChildViewHolder(it).itemView.translationY, 0f).setDuration(500)
                        animator.interpolator = DecelerateInterpolator(2.0f)
                        animator.addUpdateListener { valueAnimator ->
                            recyclerView.getChildViewHolder(it).itemView.translationY = valueAnimator.animatedValue as Float
                        }
                        animator.start()
                    }
                }

                override fun onAbsorb(velocity: Int) {
                    super.onAbsorb(velocity)
                    val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
                    Log.d("qlli1234-onAbsorb", "onAbsorb")
                    val translationVelocity = sign * velocity * FLING_TRANSLATION_MAGNITUDE
                    recyclerView.forEach {
                        if (it.isVisible) {
                          // 在这个可以做动画
                        }
                    }
                }

                override fun draw(canvas: Canvas?): Boolean {
                    // 设置大小之后,就不会有绘画阴影效果
                    setSize(0, 0)
                    val result =  super.draw(canvas)
                    return result
                }
            }
    }

这里有一个小细节,如何在使用onPull等方法时,去掉绘制的阴影部分:其实,可以重写draw方法,重置大小为0即可,如上述代码中的这一小块内容:

override fun draw(canvas: Canvas?): Boolean {
    // 设置大小之后,就不会有绘画阴影效果
    setSize(0, 0)
    val result =  super.draw(canvas)
    return result
}

5、参考

1、google的motion示例中的ChessAdapter内容

2、仿QQ的recyclerview效果实现