要点
Pull-to-Refresh — 一种用户界面模式,用户向下拖动(pull down)列表或可滚动区域来刷新内容。视觉上,该手势伴随有一个加载指示器(spinner),出现在屏幕顶部,在收到数据后消失。该模式由iPhone版Tweetie应用(2008)推广,随后由Apple(iOS 6 — UIRefreshControl)和Google(Android Support Library — SwipeRefreshLayout)标准化。
从技术角度看,Pull-to-Refresh是平移跟踪(追踪手指位移)和达到阈值时触发的组合。用户向下拖动列表,克服阻力( resistive overscroll),在超过阈值后(iOS约80px,Android约64dp),指示器动画和异步加载启动。如果用户在阈值前释放手指 — 列表返回初始位置而不进行更新。
根据Material Design Guidelines,Pull-to-Refresh 不应用于导航或切换标签页 — 其唯一目的是刷新数据。在IT Sectr,我们将Pull-to-Refresh应用于新闻源、订单列表和聊天中,这些地方数据的时效性对用户体验至关重要。
UIRefreshControl — iOS中用于Pull-to-Refresh的标准控件,自iOS 6起可用。UIRefreshControl通过refreshControl属性(iOS 10+)添加到UITableViewController,或在更早版本中作为表的子视图添加。它包含一个内置的spinner,具有可配置的颜色(tintColor)、title属性和带标签的属性字符串(例如「正在刷新...」)。
UIRefreshControl通过target-action机制工作:手势激活时,调用指定的方法(例如refresh(_:))。在方法内部执行异步数据加载。完成后,调用endRefreshing(),以动画方式隐藏指示器。UIRefreshControl自动管理手势灵敏度 — 仅在表格顶部位置(contentOffset.y <= 0)时触发。
tintColor属性设置spinner的颜色。title属性允许在完成后显示「2分钟前更新」的文本。从iOS 10开始,UIRefreshControl支持通过UIActivityIndicatorView或持久化自定义视图进行自定义动画。在IT Sectr,我们将tintColor调整为品牌色,并通过attributedTitle显示上次更新时间 — 这增强了用户对数据的信任。
SwipeRefreshLayout — 来自Android Support Library(androidx.swiperefreshlayout)的ViewGroup,包装可滚动内容(RecyclerView、NestedScrollView、ListView)并添加Pull-to-Refresh功能。与UIRefreshControl(它是控件而非容器)不同,SwipeRefreshLayout是一个容器,它拦截子视图的触摸事件,并在超过阈值时触发刷新指示器。
SwipeRefreshLayout使用Material Design圆形进度指示器,通过setColorSchemeColors()设置颜色。setOnRefreshListener方法设置onRefresh()回调,在其中执行异步加载。完成后,调用setRefreshing(false)隐藏指示器。重要:setRefreshing(true)会再次调用onRefresh() — 因此要以编程方式启动刷新,请使用标志或post方法。
setProgressBackgroundColorSchemeResource属性更改指示器的背景。setSize(SwipeRefreshLayout.LARGE) — spinner的大小。在XML布局中,SwipeRefreshLayout包装RecyclerView:swipe_refresh_layout → recycler_view。根据Google I/O 2024,SwipeRefreshLayout在85%的带内容源的Android应用中使用。在IT Sectr,我们将所有具有异步加载列表的屏幕包装在SwipeRefreshLayout中 — 这确保了在所有Android版本上统一的用户体验。
从Android 12(Material You)开始,Google推荐使用来自material-1.6.0+库(Compose使用androidx.compose.material3.pulltorefresh)的新版Material Pull-to-Refresh。新API使用带有spring动画支持和基于壁纸的自适应颜色的动画指示器。SwipeRefreshLayout对Android 12以下版本保持兼容。
Pull-to-Refresh — 容易实现的模式,但包含几个降低用户体验的典型错误。让我们看看它们以及预防方法。
在IT Sectr,我们在测试服务器日志中发现重复请求后,在每个项目中添加了isRefreshing检查 — 结果发现手指快的用户会连续触发刷新多达3次。
向UITableViewController添加带有自定义spinner颜色和attributed title的Pull-to-Refresh。数据加载完成后隐藏指示器。
import UIKit
class FeedTableViewController: UITableViewController {
private var items: [String] = []
override func viewDidLoad() {
super.viewDidLoad()
tableView.refreshControl = UIRefreshControl()
refreshControl?.tintColor = .systemBlue
refreshControl?.attributedTitle = NSAttributedString(
string: “下拉以刷新”
)
refreshControl?.addTarget(
self,
action: #selector(refreshData),
for: .valueChanged
)
}
@objc private func refreshData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.items = FeedService().fetchLatest()
self.tableView.reloadData()
self.refreshControl?.endRefreshing()
}
}
}
tableView.refreshControl属性(iOS 10+)设置UIRefreshControl。带有.valueChanged事件的addTarget在手势激活时触发。endRefreshing()是必需的 — 没有它,指示器将无限旋转。异步加载通过DispatchQueue.main.asyncAfter模拟 — 在实际项目中会是URLSession或async/await。
将RecyclerView包装在具有自定义指示器颜色的SwipeRefreshLayout中。onRefresh启动加载并在完成后隐藏指示器。
class FeedFragment : Fragment() {
private var _binding: FragmentFeedBinding? = null
private val binding get() = _binding!!
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
_binding = FragmentFeedBinding.inflate(inflater, container, false)
binding.swipeRefreshLayout.setColorSchemeColors(
resources.getColor(R.color.brand_blue, null),
resources.getColor(R.color.brand_green, null)
)
binding.swipeRefreshLayout.setOnRefreshListener {
loadData()
}
return binding.root
}
private fun loadData() {
viewModelScope.launch {
try {
val result = repository.getLatestFeed()
adapter.submitList(result)
} finally {
binding.swipeRefreshLayout.isRefreshing = false
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
setColorSchemeColors设置旋转的Material Design指示器的颜色。isRefreshing = false必须在finally中调用,以便即使在加载错误时也能隐藏指示器。ViewModelScope.launch在fragment的生命周期中执行协程 — 当fragment被销毁时,协程自动取消,防止内存泄漏。
现代SwiftUI提供了.refreshable修饰符,可自动向List或ScrollView添加Pull-to-Refresh。
import SwiftUI
struct FeedView: View {
@State private var items: [String] = []
var body: some View {
List(items, id: \.self) { item in
Text(item)
}
.refreshable {
items = await FeedService().fetchLatestAsync()
}
}
}
.refreshable修饰符接受一个async-closure,在Pull-to-Refresh时执行。SwiftUI自动显示和隐藏刷新指示器,管理竞态条件(在当前加载完成前不启动重新加载)并根据平台调整动画。对于iOS 15+,这是在SwiftUI中实现Pull-to-Refresh的preferred方式。
常见问题
是的,SwiftUI为List或ScrollView提供了.refreshable修饰符,自iOS 15起可用。在闭包内执行异步数据加载代码。SwiftUI自动管理刷新指示器,并在当前加载完成前阻止重新触发 — 这是新项目的标准recommended方法。
使用isRefreshing标志:在加载开始时设置为true,完成后设置为false。在iOS中,UIRefreshControl会自动阻止重复调用,直到调用endRefreshing()。在Android中,在onRefresh()开始时检查SwipeRefreshLayout.isRefreshing:如果为true — return。这保证每个手势一次请求。
UIRefreshControl和SwipeRefreshLayout仅在列表顶部位置(contentOffset == 0)激活。架构排除了冲突:只要列表滚动了哪怕1px,Pull-to-Refresh手势就不会激活。如果发生冲突 — 检查Android中的nestedScrollingEnabled或拦截触摸的自定义GestureRecognizer。
总结
我们将开发一款交钥匙移动应用程序
IT Sectr自2017年以来为初创企业和企业打造iOS和Android应用程序。我们将为您提供咨询并提出最佳解决方案。