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 的半壁江山。
学习资料:

