自定义弹簧边缘效果:RecyclerView 和 ListView 的解决方案
在开发 Android 应用时,为用户提供流畅且直观的交互体验是非常重要的。其中一种方法是通过自定义视图组件的滚动行为来增强用户体验。本文将介绍如何 为 RecyclerView
和 ListView
实现类似弹簧的边缘效果。
一、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
是一个布尔值,用于标识用户是否正在拖拽列表。edgeEffectTop
和edgeEffectBottom
是两个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
。
通过上述代码,您可以为 RecyclerView
和 ListView
添加吸引人的弹簧边缘效果,从而提升应用程序的用户体验
评论记录:
回复评论: