44、Material Design之SwipeRefreshLayout:下拉刷新、自定义刷新样式、与RecyclerView结合

下拉刷新这个交互,说实话,已经成了移动应用的标配了。用户手指轻轻一划,内容就焕然一新,这种体验几乎成了「好用」的代名词。在Android里,实现这个功能最正统的方式就是SwipeRefreshLayout。它属于Material Design体系,用起来简单,但想玩出花来,还是有不少门道的。

我个人习惯,只要涉及列表展示,都会优先考虑加上下拉刷新。用户有掌控感,数据也新鲜。今天我们就把它彻底聊透。

44.1 什么是SwipeRefreshLayout?

SwipeRefreshLayout,说白了就是一个可以包裹子View的容器。它监听用户的触摸事件,当检测到从屏幕顶部向下的滑动时,就会触发刷新回调,并显示一个旋转的加载动画。

它只能包含一个直接子View。最常见的搭配就是RecyclerView,当然也可以是NestedScrollView、ListView等。

它的核心工作流程,我画了张图,你看一眼就明白了:

SwipeRefreshLayout 核心工作流程 用户手指下滑 触发 onRefresh 回调 显示刷新动画 执行数据加载 调用 setRefreshing(false) 隐藏动画,刷新完成 用户可继续操作

流程不复杂,但有几个细节容易踩坑。我们一个一个说。

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。不过要注意,这种方式需要你手动控制动画的播放和停止,比较麻烦。

我的建议:如果只是改颜色和大小,用SwipeRefreshLayout自带的API就够了。如果想做炫酷的动画效果,建议升级到MaterialRefreshLayout,或者用第三方库如「SmartRefreshLayout」。别在SwipeRefreshLayout上死磕自定义视图,坑多。

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)。

注意:千万不要在onRefresh里直接调用adapter.refresh()后立即关闭刷新动画。因为adapter.refresh()是异步的,动画会一闪而过。一定要等数据加载完成后再关闭。

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