Android ScrollView 顶部下拉与底部上拉回弹效果实现

QuibblerQuibbler 2019-11-14 约 30 分钟 179 次阅读

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 外层容器都能套用同一思路,稍加改造就能加上阻尼调节、过冲插值器,做出更细腻的交互手感。

相关推荐

精选
ViewPager和PagerAdapter、FragmentPagerAdapter、FragmentStatePager
Android

ViewPager和PagerAdapter、FragmentPagerAdapter、FragmentStatePager

ViewPager1、ViewPagerandroidx.viewpager.widget.ViewPager,Android中使用非常广泛的控件,可以说是APP必备:首次打开引导页、页面Banner广告等。常用方法:setAdapter() 设置适配器setOffscreenPageLimit() 设置缓存的页面个数,默认是 1setCurrentItem() 跳转到特定的页面setOnPage

1.1k
获取Android内置WebView内核版本
Android

获取Android内置WebView内核版本

获取Android内置WebView内核版本竟然能遇到这样奇葩的事情,网页用的技术过于新颖,以至于只支持高版本Chromium内核的,低版本安卓系统中内置的内核版本较低,无法加载前端页面。1、设置查看在系统设置里 > 应用 > 应用管理 > 显示系统应用,查看WebView组件:2、页面查看通过WebView发起的网络请求,都会带上浏览器的UA,通常页面都可以通过UA判断浏览器的内核版本。这里有两

1.1w
Android 14适配总结
Android

Android 14适配总结

Android 14适配总结毕业工作至今已经适配了三个Android大版本,从Android 11到Android 12、再到Android 13。2023年,Google即将推出的Android 14,上半年已经开始第一批适配。现在,第四个Android版本已经适配完,总结记录一下。1、Android 14计划Google一般会在2月份对外发布预告,同时放出开发者预览版。“拉通”各大平台、厂商以

1.1w