44、Material Design之SwipeRefreshLayout:下拉刷新、自定义刷新样式、与RecyclerView结合
下拉刷新这个交互,说实话,已经成了移动应用的标配了。用户手指轻轻一划,内容就焕然一新,这种体验几乎成了「好用」的代名词。在Android里,实现这个功能最正统的方式就是SwipeRefreshLayout。它属于Material Design体系,用起来简单,但想玩出花来,还是有不少门道的。
我个人习惯,只要涉及列表展示,都会优先考虑加上下拉刷新。用户有掌控感,数据也新鲜。今天我们就把它彻底聊透。
44.1 什么是SwipeRefreshLayout?
SwipeRefreshLayout,说白了就是一个可以包裹子View的容器。它监听用户的触摸事件,当检测到从屏幕顶部向下的滑动时,就会触发刷新回调,并显示一个旋转的加载动画。
它只能包含一个直接子View。最常见的搭配就是RecyclerView,当然也可以是NestedScrollView、ListView等。
它的核心工作流程,我画了张图,你看一眼就明白了:
流程不复杂,但有几个细节容易踩坑。我们一个一个说。
44.2 基本用法:三步搞定
先看最基础的用法。在布局文件里,用SwipeRefreshLayout包裹住RecyclerView:
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
然后在Activity或Fragment里,设置监听器:
val swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
swipeRefreshLayout.setOnRefreshListener {
// 这里执行数据刷新逻辑
loadData()
}
数据加载完成后,记得关闭刷新状态:
private fun loadData() {
viewModel.loadData().observe(this) { result ->
adapter.submitList(result)
swipeRefreshLayout.isRefreshing = false
}
}
嗯,这里要注意:isRefreshing = false 一定要在数据加载完成后调用。我曾经见过有人直接在onRefresh里就把它设成false,结果动画闪一下就没了,用户根本不知道刷新了。
44.3 自定义刷新样式
默认的刷新动画是Material Design的圆圈,白色或主题色。但有时候产品经理会说:「这个太普通了,能不能换个颜色?加个文字?」
可以。SwipeRefreshLayout提供了几个自定义的入口。
44.3.1 修改颜色
设置进度条的颜色,用setColorSchemeResources:
swipeRefreshLayout.setColorSchemeResources(
R.color.colorAccent,
R.color.colorPrimary,
R.color.colorPrimaryDark
)
也可以设置背景色:
swipeRefreshLayout.setProgressBackgroundColorSchemeResource(R.color.white)
我个人的习惯是,颜色不要超过三种,否则动画切换时眼花缭乱。
44.3.2 自定义刷新视图
如果你想完全替换掉那个转圈动画,比如显示一个自定义的ImageView或者Lottie动画,那就需要用到setChildView或自定义布局了。
不过说实话,SwipeRefreshLayout本身对自定义视图的支持比较有限。它内部用的是SwipeProgressBar,想完全替换,需要自己继承SwipeRefreshLayout并重写相关方法。更常见的做法是使用第三方库,或者直接用官方的MaterialRefreshLayout(Material 1.1.0之后推荐)。
但如果你坚持用SwipeRefreshLayout,可以这样设置自定义的刷新头:
// 创建一个自定义的View作为刷新指示器
val customView = LayoutInflater.from(this).inflate(R.layout.custom_refresh_header, null)
swipeRefreshLayout.addView(customView, 0)
然后在布局里,你可以放一个ImageView,配合帧动画或者Lottie。不过要注意,这种方式需要你手动控制动画的播放和停止,比较麻烦。
44.4 与RecyclerView结合的最佳实践
SwipeRefreshLayout + RecyclerView,这是最经典的组合。但有几个细节,处理不好就会出问题。
44.4.1 嵌套滚动冲突
当RecyclerView的内容不满一屏时,下拉刷新会失效。为什么?因为SwipeRefreshLayout检测到RecyclerView没有滚动到顶部,就不会触发刷新。
解决办法:确保RecyclerView的canScrollVertically(-1)返回false时,才允许触发刷新。SwipeRefreshLayout默认就是这么做的,但如果你用了NestedScrollView或者其他嵌套布局,就需要手动处理。
swipeRefreshLayout.setOnChildScrollUpCallback { parent, child ->
(child as? RecyclerView)?.canScrollVertically(-1) ?: true
}
这段代码的意思是:如果RecyclerView不能向上滚动(即已经在顶部),就允许触发下拉刷新。
44.4.2 刷新时禁止滑动
有时候用户在下拉刷新过程中,手指还没松开就上下滑动,会导致界面错乱。我建议在刷新期间禁用RecyclerView的滑动:
swipeRefreshLayout.setOnRefreshListener {
recyclerView.isEnabled = false
loadData()
}
// 数据加载完成后恢复
private fun onDataLoaded() {
recyclerView.isEnabled = true
swipeRefreshLayout.isRefreshing = false
}
44.4.3 配合Paging 3使用
如果你用了Paging 3库,下拉刷新的逻辑会稍有不同。Paging 3本身支持刷新,你只需要调用adapter.refresh()即可。但要注意,Paging 3的刷新是异步的,你需要监听LoadState来关闭刷新动画。
viewModel.flow.observe(this) { pagingData ->
adapter.submitData(lifecycle, pagingData)
}
lifecycleScope.launch {
adapter.loadStateFlow.collect { loadState ->
if (loadState.refresh is LoadState.NotLoading) {
swipeRefreshLayout.isRefreshing = false
}
}
}
这样,当数据加载完成后,刷新动画会自动关闭。不用手动调用setRefreshing(false)。
44.5 常见问题与避坑指南
做这个功能,我踩过的坑还真不少。挑几个典型的说说。
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 下拉刷新不触发 | RecyclerView未滚动到顶部,或SwipeRefreshLayout被嵌套在可滚动的父布局中 | 检查RecyclerView的滚动位置,或使用NestedScrollView作为父布局 |
| 刷新动画一直转 | 忘记调用setRefreshing(false) | 在数据加载完成的回调中关闭动画 |
| 刷新时界面卡顿 | 在主线程执行了耗时操作 | 使用协程或RxJava异步加载数据 |
| 自定义刷新头不显示 | SwipeRefreshLayout不支持完全替换刷新头 | 改用MaterialRefreshLayout或第三方库 |
我曾经在一个项目里,因为SwipeRefreshLayout外面又包了一层ScrollView,导致下拉刷新死活不触发。排查了半天才发现是嵌套滚动的问题。所以,SwipeRefreshLayout的直接子View最好是RecyclerView或NestedScrollView,不要再套其他可滚动的布局。
44.6 性能优化建议
下拉刷新虽然简单,但性能问题也不能忽视。
- 避免在onRefresh里做大量初始化操作。比如重新创建Adapter、重新设置LayoutManager等。这些操作应该在onCreate里完成,onRefresh只负责加载数据。
- 使用DiffUtil。如果你用RecyclerView,配合DiffUtil可以只更新变化的数据项,避免整个列表闪烁。
- 控制刷新频率。用户疯狂下拉刷新时,应该做防抖处理。比如在1秒内只允许触发一次刷新。
private var lastRefreshTime = 0L
swipeRefreshLayout.setOnRefreshListener {
val now = System.currentTimeMillis()
if (now - lastRefreshTime < 1000) {
swipeRefreshLayout.isRefreshing = false
return@setOnRefreshListener
}
lastRefreshTime = now
loadData()
}
这个小技巧,能有效避免用户手滑导致的重复刷新。
44.7 总结
SwipeRefreshLayout是Android下拉刷新的基础组件,用起来简单,但想用好还是需要花点心思。记住几个关键点:
- 布局嵌套要合理,避免滚动冲突
- 刷新状态要及时关闭
- 自定义样式量力而行,别在SwipeRefreshLayout上硬搞
- 配合Paging 3使用时,注意LoadState的监听
下拉刷新这个交互,用户每天都在用。做得流畅自然,用户会觉得「这App真顺手」。做得卡顿或者有bug,用户就会觉得「这App真难用」。细节决定体验,这话一点不假。
公众号:蓝海资料掘金营,微信deep3321