Android ScrollView 详解:从使用到原理与嵌套冲突

QuibblerQuibbler 2020-02-06 约 23 分钟 204 次阅读

Android ScrollView 详解:从使用到原理与嵌套冲突

ScrollView 是 Android 里最基础的"可滚动容器",看似简单,真到实战却处处是坑:嵌套 ListView 只显示一行、和 ViewPager 抢滑动、监听不到滚动方向、滚不到底、拉到边缘有碍眼的蓝色回弹……本文把 ScrollView 的基本使用、滑动监听、滚到顶底、滑动冲突、嵌套列表、消除回弹、底层原理一次性讲透——"庖丁解牛",从外到内。

1、概述与基本使用

ScrollView 继承自 FrameLayout,专做垂直滚动(水平滚动请用 HorizontalScrollView)。它有两条铁律:第一,只能有一个直接子 View——这个子 View 通常是 LinearLayout 之类的容器,内部再装真实内容;第二,它默认按内容实际高度滚动,子内容多高就滚多少。基础用法:

<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fillViewport="true">     <!-- 内容不足一屏时也填满视口 -->

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <!-- 这里放很多子 View,超出屏幕就能滚动 -->

    </LinearLayout>
</ScrollView>

这里有个高频属性 fillViewport:默认 false,此时子容器按 wrap_content 计算,"内容不足一屏"时不会撑开到屏幕高度(看起来内容挤在顶部);设为 true,ScrollView 会把子容器拉满到自身视口高度,适合"内容少也要占满屏幕"的布局(如下拉刷新占位、底部"加载更多"按钮贴底)。新项目更推荐直接用 NestedScrollView(androidx),它向下兼容、还自带嵌套滑动支持,能省掉后面一半的坑。

2、滑动监听与滚动方向判断

API 23(M)以上,ScrollView 直接自带 `setOnScrollChangeListener`,回调里能拿到新旧 scrollY,方向判断手到擒来:

// API 23+
scrollView.setOnScrollChangeListener { v, scrollX, scrollY, oldScrollX, oldScrollY ->
    if (scrollY > oldScrollY) {
        // 向下滚(内容上移)
    } else if (scrollY < oldScrollY) {
        // 向上滚(内容下移)
    }
}

低版本没有这个公开回调,要监听就得自定义 ScrollView,重写 protected 的 `onScrollChanged`,对外暴露接口:

class ObservableScrollView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : ScrollView(context, attrs, defStyle) {

    interface OnScrollChangedListener {
        fun onScrollChanged(scrollView: ObservableScrollView, x: Int, y: Int, oldX: Int, oldY: Int)
    }

    private var listener: OnScrollChangedListener? = null
    fun setOnScrollChangedListener(l: OnScrollChangedListener) { listener = l }

    override fun onScrollChanged(x: Int, y: Int, oldX: Int, oldY: Int) {
        super.onScrollChanged(x, y, oldX, oldY)
        listener?.onScrollChanged(this, x, y, oldX, oldY)
    }
}

这套"自定义 ScrollView 暴露 onScrollChanged"的写法,是兼容老项目的通用解法,监听滚动方向、做"滚动隐藏标题栏""滚动到一定位置触发加载"都靠它。

3、滚动到顶部 / 底部,以及判断

控制 ScrollView 滚动位置有几个常用 API,要分清"瞬时"和"平滑":

// 瞬时跳转
scrollView.scrollTo(0, 0)                       // 滚到顶
scrollView.fullScroll(View.FOCUS_DOWN)          // 滚到底(FOCUS_UP 滚到顶)
scrollView.scrollBy(0, 100)                     // 相对滚动 100px

// 平滑滚动(带动画)
scrollView.smoothScrollTo(0, 0)                 // 平滑滚到顶
scrollView.smoothScrollBy(0, 200)               // 平滑相对滚动
scrollView.fullScroll(View.FOCUS_DOWN)          // 配合 post 用,瞬时到底

// 判断是否到顶 / 到底(在滚动回调或合适时机)
val reachedTop = scrollView.scrollY == 0
val childHeight = scrollView.getChildAt(0).height
val reachedBottom = scrollView.scrollY + scrollView.height >= childHeight

两个高频坑:第一,"滚到底"要在布局完成后调——直接在 onCreate 里 fullScroll 往往无效(此时还没 measure 完),正确做法是 `scrollView.post { scrollView.fullScroll(View.FOCUS_DOWN) }` 或在 onWindowFocusChanged 里调。第二,到底判断用 `scrollY + height >= childHeight`(childHeight 取唯一子 View 的高度),留点余量防浮点/四舍五入误差,写成 `>= childHeight - 5` 更稳。

4、滑动冲突:与 ViewPager / 列表的争夺

ScrollView 最头疼的是嵌套滑动冲突。典型有两种:一是 ScrollView(纵向)里嵌 ViewPager(横向),二是 ScrollView(纵向)里嵌 RecyclerView/ListView(也纵向)。前者方向不同相对好处理,后者同向最麻烦。

// 思路一:requestDisallowInterceptTouchEvent —— 子 View 想要事件时,禁止父拦截
//   适合 ScrollView + ViewPager:ViewPager 处理左右、ScrollView 处理上下
viewPager.setOnTouchListener { _, e ->
    when (e.action) {
        MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE ->
            viewPager.parent.requestDisallowInterceptTouchEvent(true)
        MotionEvent.ACTION_UP ->
            viewPager.parent.requestDisallowInterceptTouchEvent(false)
    }
    false
}

// 思路二:自定义父控件,在 onInterceptTouchEvent 里按"滑动角度"决定谁拦截
//   dx、dy 谁大就听谁的:横向 > 纵向 → ViewPager 拿,否则 ScrollView 拿
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    when (ev.actionMasked) {
        MotionEvent.ACTION_MOVE -> {
            val dx = Math.abs(lastX - ev.x)
            val dy = Math.abs(lastY - ev.y)
            // 横向位移明显大于纵向 → 不拦截,交给 ViewPager
            if (dx > dy && dx > touchSlop) return false
        }
    }
    return super.onInterceptTouchEvent(ev)
}

同向冲突(ScrollView + RecyclerView)思路类似但更建议"放弃手撕":直接用 NestedScrollView + RecyclerView,它们实现了 NestedScrollingChild/Parent 接口,滑动会通过嵌套滑动协议自动协调,省掉自己算角度的麻烦。

5、嵌套 TableView / ListView:"只显示一行"的根治

把 ListView(或 TableView)直接塞进 ScrollView,会撞上经典问题——ListView 只显示一行。根因是 ScrollView 给子 View 的测量模式是 UNSPECIFIED(不限高),ListView 在 UNSPECIFIED 下只 measure 出"一个 item"的高度。两种主流解法:

// 解法一:手动重算 ListView 总高度,setLayoutParams 强行撑高(老办法,能用但性能差)
fun setListViewHeightBasedOnChildren(listView: ListView) {
    val adapter = listView.adapter ?: return
    var totalHeight = 0
    for (i in 0 until adapter.count) {
        val item = adapter.getView(i, null, listView)
        item.measure(0, 0)
        totalHeight += item.measuredHeight
    }
    val params = listView.layoutParams
    params.height = totalHeight + listView.dividerHeight * (adapter.count - 1)
    listView.layoutParams = params
}
// 缺点:item 复用失效,数据多了卡顿——只适合少量固定数据

// 解法二(推荐):换用 NestedScrollView + RecyclerView
//   NestedScrollView 支持嵌套滑动,RecyclerView 设置 "不自己滚动",交给外层滚动
<androidx.core.widget.NestedScrollView ...>
    <androidx.recyclerview.widget.RecyclerView
        android:nestedScrollingEnabled="false" ... />   <!-- 关掉子滚动,整体随外层 -->
</androidx.core.widget.NestedScrollView>

结论:老项目少量数据可以用"重算高度"凑合;新项目一律 NestedScrollView + RecyclerView(`nestedScrollingEnabled=false`),干净又高效。TableView(自定义表格)同理——本质都是"可滚动列表嵌进可滚动容器",用嵌套滑动协议统一交给外层即可。

6、消除过度滚动的"回弹"效果

Android 2.3+ 的 ScrollView 拉到边缘会出现一道发光回弹(Edge Effect)——在某些设计里显得多余。一行属性或一个方法就能关掉:

// 方法一:XML 关闭
<ScrollView
    android:overScrollMode="never" ... />

// 方法二:代码关闭(取值 NEVER / IF_CONTENT_SCROLLS / ALWAYS)
scrollView.overScrollMode = View.OVER_SCROLL_NEVER

// 方法三:自定义 ScrollView,重写 overScrollBy / onOverScrolled 完全接管
override fun overScrollBy(deltaX: Int, deltaY: Int, scrollX: Int, scrollY: Int,
        scrollRangeX: Int, scrollRangeY: Int, maxOverScrollX: Int, maxOverScrollY: Int,
        isTouchEvent: Boolean): Boolean {
    // 想要"拉到边缘还能再拉一点然后弹回"的 iOS 风格回弹,可在这里允许 maxOverScrollY > 0
    return super.overScrollBy(deltaX, deltaY, scrollX, scrollY,
        scrollRangeX, scrollRangeY, maxOverScrollX, maxOverScrollY, isTouchEvent)
}

反过来,如果你想要 iOS 那种"越界弹性回弹",把 `maxOverScrollY` 传一个正值(如 200),并在 onOverScrolled 里配合属性动画回弹即可——不少"弹性 ScrollView"开源库就是这么实现的。

7、原理浅析:measure、fillViewport 与滚动

理解 ScrollView 的行为,看三个关键点:

// 1) measureChildWithMargins:测量唯一子 View 时,高度规格用 UNSPECIFIED,
//    所以子 View 能"想多高就多高"——这才是滚动的物理基础
override fun measureChildWithMargins(child, parentMeasureSpec, widthUsed,
        heightMeasureSpec, heightUsed) {
    val lp = child.layoutParams
    val childHeightSpec = MeasureSpec.makeSafeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
    child.measure(/* width */, childHeightSpec)
}

// 2) fillViewport:当"子内容高度 < ScrollView 自身高度"时,
//    把子 View 重新 measure 成"视口高度",让它撑满(否则内容会浮在顶部)

// 3) 滚动机制:scrollTo / scrollBy 改的是 View 的 mScrollY,
//    ScrollView 内部"平移绘制子 View"来呈现滚动效果;
//    fling(手指快速甩)由 OverScroller 计算每帧位移,逐帧 scrollTo 形成惯性滚动

一句话总结原理:ScrollView 通过"不限高测量 + 平移绘制"实现滚动,`mScrollY` 是滚动的核心状态。理解了这点,就能解释为什么"子内容必须 wrap_content 才滚得动"(match_parent 会让子 View 高度 = 视口,没有可滚空间)、为什么"fillViewport 关掉后内容贴顶"。

8、常见坑与最佳实践

       - 只能一个直接子 View:要装多个控件,先套一个垂直 LinearLayout

       - 嵌 EditText 时 ScrollView 会自动滚到获焦的 EditText,可用 `descendantFocusability` 或手动 clearFocus 控制

       - 滚到底/顶的代码要在 measure 完成后调(post 或 onWindowFocusChanged)

       - 新项目优先 NestedScrollView,原生 ScrollView 留给老代码兼容

       - 嵌套列表用 NestedScrollView + RecyclerView(nestedScrollingEnabled=false),别再手算 ListView 高度

       - 性能:ScrollView 一次性把全部子 View 都测量/绘制,超长列表(成百上千项)会卡——那是 RecyclerView/NestedScrollView 的活,别让 ScrollView 硬扛

9、总结

ScrollView 是个"简单但坑多"的容器:它靠"不限高测量 + 平移绘制 mScrollY"实现垂直滚动,要求只能一个子 View、内容要 wrap_content。它的实战难点全在"监听与协调"——监听滚动方向要自定义 ScrollView(低版本)或 setOnScrollChangeListener(API 23+);滚到顶/底要在布局完成后调;嵌套 ViewPager/列表的滑动冲突要么 requestDisallowInterceptTouchEvent + 角度拦截,要么直接上 NestedScrollView 走嵌套滑动协议;边缘回弹用 overScrollMode=never 关闭。

关键要点:

       - 只能一个子 View;内容 wrap_content 才滚得动;fillViewport 让"少内容"也撑满

       - 监听:API23+ 用 setOnScrollChangeListener,低版本自定义 ScrollView 重写 onScrollChanged

       - 滚到顶底:scrollTo/fullScroll/smoothScrollTo,记得 post 等布局完成;到底判断 scrollY+height>=childHeight

       - 嵌套冲突:requestDisallowInterceptTouchEvent 或角度拦截;同向优先 NestedScrollView

       - 回弹关 overScrollMode=never;原理是"不限高测量 + 平移绘制"

对于做 Android 界面的开发者而言,ScrollView 几乎天天见,却常被它的"嵌套"与"监听"绊住。把"一个子 View、wrap_content、measure 后再滚、新项目用 NestedScrollView"这几条纪律刻进肌肉记忆,再遇到滑动冲突和只显示一行的问题,多半都能一眼定位。ScrollView 不复杂,但它考验的是对 View 事件分发与测量机制的熟悉度——搞懂它,等于顺带打通了 Android 自定义 View 的半壁江山。

学习资料:

        ScrollView 的基本使用(慕课网)

        Android 设置 ScrollView 滚动到底部和顶部(CSDN)

相关推荐

精选
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