Android ScrollView 顶部下拉与底部上拉回弹效果实现
ScrollView 滑到顶部或底部时,默认是"硬边界"——戛然而止,没有 iOS 那种顺滑的橡皮筋回弹手感。要让 Android 的 ScrollView 也具备这种效果,需要自定义控件在触摸事件里做干预。本文实现一个 ReboundScrollView:在 dispatchTouchEvent 中判断是否到达顶部/底部,到达后让内容跟随手指偏移(带阻尼),松手后用动画回弹归位,并支持回弹结束回调(可用来做下拉刷新)。参考 脚本之家原文,并对其中回弹动画的写法做了健壮性修正。
1、效果与原理总览
目标效果:滑到顶部继续下拉、滑到底部继续上拉时,内容会跟手移动一段距离,松手后平滑弹回原位。整体只需四步:
// 四步原理
DOWN → 记录手指起点 lastY
MOVE → 仅在顶/底时:offset = deltaY * 阻尼;contentView.layout(top+offset, ...) 跟手偏移
UP → TranslateAnimation 从 offset 平滑回弹到 0;layout 复位;触发回调核心实现(关键认知):
1. 用 layout() 而非 scrollBy():scrollTo/By 改的是 ScrollView 自身的滚动位置,到顶后再 scroll 没有意义;layout 直接挪动子 View 在父布局中的位置,才能"突破边界"跟手
2. 必须先用 Rect 记录子 View 的原始位置作为回弹基准,否则松手不知道弹回哪
3. 阻尼系数让内容移动慢于手指(如 0.5),产生"拉不动"的橡皮筋手感
4. 只在到达顶/底时干预,中间区域交给 ScrollView 正常滚动,互不干扰
2、记录子 View 原始位置
ScrollView 强制只能有一个直接子 View。在布局完成后拿到它,并用 Rect 保存它的原始四边坐标,作为回弹的基准位。
public class ReboundScrollView extends ScrollView {
private View mContentView; // 唯一子 View(内容容器)
private Rect mRect = new Rect(); // 记录子 View 的原始位置
private boolean mEnableTopRebound = true;
private boolean mEnableBottomRebound = true;
// 布局加载完成,拿到第一个(也是唯一)子 View
@Override
protected void onFinishInflate() {
super.onFinishInflate();
if (getChildCount() > 0) {
mContentView = getChildAt(0);
}
}
// 布局确定后,记录子 View 原始四边坐标,作为回弹基准
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mContentView == null) return;
mRect.set(mContentView.getLeft(), mContentView.getTop(),
mContentView.getRight(), mContentView.getBottom());
}
// 内容不足时也填满 ScrollView,否则 isScrollToBottom 判断会失真
@Override
public void setFillViewport(boolean fillViewport) {
super.setFillViewport(true);
}
}核心实现:
1. ScrollView 只允许一个直接子 View,getChildAt(0) 即内容容器
2. 必须在 onLayout 之后取坐标(onFinishInflate 时宽高还是 0)
3. Rect 记录原始位置,ACTION_UP 回弹就回到 mRect 这组坐标
4. setFillViewport(true) 让子 View 在内容不足时也能填满,保证 isScrollToBottom 成立
3、触摸跟手:DOWN/MOVE 偏移与阻尼
重写 dispatchTouchEvent:DOWN 记录起点;MOVE 时仅当处于顶部或底部才按阻尼偏移子 View,否则正常滚动。
private int lastY;
private boolean rebound = false; // 本次触摸是否触发了回弹
private int reboundDirection = 0; // >0 顶部回弹 <0 底部回弹
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (mContentView == null) return super.dispatchTouchEvent(ev);
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
lastY = (int) ev.getY();
break;
case MotionEvent.ACTION_MOVE:
if (!isScrollToTop() && !isScrollToBottom()) {
lastY = (int) ev.getY(); // 正常滚动区间,更新基点后放行
break;
}
int deltaY = (int) (ev.getY() - lastY); // >0 下拉 <0 上拉
// 顶部/底部回弹被禁用则不处理
if ((!mEnableTopRebound && deltaY > 0)
|| (!mEnableBottomRebound && deltaY < 0)) {
break;
}
// 阻尼系数 0.5:内容只跟手一半距离,产生橡皮筋感
int offset = (int) (deltaY * 0.5);
mContentView.layout(mRect.left, mRect.top + offset,
mRect.right, mRect.bottom + offset);
rebound = true;
break;
case MotionEvent.ACTION_UP:
// 见第 4 节:动画回弹
break;
}
return super.dispatchTouchEvent(ev); // 交给 ScrollView 处理正常滚动
}
// 是否滑到顶部
private boolean isScrollToTop() {
return getScrollY() == 0;
}
// 是否滑到底部:内容高度 ≤ 自身高度 + 滚动偏移
private boolean isScrollToBottom() {
return mContentView.getHeight() <= getHeight() + getScrollY();
}核心实现:
1. 用 dispatchTouchEvent 而非 onTouchEvent:它是事件分发入口,自己干预后仍 super 交给 ScrollView 处理正常滚动,两套逻辑互不干扰
2. 仅在顶/底时才偏移,中间区域正常滚动,体验自然
3. isScrollToBottom 用"内容高度 ≤ 视图高度 + 滚动量"判断,必须先开 fillViewport否则内容高度为 0 判断失效
4. 阻尼系数越大越"软"、跟手越远;越小越"硬"。原文用 0.48,本文用 0.5
4、松手回弹:动画归位与监听
ACTION_UP 时算出当前相对原位的偏移量,先 layout 复位,再用 TranslateAnimation 做相对偏移的回弹动画,结束后按偏移方向回调。这里有个关键修正:TranslateAnimation 的 fromY/toY 是相对偏移量,不是绝对坐标,必须用 offset 而非 getTop(),否则依赖 mRect.top==0 才能正常工作。
case MotionEvent.ACTION_UP:
if (!rebound) break;
final int offset = mContentView.getTop() - mRect.top; // 相对原位的偏移量
reboundDirection = offset;
// ① 先复位实际位置(view 真实位置归位)
mContentView.layout(mRect.left, mRect.top, mRect.right, mRect.bottom);
// ② 再用相对偏移做回弹动画:从 offset 平滑回到 0
TranslateAnimation anim = new TranslateAnimation(0, 0, offset, 0);
anim.setDuration(300);
anim.setAnimationListener(new Animation.AnimationListener() {
@Override public void onAnimationStart(Animation a) { }
@Override public void onAnimationRepeat(Animation a) { }
@Override public void onAnimationEnd(Animation a) {
if (mOnReboundEndListener != null) {
if (reboundDirection > 0) {
mOnReboundEndListener.onReboundTopComplete(); // 顶部回弹
} else if (reboundDirection < 0) {
mOnReboundEndListener.onReboundBottomComplete(); // 底部回弹
}
reboundDirection = 0;
}
}
});
mContentView.startAnimation(anim);
rebound = false;
break;
}
// 回弹结束监听,可用于触发下拉刷新 / 上拉加载
public interface OnReboundEndListener {
void onReboundTopComplete();
void onReboundBottomComplete();
}核心实现:
1. 先 layout 复位把 view 实际位置归位,动画只负责绘制层的偏移过渡
2. TranslateAnimation 用相对偏移 offset(getTop − mRect.top),而非绝对坐标,健壮且不依赖 mRect.top==0
3. 动画结束按 offset 正负区分顶部/底部回弹,触发回调(可接入下拉刷新)
4. rebound 置 false,等待下一次触摸
5、完整实现:ReboundScrollView
把前面几节拼起来,补上构造方法和链式 setter,就是完整可用的 ReboundScrollView:
public class ReboundScrollView extends ScrollView {
private View mContentView;
private Rect mRect = new Rect();
private boolean mEnableTopRebound = true;
private boolean mEnableBottomRebound = true;
private OnReboundEndListener mOnReboundEndListener;
private int lastY;
private boolean rebound = false;
private int reboundDirection = 0;
public ReboundScrollView(Context context) { super(context); }
public ReboundScrollView(Context context, AttributeSet attrs) { super(context, attrs); }
public ReboundScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override protected void onFinishInflate() {
super.onFinishInflate();
if (getChildCount() > 0) mContentView = getChildAt(0);
}
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (mContentView == null) return;
mRect.set(mContentView.getLeft(), mContentView.getTop(),
mContentView.getRight(), mContentView.getBottom());
}
@Override public void setFillViewport(boolean fillViewport) { super.setFillViewport(true); }
public void setEnableTopRebound(boolean enable) { this.mEnableTopRebound = enable; }
public void setEnableBottomRebound(boolean enable) { this.mEnableBottomRebound = enable; }
public void setOnReboundEndListener(OnReboundEndListener l) { this.mOnReboundEndListener = l; }
@Override
public boolean dispatchTouchEvent(MotionEvent ev) {
if (mContentView == null) return super.dispatchTouchEvent(ev);
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
lastY = (int) ev.getY();
break;
case MotionEvent.ACTION_MOVE:
if (!isScrollToTop() && !isScrollToBottom()) {
lastY = (int) ev.getY();
break;
}
int deltaY = (int) (ev.getY() - lastY);
if ((!mEnableTopRebound && deltaY > 0)
|| (!mEnableBottomRebound && deltaY < 0)) break;
int offset = (int) (deltaY * 0.5);
mContentView.layout(mRect.left, mRect.top + offset,
mRect.right, mRect.bottom + offset);
rebound = true;
break;
case MotionEvent.ACTION_UP:
if (!rebound) break;
final int off = mContentView.getTop() - mRect.top;
reboundDirection = off;
mContentView.layout(mRect.left, mRect.top, mRect.right, mRect.bottom);
TranslateAnimation anim = new TranslateAnimation(0, 0, off, 0);
anim.setDuration(300);
anim.setAnimationListener(new Animation.AnimationListener() {
@Override public void onAnimationStart(Animation a) {}
@Override public void onAnimationRepeat(Animation a) {}
@Override public void onAnimationEnd(Animation a) {
if (mOnReboundEndListener != null) {
if (reboundDirection > 0) mOnReboundEndListener.onReboundTopComplete();
else if (reboundDirection < 0) mOnReboundEndListener.onReboundBottomComplete();
reboundDirection = 0;
}
}
});
mContentView.startAnimation(anim);
rebound = false;
break;
}
return super.dispatchTouchEvent(ev);
}
private boolean isScrollToTop() { return getScrollY() == 0; }
private boolean isScrollToBottom() {
return mContentView.getHeight() <= getHeight() + getScrollY();
}
public interface OnReboundEndListener {
void onReboundTopComplete();
void onReboundBottomComplete();
}
}核心实现:
1. 整体不到 100 行,结构清晰:初始化记原位 → MOVE 跟手 → UP 回弹
2. 提供 setEnableTopRebound / setEnableBottomRebound 分别禁用顶部/底部回弹
3. OnReboundEndListener 在回弹动画结束时回调,区分顶/底
4. 复用成员变量 lastY / rebound,不在 MOVE 中创建对象,避免频繁 GC
6、使用与进阶优化
使用时直接在 XML 里把 ScrollView 换成全限定类名,再在代码里设置监听即可:
<!-- 直接把 ScrollView 替换为 ReboundScrollView -->
<com.example.widget.ReboundScrollView
android:id="@+id/reboundScrollView"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 内容容器:只能有一个直接子 View -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/content" />
</LinearLayout>
</com.example.widget.ReboundScrollView>ReboundScrollView scrollView = findViewById(R.id.reboundScrollView);
// 可选:单独禁用顶部或底部回弹
// scrollView.setEnableTopRebound(false);
// scrollView.setEnableBottomRebound(false);
scrollView.setOnReboundEndListener(new ReboundScrollView.OnReboundEndListener() {
@Override public void onReboundTopComplete() {
Toast.makeText(this, "顶部回弹,可触发下拉刷新", Toast.LENGTH_SHORT).show();
}
@Override public void onReboundBottomComplete() {
Toast.makeText(this, "底部回弹,可触发上拉加载", Toast.LENGTH_SHORT).show();
}
});进阶优化与避坑:
1. 阻尼可调:把 0.5 提取为成员变量;想做"越拉越费力"的真实手感,可让阻尼随 offset 增大而递减
2. 弹性插值器:anim.setInterpolator(new OvershootInterpolator()) 让回弹带轻微"过冲",比线性更生动
3. NestedScrollView:AndroidX 推荐用 NestedScrollView 替代 ScrollView,本方案思路完全通用,且能配合 CoordinatorLayout 做嵌套滚动
4. fillViewport 必须开,否则内容不足一屏时 isScrollToBottom 永远为 false,底部回弹失效
- 补间动画(TranslateAnimation)只改绘制不改真实位置,回弹后已 layout 复位故无副作用;若复用子 View 注意 clearAnimation
7、总结
ScrollView 回弹的本质,是在事件分发层突破滚动边界:到顶/底后不再滚动自身,而是用 layout 挪动子 View 跟手,松手再用动画弹回。
关键要点:
- 用 dispatchTouchEvent 干预 + super 交给 ScrollView 正常滚动,两不耽误
- Rect 记录子 View 原始位置作为回弹基准,layout 偏移实现跟手
- 阻尼系数(0.5)制造橡皮筋手感;isScrollToBottom 依赖 fillViewport
- 回弹动画用相对偏移 offset 而非绝对坐标,健壮不依赖 mRect.top==0
- OnReboundEndListener 可接入下拉刷新/上拉加载;NestedScrollView 同理适用
对于做个人中心页、设置页、长文本详情等需要"软边界"体验的开发者而言,掌握这套"记原位 + layout 跟手 + 动画回弹"的三段式写法是必要的——它不仅适用于 ScrollView,NestedScrollView、甚至 WebView 外层容器都能套用同一思路,稍加改造就能加上阻尼调节、过冲插值器,做出更细腻的交互手感。

