如果你手里正好有一个这样的需求:页面不需要复杂多变的列表逻辑,数据也就五六条到十几条,每条 item 长什么样还不一样,但这个页面必须支持下拉刷新——你会怎么做?
我遇到这个场景的时候,第一反应并不是上 RecyclerView。倒不是不会写,而是那点数据量实在没必要为了一个 Adapter 来回倒腾。更关键的是,每一条 item 的布局差异都很大,硬用 RecyclerView 的多 type 反而把自己绕晕。于是我的选择是:SwipeRefreshLayout 包一层 ScrollView,ScrollView 里放一个 LinearLayout,再通过 addView 把列表项一条一条挂上去,整体用 SwipeRefreshLayout 实现下拉刷新。
这套组合在 Android 开发里属于比较“复古”的玩法,但它简单、可控、代码量少,尤其适合轻量页面快速上线。当然,简单不等于没坑。这篇内容就是我从一个真实项目里把整个方案的实现思路、源码级触发原理、踩坑记录和优化方式完整整理出来的结果,希望能给正打算用同样思路做页面的你省点时间。
1. 这种“手工列表”到底在什么场景下值得用?
先别急着抄代码。你需要先弄清楚一个前提:ScrollView + LinearLayout + addView 这种“手工列表”,它的适用边界到底是什么。很多人一看到 ScrollView 就先摇头,觉得性能不行、不够现代。这个判断在大多数场景下是对的,但有一种场景它反而是最优解。
1.1 为什么不用 RecyclerView
RecyclerView 是 Android 列表的绝对主流,这一点没有任何疑问。但它的优势是有前提的:数据量大、item 结构可复用、需要滚动性能优化。一旦你的场景变成“数据量很小、每条 item 长得完全不一样”,RecyclerView 的优势就发挥不出来了,反而会带来额外成本。
举一个我之前做过的真实需求:一个保险产品的详情页,从上到下依次是产品介绍卡片、保障责任列表、理赔说明、常见问题、底线免责声明。整个页面一共 9 个区块,每个区块的布局完全不同,有些区块里面还要嵌套一个小列表。数据量撑死十几条。
如果用 RecyclerView 做,你要做至少 6 种 ViewType,写对应的 ViewHolder、Adapter 里的类型分发逻辑、item 间距处理,还得小心嵌套滚动的冲突。一套搞下来几百行代码起步。但用 ScrollView + LinearLayout,就是一个普通布局文件,代码里按顺序 addView 进去就完事了。
为了更直观,我把两种方案的差异拉一个对比表:
| 对比维度 | RecyclerView | ScrollView + LinearLayout |
|---|---|---|
| 数据量 20 条以内 | 需要写 Adapter、ViewHolder | 直接 addView,代码量少一半 |
| item 类型多且互不相同 | 需要多 ViewType 分发 | 天然支持,各写各的布局 |
| 滚动嵌套 | 需要处理 NestedScrolling | 普通 View 滚动,无冲突 |
| 页面结构固定 | 需要一个 item 一个 type | 每个区块独立 View,直观 |
| 性能 | 回收复用,适合大数据量 | 全部挂载,超过 30 条会吃力 |
| 快速原型验证 | 搭建成本高 | 半小时出效果 |
所以我的结论是:数据量在 20 条以内、item 结构不重复、页面整体是一个长滚动页,这三个条件同时满足时,ScrollView 方案比 RecyclerView 更合适。反过来,只要你的数据可能增长到 50 条以上,或者 item 结构可以复用,那就老老实实用 RecyclerView,别跟性能过不去。
1.2 手工挂载子 View 的三种做法
ScrollView 里“放列表”,实际操作层面有三种常见姿势,取决于你的列表项是静态的还是动态的。
第一种,全部写在 XML 里。适合页面结构完全固定、不需要根据接口数据增删的情况。比如一个“关于我们”页面,三个区块永远不变,直接在布局里摆好就行。
第二种,代码里动态 addView。这是我要重点讲的。LinearLayout 作为容器,根据接口返回的数据循环创建 item View,然后 addView 挂上去。这样接口返回多少条,页面就显示多少条。
第三种,用 NestedScrollView 代替 ScrollView。这是更好的替代方案。NestedScrollView 是 Android 5.0 之后官方推出的增强版 ScrollView,专门解决嵌套滑动问题。SwipeRefreshLayout 对它的支持也比对 ScrollView 更平滑。如果你是从零开始写,建议直接用 NestedScrollView,后面我会解释原因。
2. 搭建基础骨架:布局、初始化、刷新回调
搞清楚了适用场景,接下来就是实操环节。这套方案的核心骨架分三层:外层 SwipeRefreshLayout 负责下拉刷新,中层 ScrollView 负责滚动,内层 LinearLayout 作为列表容器。三层各司其职。
2.1 布局 XML 的写法与关键属性
直接看布局文件:
<?xml version="1.0" encoding="utf-8"?> <androidx.swiperefreshlayout.widget.SwipeRefreshLayout android:id="@+id/refreshLayout" android:layout_width="match_parent" android:layout_height="match_parent"> <ScrollView android:id="@+id/scrollView" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true"> <LinearLayout android:id="@+id/listContainer" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" /> </ScrollView> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>这里有几个细节我必须强调一下。
第一,SwipeRefreshLayout 的直接子 View 只能有一个。这是它的硬性限制。如果往里面放两个并列的 View,不是报错就是行为异常。所以 ScrollView 必须是 SwipeRefreshLayout 的唯一子 View。
第二,ScrollView 的 android:fillViewport="true" 一定要加。这个属性的作用是把 ScrollView 的高度撑满整个屏幕。不加的话,当内容不足一屏时,ScrollView 的高度会收缩成内容的高度,SwipeRefreshLayout 的布局高度在视觉上就不完整,某些国产 ROM 上甚至会影响下拉手势的响应区域。
第三,内层 LinearLayout 的高度用 wrap_content。它的职责是包裹所有 item View,高度根据内容自适应。这里不能写 match_parent,否则会撑满 ScrollView,导致 item 下方出现大片空白,而且会让“是否滚动到顶部”的判断出现问题。
注意:如果你还没有引入 androidx.swiperefreshlayout 依赖,记得在 build.gradle 里加上:
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
2.2 动态创建 item 并模拟网络加载
布局文件搞定之后,核心代码就是动态添加 item View。这里我用一个模拟网络请求的例子来说明完整链路。
先定义一个简单的数据类:
data class ItemData( val id: Int, val title: String, val desc: String )然后写一个模拟网络请求的方法。这里用 Handler 延迟 1.5 秒模拟网络延迟,实际项目中替换成你的 Retrofit/RxJava 请求即可:
private fun fetchData(callback: (List<ItemData>) -> Unit) { Handler(Looper.getMainLooper()).postDelayed({ val data = mutableListOf<ItemData>() for (i in 1..10) { data.add(ItemData(i, "标题 $i", "这是第 $i 条数据的描述内容")) } callback(data) }, 1500L) }接下来是需要重点关注的 addView 环节。很多人在这里掉坑:动态创建的 item View 如果不手动指定 LayoutParams,默认按 wrap_content 处理,一个两个看不出来,多了就会有问题。正确姿势是创建 item View 后显式设置 LayoutParams:
private fun createItemView(data: ItemData): View { val itemLayout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL setPadding(dp2px(16f), dp2px(12f), dp2px(16f), dp2px(12f)) } val titleView = TextView(context).apply { text = data.title textSize = 16f setTextColor(Color.parseColor("#333333")) } val descView = TextView(context).apply { text = data.desc textSize = 13f setTextColor(Color.parseColor("#888888")) setPadding(0, dp2px(4f), 0, 0) } itemLayout.addView(titleView) itemLayout.addView(descView) val params = LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) params.setMargins(0, 0, 0, dp2px(8f)) itemLayout.layoutParams = params return itemLayout }这里我把 item 的宽度固定为 MATCH_PARENT,高度 wrap_content,并给每个 item 设置底部间距,避免密密麻麻贴在一起。实际项目中,你完全可以把 item 做成一个独立的 XML 布局,用 LayoutInflater 加载,这样代码会更好维护:
private fun createItemView(data: ItemData): View { val itemView = LayoutInflater.from(context) .inflate(R.layout.item_list, listContainer, false) itemView.findViewById<TextView>(R.id.tv_title).text = data.title itemView.findViewById<TextView>(R.id.tv_desc).text = data.desc return itemView }注意这里 inflate 的第三个参数传的是listContainer而不是null。传 parent 进去可以让 LayoutInflater 按照父容器的布局规则生成正确的 LayoutParams,这是很多新手容易忽略的细节。
2.3 完整刷新流程:从 onRefresh 到页面重建
数据加载和 item 创建都备好了,接下来把它们串成一个完整的刷新流程。SwipeRefreshLayout 的下拉刷新回调是setOnRefreshListener,在里面触发数据加载,数据回来之后再重建列表、收起刷新动画:
class MainActivity : AppCompatActivity() { private lateinit var refreshLayout: SwipeRefreshLayout private lateinit var scrollView: ScrollView private lateinit var listContainer: LinearLayout override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) refreshLayout = findViewById(R.id.refreshLayout) scrollView = findViewById(R.id.scrollView) listContainer = findViewById(R.id.listContainer) refreshLayout.setOnRefreshListener { loadData() } // 首次进入页面,自动触发一次加载 refreshLayout.isRefreshing = true loadData() } private fun loadData() { fetchData { result -> // 1. 清空旧的 item listContainer.removeAllViews() // 2. 重新添加新的 item result.forEach { item -> listContainer.addView(createItemView(item)) } // 3. 数据渲染完成后再收起刷新动画 refreshLayout.isRefreshing = false } } }这套流程看起来简单,但有几个执行顺序的讲究。
removeAllViews 必须在 addView 之前,不然每次刷新都会在旧的 item 后面追加新的 item,页面内容会无限增长。isRefreshing = false 必须放在数据渲染完成之后,不能放在 fetchData 返回之前。很多人习惯把 isRefreshing=false 放在网络请求的 finally 块里,结果数据还没渲染完,转圈先停了,页面闪一下新的内容才开始渲染,视觉上很难受。
setRefreshing(false) 还必须在主线程调用。如果你的网络请求回调是在子线程,直接调用会触发 CalledFromWrongThreadException。上面我用 Handler(Looper.getMainLooper()) 模拟请求,回调天然在主线程。实际项目中,如果你用的是 Retrofit + RxJava,记得在 AndroidSchedulers.mainThread() 中订阅,或者用协程的 Dispatchers.Main 确保回到主线程再操作 UI。
到这里,一个基本能跑的下拉刷新列表就已经完成了。但事情没这么简单。我在真实项目里把这段代码跑起来之后,遇到了一个让人挠头的问题:在某些情况下,下拉刷新就是不出转圈动画。这就要从 SwipeRefreshLayout 的触发原理说起了。
3. 下拉刷新触发的关键细节:为什么转圈不出来?
SwipeRefreshLayout 作为一个成熟的组件,它的触发机制是有明确逻辑的。搞清楚这个逻辑,你才能理解为什么有时下拉没反应,以及怎么去排查。
3.1 触发条件:只有滚动容器在顶部才能触发
SwipeRefreshLayout 处理触摸事件时,会先判断当前子 View 是否处于可滚动区域的顶部。只有在顶部,它才会接管触摸事件,让下拉手势变成刷新动画。如果子 View 不在顶部,比如你正在列表中间的位置,下拉手势会被子 View 消费掉,表现为子 View 继续向上滚动,而不是触发刷新。
这个判断为什么这么设计?因为下拉刷新的交互约定是:在列表顶部继续下拉才表示“我要重新加载数据”。如果你在列表中间下拉也触发刷新,那用户想往上滚动的时候就会误触刷新,体验会非常糟糕。
所以当你发现下拉刷新不出圈时,第一反应应该是:我的滚动容器到底在不在顶部?
3.2 ScrollView 的“顶部”判定:getScrollY 的边界情况
SwipeRefreshLayout 内部通过canChildScrollUp()方法判断子 View 是否还能向上滚动。如果不能向上滚动了,说明已经到达顶部,此时才允许下拉。
对于 ScrollView,这个判断最终落到ViewCompat.canScrollVertically(target, -1)上。方向参数 -1 表示“向上滚动”。如果返回 false,说明 ScrollView 再也无法向上滚动了,即处于顶部。
这里有一个关键细节:当 ScrollView 的内容不足一屏时,getScrollY 始终为 0,canScrollVertically 始终返回 false。这意味着两个结论:
第一,内容不足一屏时,理论上更容易触发下拉刷新,因为整个 ScrollView 永远处于“顶部状态”。
第二,如果内容超过一屏,你必须先把 ScrollView 滚回顶部,然后才能下拉。这是符合预期的行为。
但我实际测试中发现一个问题:当 ScrollView 的内容刚好等于或略大于一屏高度时,下拉刷新会出现一点“顿挫感”。手指往下拖的时候,SwipeRefreshLayout 的转圈没有第一时间跟随手指出现,而是要等 ScrollView 先滚回顶部才突然弹出来。这是因为 ScrollView 自身先消费了 move 事件,直到它滚到顶部之后,SwipeRefreshLayout 才拦截到事件。
这个问题在 NestedScrollView 上会好很多,因为 NestedScrollView 实现了嵌套滚动机制,SwipeRefreshLayout 能在滚动事件分发阶段更早地感知到“子 View 已经到顶了”。
3.3 从源码看 onInterceptTouchEvent 和 canChildScrollUp
为了加深理解,直接看一下 SwipeRefreshLayout 的核心源码逻辑。它的触摸判断主要在这几个方法里:
@Override public boolean onInterceptTouchEvent(MotionEvent ev) { // ... 部分代码省略 switch (action) { case MotionEvent.ACTION_DOWN: // 记录手指按下时是否在顶部 mInitialDownY = ev.getY(); mInitialMotionY = mInitialDownY; mActivePointerId = ev.getPointerId(0); mIsBeingDragged = false; mCurrRatio = 0; if (mInitialDownY - mInitialDownY > 0) { // 简化示意 return false; } break; case MotionEvent.ACTION_MOVE: // 关键判断:是否处于可以刷新的位置 if (!canChildScrollUp() && !mIsBeingDragged) { // 子 View 不能向上滚动(已到顶部) // 且手指正在向下滑动 if (ev.getY() - mInitialMotionY > mTouchSlop) { mIsBeingDragged = true; parent.requestDisallowInterceptTouchEvent(true); } } break; } return mIsBeingDragged; }而核心方法 canChildScrollUp:
private boolean canChildScrollUp() { if (mTarget == null) { return false; } return ViewCompat.canScrollVertically(mTarget, -1); }关键就是最后这行。canScrollVertically(mTarget, -1)内部进一步调用View.canScrollVertically(direction),对于 ScrollView 来说,这行判断最终返回的是getScrollY() > 0(简化后的理解)。
当你理解了这层机制,排查“为什么下拉不触发”就有了明确的思路:先确认子 View 是否在顶部,再确认事件有没有被子 View 消费,再确认 onInterceptTouchEvent 是否进入了下拉分支。我下面要讲的几个真实踩坑案例,都是围绕着这几个环节展开的。
4. 实测中容易踩的坑与调试链路
理论说完了,进入正题。这一节是我在实际项目中踩过的坑的完整记录,每条都付出了真金白银的时间成本。我把排查过程和最终结论都写出来,你可以直接对照排查。
4.1 内容不满一屏时下拉无反应的排查过程
这是最诡异的一个问题。我的页面当时只有三条数据,内容远远不满一屏。按照我刚才的分析,内容不足一屏时 getScrollY 恒为 0,应该一直处于“可下拉”的状态。但实测结果是:手指从列表区域往下拖,SwipeRefreshLayout 没有反应;从 ScrollView 之外的空白区域往下拖,倒可以触发刷新。
这个问题我排查了快两个小时,过程记录下来。
第一步,检查 XML 结构。SwipeRefreshLayout 只包了一个 ScrollView,ScrollView 里只放了一个 LinearLayout,结构没问题。
第二步,在 SwipeRefreshLayout 的 onInterceptTouchEvent 里打日志,发现 ACTION_DOWN 事件每次都走到了canChildScrollUp()的判断。于是我又在canChildScrollUp()前后打印了返回值,发现返回的是 false,也就是说判断结果确实是“可以下拉”。那为什么手指在列表区域拖动时没有触发?
第三步,检查 OnTouchListener。我没有给 ScrollView 设置任何 OnTouchListener,排除拦截嫌疑。
第四步,把注意力放在 ScrollView 本身。这时候我突然意识到一个问题:ScrollView 里的子 View 高度被设置成了 match_parent。我加载 item 的根布局使用的是LayoutInflater.from(context).inflate(R.layout.item_list, listContainer, false),但 item 的根布局 XML 里被我写了一个占满全屏的 FrameLayout,FrameLayout 里的内容并没有把高度撑起来,导致 LinearLayout 容器被 FrameLayout 撑成了全屏高度。
这一步带来的连锁反应是:ScrollView 认为自己的内容区域高度等于屏幕高度,虽然视觉上内容只有三条 item,但布局层面积已经是全屏了。在某些系统版本的 ViewCompat 实现里,这个状态可能导致 canChildScrollUp 的判定出现边界偏差,加上 ScrollView 自身的滚动逻辑又认为“没有内容可滚”,事件处理上出现了矛盾。
最终解决方案是:把 item 根布局的高度从 match_parent 改成 wrap_content,同时在 inflate 时明确指定 LayoutParams。改完之后,下拉刷新立刻恢复正常,从列表区域任意位置下拉都稳稳触发。
4.2 动态添加子 View 后 item 高度和宽度异常的坑
另一个常见的坑是:动态 addView 进去的 item 显示出来宽度不对或者高度塌陷。
我之前遇到过的情况是:item 里有个 ImageView 加载一张网络图片,图片加载完成后,ImageView 的实际高度和我在代码里声明的 wrap_content 不一致,导致 item 布局被拉伸,页面出现一大块空白。
排查过程比较曲折,最终定位到两个原因。
第一个原因,ImageView 没有设置 scaleType 和 adjustViewBounds。图片加载进来后默认按原始尺寸绘制,把 item 撑得很大。解决办法是在 XML 里设置android:scaleType="centerCrop"和android:adjustViewBounds="true",或者直接给 ImageView 写死宽高。
第二个原因,addView 时没有传 LayoutParams。如果你直接在代码里 new 一个 View 然后 addView 进去,系统会给你一个默认 LayoutParams,宽度和高度都是 wrap_content。问题是这个 wrap_content 的默认行为有时候不是你想要的效果,特别是设置的 LayoutParams 类型和实际容器不匹配时,会显示异常。
正确的做法是:
val layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) listContainer.addView(itemView, layoutParams)添加 item 的话,我还建议在 item 之间加一个分割线或者 margin,不然多个 item 堆一起,视觉上像一大坨,也容易让用户误以为内容没有更新。可以用一个通用的方法:
private fun addItemWithDivider(itemView: View, marginBottom: Int) { val layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) layoutParams.setMargins(0, 0, 0, marginBottom) listContainer.addView(itemView, layoutParams) }4.3 刷新完成时 setRefreshing(false) 时机不对
这个坑属于典型的“逻辑顺序”问题,代码不会报错,但视觉效果很怪。
我遇到过两种表现。
第一种,转圈闪一下就没。原因是把isRefreshing = false放在了一个提前回调的地方,比如把 fetchData 的回调结果先缓存了,然后认为数据已经加载完成,直接收起转圈。但此时 item View 还没来得及创建和 addView,用户看到的效果就是:转圈突然消失,页面空白,过了几百毫秒内容刷一下出现。
第二种,转圈永远不消失。原因是数据加载过程中抛了异常,没有进入回调,isRefreshing 一直没有被置为 false。这种情况更隐蔽,因为项目默认把异常吞掉了,只打了一行日志,UI 上就一直卡在刷新的状态。
排查这个问题,我建议你把“刷新完成”看作一个状态机流转,而不是一个简单的回调:
private fun loadData() { refreshLayout.isRefreshing = true viewModel.fetchData( onSuccess = { result -> renderList(result) completeRefresh() }, onError = { error -> showErrorToast(error) completeRefresh() } ) } private fun completeRefresh() { // 确保在 UI 线程 refreshLayout.isRefreshing = false }核心原则:无论成功还是失败,都必须有最终收起转圈的出口。最稳妥的办法是在网络请求的最外层包裹 try/finally,确保万无一失。
4.4 特殊子 View 引发的连带崩溃
除了逻辑问题,还有一个容易在“手工列表”中踩的连带坑:item 里有 ImageView 需要加载本地图片或者 content:// URI 的图片时,会触发 FileUriExposedException 崩溃。
这个问题的根因是 Android 7.0 之后对 file:// URI 的限制。如果你手动拼接了一个content://com.tencent.wework.fileprovider/external_path/...之类的 URI 并直接传给 ImageView,如果你没有正确配置 FileProvider 和对应路径规则,就会在页面加载时崩溃。
解决方案有两个路径:
路径一,使用成熟的图片加载库。比如 Glide、Coil,它们内部已经处理好了 URI 转换、缓存和管理。用 Coil 的代码简洁得感人:
imageView.load(imageUrl)路径二,自己配置 FileProvider。如果确实需要手动处理,需要在 AndroidManifest.xml 里声明 FileProvider,并在 xml 里配置 paths 规则。这个方案不在本文展开,只提醒一句:凡是通过外部传入的 URI,不要直接丢给 ImageView 做 decode。用 Glide 或 Coil 可以省掉一大批这种莫名其妙的崩溃。
你在 Android Studio 里调试这类问题的时候,布局层级看着头大,可以打开 Layout Inspector 直接看视图树。ScrollView 的层级本身不深,但动态 addView 进去的 item 数量多了之后,视图树会比较长,用 Layout Inspector 能快速定位到具体是哪个 item 的哪个控件尺寸异常。
5. 进阶:让“手工列表”更接近真实 App 体验
基础功能跑通之后,如果你愿意再多做一步,这个“手工列表”的体验可以再上一个台阶。下面这几个方向是我在项目里实际验证过的。
5.1 空数据、加载失败与正常状态的切换
真实项目里,接口不一定每次都有数据。什么都不显示直接给用户一个空白页面,体验很糟糕。所以我在列表容器外面额外准备了两个布局:空数据布局和加载失败布局。
思路很简单:在 loadData 的结果里分情况处理。
private fun renderList(result: List<ItemData>) { emptyView.visibility = View.GONE errorView.visibility = View.GONE listContainer.visibility = View.VISIBLE if (result.isEmpty()) { listContainer.visibility = View.GONE emptyView.visibility = View.VISIBLE return } listContainer.removeAllViews() result.forEach { item -> listContainer.addView(createItemView(item)) } }加载失败同理,展示错误提示和一个“重试”按钮。这个重试按钮的点击事件重新调用 loadData,就完成了“失败 -> 重试 -> 成功”的闭环。
有个细节:空数据布局和错误布局最好放在 SwipeRefreshLayout 的里面。这样即使用户在空数据状态下,也能通过下拉刷新重新发起请求。如果把它们放在 SwipeRefreshLayout 外面,下拉手势会被空布局拦截,刷新就无效了。
5.2 与“上拉加载更多”联动
ScrollView 配合 SwipeRefreshLayout 做下拉刷新没问题,但如果你还想要“上拉加载更多”,那就需要自己监听滚动了。
用 NestedScrollView 的话,可以这样监听:
nestedScrollView.setOnScrollChangeListener { _, _, scrollY, _, _ -> // 判断是否滚动到底部 if (scrollY >= nestedScrollView.getChildAt(0).height - nestedScrollView.height) { if (hasMore) { loadMore() } } }这个判断的原理是:子 View 的总高度减去 ScrollView 的可视高度等于最大滚动距离,scrollY 达到这个值就说明已经滚到底部了。
但我要提醒一句:ScrollView 方案做上拉加载更多,有个天然短板——没有“加载中”的脚部状态展示。RecyclerView 可以通过 addFooterView 或者 Adapter 的 getItemViewType 来展示一个“正在加载”的底部条,但 ScrollView 需要你自己额外 addView 一个 loading 的脚部布局,加载完成后再 remove。不是不能做,但代码量会涨不少。
所以我还是那句话:如果业务明确需要分页加载,数据量可能持续增长,尽早切换成 RecyclerView。下拉刷新 + 上拉加载更多这种组合,从来不是 ScrollView 的强项。
5.3 自定义刷新动画的配色与行为
SwipeRefreshLayout 默认的转圈是主题色,如果你想跟 App 的品牌色一致,或者觉得默认的白色背景太突兀,可以在初始化时设置:
refreshLayout.setColorSchemeResources( R.color.refresh_primary, R.color.refresh_accent ) refreshLayout.setProgressBackgroundColorSchemeResource(R.color.white) refreshLayout.setDistanceToTriggerSync(dp2px(80f))setColorSchemeResources 设置的是转圈的颜色序列,可以在下拉过程中依次变化。setProgressBackgroundColorSchemeResource 设置的是转圈背后的圆形背景色。如果 App 页面底色是深色,把背景设成半透明或者白色,转圈才看得清。
还有一个很多人不知道的参数:setDistanceToTriggerSync。它控制的是下拉多少距离才触发刷新。默认值是 64dp,如果你觉得默认下拉距离太长或者太短,可以通过这个接口调整。我一般会在内容不满一屏的页面上把它调小一点,比如 48dp,让用户稍微拉一点就能触发。
如果你对默认的进度条样式不满意,SwipeRefreshLayout 还支持通过setProgressViewOffset调整转圈的初始偏移量,让它在页面顶部显示还是稍微靠下一点。这些参数没有标准答案,我建议按照你的页面头部布局实际调一调,观感最舒服就行。
最后再分享一个细节:如果这个页面本身是一个 Fragment,不要把 SwipeRefreshLayout 的初始化逻辑放在 onCreateView 之前。等 Fragment 的 View 创建完成后,再去 findViewById 和 setOnRefreshListener,否则大概率拿到一个 null,然后一脸懵。
我在实际项目中就是让这种对手工列表又爱又恨的状态持续了一整年。爱的是它真的快,几个文件就搞定一个页面;恨的是每次数据量超了预期,都得回头做技术债重构。建议你也像我一样,在心里给这个方案划一条明确的数据量红线——超过 30 条就果断换 RecyclerView。在这个范围内,SwipeRefreshLayout + ScrollView 作为一个轻量列表的快速方案,用起来还是很舒服的。