定义列表的弹簧边缘效果

自定义弹簧边缘效果:RecyclerView 和 ListView 的解决方案

在开发 Android 应用时,为用户提供流畅且直观的交互体验是非常重要的。其中一种方法是通过自定义视图组件的滚动行为来增强用户体验。本文将介绍如何 为 RecyclerViewListView 实现类似弹簧的边缘效果。

一、RecyclerView 的自定义弹簧边缘效果

1、使用 EdgeEffectFactory 创建自定义边缘效果

EdgeEffectFactory 是 Android 支持库(现在是 AndroidX)中的一部分,用于在 RecyclerView 边缘被拉动时创建边缘效果。默认的边缘效果是一个阻尼效果,当用户滚动到列表或网格的顶部或底部并且继续尝试滚动时会看到这个效果。为了实现更吸引人的弹簧效果,我们可以扩展 RecyclerView.EdgeEffectFactory 并重写 createEdgeEffect 方法。

2、SpringEdgeEffectFactory 类

以下是 SpringEdgeEffectFactory 类的具体实现:

kotlin 复制代码
import android.view.View
import androidx.core.view.ViewCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory
import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory.DIRECTION_BOTTOM
import androidx.recyclerview.widget.RecyclerView.EdgeEffectFactory.DIRECTION_TOP
import android.graphics.Canvas
import android.view.animation.DecelerateInterpolator
import android.animation.ValueAnimator

/**
 * 自定义 EdgeEffectFactory,用于为 RecyclerView 创建弹簧效果的边缘效果。
 * 当用户拉伸 RecyclerView 的边缘时,子视图会根据拉伸距离进行平移,
 * 并在释放后返回原始位置,模拟弹簧效果。
 */
class SpringEdgeEffectFactory : EdgeEffectFactory() {

    /**
     * 创建一个新的 EdgeEffect 实例。
     *
     * @param view   RecyclerView 实例。
     * @param direction 拉动的方向,可以是 DIRECTION_TOP 或 DIRECTION_BOTTOM。
     * @return 一个新的 EdgeEffect 实例。
     */
    @NonNull
    override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
        return object : EdgeEffect(view.context) {

            /**
             * 处理拉伸事件,根据拉伸距离调整子视图的位置。
             */
            private fun handlePull(deltaDistance: Float) {
                // 根据方向设置正负号
                val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
                // 计算要移动的距离
                val translationYDelta = sign * view.width * deltaDistance * 0.8f
                // 遍历所有可见的子视图并更新它们的位置
                for (i in 0 until view.childCount) {
                    val child = view.getChildAt(i)
                    if (child.visibility == View.VISIBLE) {
                        // 使用 ViewCompat 来确保兼容性
                        ViewCompat.setTranslationY(child, ViewCompat.getTranslationY(child) + translationYDelta)
                    }
                }
            }

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

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

            override fun onRelease() {
                super.onRelease()
                // 在释放后,创建动画让所有子视图回到原来的位置
                for (i in 0 until view.childCount) {
                    val child = view.getChildAt(i)
                    ValueAnimator.ofFloat(ViewCompat.getTranslationY(child), 0f).apply {
                        duration = 500
                        interpolator = DecelerateInterpolator(2.0f)
                        addUpdateListener { animation ->
                            ViewCompat.setTranslationY(child, animation.animatedValue as Float)
                        }
                        start()
                    }
                }
            }

            override fun onAbsorb(velocity: Int) {
                super.onAbsorb(velocity)
            }

            override fun draw(canvas: Canvas): Boolean {
                setSize(0, 0)
                return super.draw(canvas)
            }
        }
    }
}

使用说明

  • SpringEdgeEffectFactory 类加入到您的项目中。
  • 在您需要应用此效果的 RecyclerView 上调用 setEdgeEffectFactory() 方法,并传入 SpringEdgeEffectFactory()

例如:

kotlin 复制代码
val recyclerView: RecyclerView = findViewById(R.id.recycler_view)
recyclerView.edgeEffectFactory = SpringEdgeEffectFactory()

这段代码将为指定的 RecyclerView 设置弹簧边缘效果。

二、ListView 的弹簧边缘效果

对于 ListView,由于它不像 RecyclerView 那样提供直接的 API 来定制边缘效果,因此我们需要手动处理滚动事件和动画来模拟类似的弹簧效果。

1、SpringListView 类

以下是 SpringListView 类的具体实现:

kotlin 复制代码
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.widget.EdgeEffect
import android.widget.ListView

/**
 * 自定义 ListView,实现了边缘回弹效果。
 * 当用户拉伸 ListView 的边缘时,子视图会根据拉伸距离进行平移,
 * 并在释放后返回原始位置,模拟弹簧效果。
 */
class SpringListView(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ListView(context, attrs, defStyleAttr) {

    /**
     * Companion object 用来存储静态变量和方法。
     * MAX_OVERSCROLL_DISTANCE 设置了最大过滚动距离,防止过度拉伸。
     */
    private companion object {
        const val MAX_OVERSCROLL_DISTANCE = 200 // 设置最大过滚动距离
    }

    /**
     * startY 用于记录触摸事件开始的 Y 轴坐标。
     * isBeingDragged 标志位,表示当前是否正在拖动列表。
     */
    private var startY = 0f
    private var isBeingDragged = false

    /**
     * edgeEffectTop 和 edgeEffectBottom 分别是顶部和底部的 EdgeEffect 实例,
     * 用于创建当用户拉动列表边缘时的效果。
     */
    private val edgeEffectTop = EdgeEffect(context)
    private val edgeEffectBottom = EdgeEffect(context)

    /**
     * 重写 onTouchEvent 方法来处理触摸事件。
     * ACTION_DOWN:记录触摸点的初始位置。
     * ACTION_MOVE:判断是否已经开始拖动,并调用 handleScroll 处理滚动。
     * ACTION_UP 和 ACTION_CANCEL:当手指离开屏幕时触发,结束拖动并释放滚动。
     */
    override fun onTouchEvent(ev: MotionEvent): Boolean {
        when (ev.action) {
            MotionEvent.ACTION_DOWN -> {
                // 记录触摸起始点的 Y 坐标
                startY = ev.y
                // 标记为未拖拽状态
                isBeingDragged = false
            }
            MotionEvent.ACTION_MOVE -> {
                // 如果尚未标记为拖拽状态,并且移动距离超过 touchSlop,则认为开始拖拽
                if (!isBeingDragged && Math.abs(ev.y - startY) > touchSlop) {
                    isBeingDragged = true
                }
                // 如果已经是拖拽状态,则调用 handleScroll 来处理滚动逻辑
                if (isBeingDragged) {
                    handleScroll(ev.y - startY)
                    return true // 消费此事件
                }
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                // 手指离开屏幕时,如果之前处于拖拽状态,则调用 releaseScroll 结束动画
                if (isBeingDragged) {
                    releaseScroll()
                    // 重置拖拽标志位
                    isBeingDragged = false
                }
            }
        }
        // 将事件传递给父类处理
        return super.onTouchEvent(ev)
    }

    /**
     * handleScroll 函数用于处理垂直方向上的滚动。
     * 它检查是否达到了列表的顶部或底部,并相应地调用 onPull 来启动边缘效果。
     */
    private fun handleScroll(distanceY: Float) {
        // 获取当前的滚动偏移量和滚动范围
        val scrollY = computeVerticalScrollOffset()
        val scrollRange = computeVerticalScrollRange() - computeVerticalScrollExtent()

        // 如果向上滚动并且已经到达顶部,则触发顶部边缘效果
        if (distanceY > 0 && scrollY <= 0) {
            onPull(distanceY, edgeEffectTop)
        }
        // 如果向下滚动并且已经到达底部,则触发底部边缘效果
        else if (distanceY < 0 && scrollY >= scrollRange) {
            onPull(-distanceY, edgeEffectBottom)
        }
    }

    /**
     * onPull 函数负责更新 EdgeEffect 的状态。
     * 它接收一个距离参数和一个 EdgeEffect 对象,然后调用其 onPull 方法。
     */
    private fun onPull(distance: Float, edgeEffect: EdgeEffect) {
        // 只有当边缘效果没有完成时才更新它的状态
        if (!edgeEffect.isFinished) {
            // 更新边缘效果的状态,确保不超过 1 的比例
            edgeEffect.onPull(Math.min(1f, distance / height))
            // 请求重新绘制以显示新的边缘效果
            invalidate()
        }
    }

    /**
     * releaseScroll 函数用于在手指离开屏幕后释放滚动。
     * 它分别对顶部和底部的 EdgeEffect 调用 onRelease 方法,使得边缘效果可以自然地结束。
     */
    private fun releaseScroll() {
        // 释放顶部边缘效果
        if (!edgeEffectTop.isFinished) {
            edgeEffectTop.onRelease()
            postInvalidateOnAnimation() // 触发下一个绘制周期
        }
        // 释放底部边缘效果
        if (!edgeEffectBottom.isFinished) {
            edgeEffectBottom.onRelease()
            postInvalidateOnAnimation() // 触发下一个绘制周期
        }
    }

    /**
     * 重写 overScrollBy 方法,限制过滚动的最大距离。
     */
    override fun overScrollBy(
        deltaX: Int,
        deltaY: Int,
        scrollX: Int,
        scrollY: Int,
        scrollRangeX: Int,
        scrollRangeY: Int,
        maxOverScrollX: Int,
        maxOverScrollY: Int,
        isTouchEvent: Boolean
    ): Boolean {
        // 应用最大过滚动距离
        val finalDeltaY = if (Math.abs(deltaY) > MAX_OVERSCROLL_DISTANCE) {
            if (deltaY > 0) MAX_OVERSCROLL_DISTANCE else -MAX_OVERSCROLL_DISTANCE
        } else deltaY

        // 调用父类的方法,传入调整后的 delta 值
        return super.overScrollBy(
            deltaX, finalDeltaY,
            scrollX, scrollY,
            scrollRangeX, scrollRangeY,
            maxOverScrollX, MAX_OVERSCROLL_DISTANCE,
            isTouchEvent
        )
    }

    /**
     * 重写 dispatchDraw 方法,在绘制所有子项之后绘制边缘效果。
     */
    override fun dispatchDraw(canvas: Canvas) {
        // 先绘制所有的子项
        super.dispatchDraw(canvas)
        // 然后绘制顶部和底部的边缘效果
        drawEdgeEffect(canvas, edgeEffectTop, 0, 0)
        drawEdgeEffect(canvas, edgeEffectBottom, height, height)
    }

    /**
     * drawEdgeEffect 函数负责在 Canvas 上绘制指定的 EdgeEffect。
     * 它接受一个 Canvas 对象、一个 EdgeEffect 对象以及要应用的平移和尺寸信息。
     */
    private fun drawEdgeEffect(canvas: Canvas, edgeEffect: EdgeEffect, translateY: Int, sizeY: Int) {
        // 保存当前画布状态
        val restoreCount = canvas.save()
        // 平移画布到指定位置
        canvas.translate(0f, translateY.toFloat())
        // 设置边缘效果的大小
        edgeEffect.setSize(width, height)
        // 如果边缘效果需要绘制,则调用其 draw 方法并在下一帧请求重绘
        if (edgeEffect.draw(canvas)) {
            postInvalidateOnAnimation()
        }
        // 恢复画布状态
        canvas.restoreToCount(restoreCount)
    }
}

注释总结:

  • Companion Object :包含了一个常量 MAX_OVERSCROLL_DISTANCE,它定义了用户可以超出列表边界的最大距离。
  • 成员变量
    • startY 用于跟踪手指按下的起始位置。
    • isBeingDragged 是一个布尔值,用于标识用户是否正在拖拽列表。
    • edgeEffectTopedgeEffectBottom 是两个 EdgeEffect 实例,分别对应顶部和底部的边缘效果。
  • onTouchEvent:重写了触摸事件处理函数,通过监听手指的动作来控制何时开始和结束拖拽行为。
  • handleScroll:计算滚动偏移量,并决定是否应该激活边缘效果。
  • onPull :更新特定 EdgeEffect 的状态,以反映用户的拖拽动作。
  • releaseScroll:当用户结束拖拽时,让边缘效果自然地结束。
  • overScrollBy:限制了过滚动的距离,避免过度拉伸。
  • dispatchDraw:在绘制完所有子视图之后,再绘制边缘效果。
  • drawEdgeEffect :具体实现如何将 EdgeEffect 绘制到 Canvas 上。

2、SpringListView 简单版实现

java 复制代码
public class SpringListView extends ListView {
    // 创建两个EdgeEffect实例来分别处理顶部和底部的边缘回弹效果。
    private EdgeEffect edgeEffectTop;
    private EdgeEffect edgeEffectBottom;

    // 用于跟踪当前是否正在发生过量滚动(overscroll)。
    private boolean isOverscrolling;

    public SpringListView(Context context) {
        super(context);
        init();
    }

    public SpringListView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public SpringListView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        // 初始化顶部和底部的EdgeEffect。
        edgeEffectTop = new EdgeEffect(getContext());
        edgeEffectBottom = new EdgeEffect(getContext());
    }

    @Override
    protected boolean overScrollBy(int deltaX, int deltaY,
                                   int scrollX, int scrollY,
                                   int scrollRangeX, int scrollRangeY,
                                   int maxOverScrollX, int maxOverScrollY,
                                   boolean isTouchEvent) {

        final boolean canScrollUp = getFirstVisiblePosition() == 0 && getChildAt(0).getTop() >= 0;
        final boolean canScrollDown = getLastVisiblePosition() == getCount() - 1 && getChildAt(getChildCount() - 1).getBottom() <= getHeight();

        if (deltaY < 0 && canScrollUp) {
            // 处理顶部过量滚动
            handleEdgeEffect(edgeEffectTop, deltaY / getHeight(), true);
        } else if (deltaY > 0 && canScrollDown) {
            // 处理底部过量滚动
            handleEdgeEffect(edgeEffectBottom, deltaY / getHeight(), false);
        } else {
            // 如果没有过量滚动,则重置标志位。
            isOverscrolling = false;
        }

        return super.overScrollBy(deltaX, deltaY, scrollX, scrollY,
                scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent);
    }

    private void handleEdgeEffect(EdgeEffect edgeEffect, float deltaDistance, boolean isTop) {
        if (!edgeEffect.isFinished()) {
            edgeEffect.onPull(deltaDistance);
            invalidate();
            isOverscrolling = true;
        }
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        if (isOverscrolling) {
            if (!edgeEffectTop.isFinished()) {
                edgeEffectTop.onRelease();
                postInvalidateOnAnimation();
            } else if (!edgeEffectBottom.isFinished()) {
                edgeEffectBottom.onRelease();
                postInvalidateOnAnimation();
            } else {
                isOverscrolling = false;
            }
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        final int height = getHeight();
        final int width = getWidth();

        // 绘制顶部EdgeEffect
        if (isOverscrolling && !edgeEffectTop.isFinished()) {
            edgeEffectTop.setSize(width, height);
            if (edgeEffectTop.draw(canvas)) {
                postInvalidateOnAnimation();
            }
        }

        // 绘制底部EdgeEffect
        if (isOverscrolling && !edgeEffectBottom.isFinished()) {
            canvas.translate(0, height);
            edgeEffectBottom.setSize(width, height);
            if (edgeEffectBottom.draw(canvas)) {
                postInvalidateOnAnimation();
            }
            canvas.translate(0, -height);
        }
    }
}

这段代码实现了以下功能:

  • 它创建了两个EdgeEffect对象,一个用于顶部,一个用于底部。
  • overScrollBy方法中,它检查用户是尝试在顶部还是底部进行过量滚动,并相应地激活对应的EdgeEffect
  • handleEdgeEffect辅助方法简化了对EdgeEffect的操作。
  • computeScroll方法确保当任何一个EdgeEffect动画未完成时,都会继续播放直到结束。
  • onDraw方法根据需要绘制顶部或底部的EdgeEffect,并保证动画能够正确显示。

这样,无论是在顶部下拉还是在底部上滑,SpringListView都会提供一个自然的回弹效果。

使用说明

  • SpringListView 类添加到项目中。
  • 在 XML 布局文件中使用 SpringListView 替换普通的 ListView
  • 确保您的布局文件和 Activity/Fragment 中正确引用了 SpringListView

通过上述代码,您可以为 RecyclerViewListView 添加吸引人的弹簧边缘效果,从而提升应用程序的用户体验


相关推荐
大胃粥1 小时前
Android V app 冷启动(8) 动画结束
android
ufo00l1 小时前
Kotlin在Android中有哪些重要的应用和知识点是需要学习或者重点关注的
android
AJi1 小时前
Android音视频框架探索(二):Binder——系统服务的通信基础
android·ffmpeg·音视频开发
tjsoft2 小时前
Nginx配置伪静态,URL重写
android·运维·nginx
努力学习的小廉2 小时前
【C++11(中)】—— 我与C++的不解之缘(三十一)
android·java·c++
tangweiguo030519872 小时前
打破界限:Android XML与Jetpack Compose深度互操作指南
android·kotlin·compose
Watink Cpper3 小时前
[MySQL初阶]MySQL(8)索引机制:下
android·数据库·b树·mysql·b+树·myisam·innodedb
一起搞IT吧4 小时前
高通camx IOVA内存不足,导致10-15x持续拍照后,点击拍照键定屏无反应,过一会相机闪退
android·数码相机
前行的小黑炭6 小时前
设计模式:为什么使用模板设计模式(不相同的步骤进行抽取,使用不同的子类实现)减少重复代码,让代码更好维护。
android·java·kotlin
ufo00l6 小时前
2025年了,Rxjava解决的用户痛点,是否kotlin协程也能解决,他们各有什么优缺点?
android