前言
这次是一个全新的项目:TTGRefresh。
iOS 里下拉刷新、上拉加载更多是非常常见的需求。自己写并不难,但真正放到项目里以后,会慢慢遇到一些细节:contentInset 怎么恢复、刷新和加载更多能不能同时触发、异步任务结束时 UI 怎么收起、列表内容不满一屏时 footer 怎么处理、想换一个动画样式是不是要改很多业务代码。
TTGRefresh 的目标就是把这些细节收拢起来,同时保持 API 尽量轻:通过一个 scrollView.ttg 命名空间,把 header refresh、footer refresh、infinite load、默认 UI、路径动画和自定义 View 都接进去。

Github: https://github.com/zekunyan/TTGRefresh
项目特点
TTGRefresh 0.1.0 目前是第一个版本,我希望它一开始就把方向定清楚:
- 极简 API:常用场景一两行代码完成。
- 低侵入性:不需要替换
UITableView/UICollectionView,只通过scrollView.ttg扩展安装能力。 - 高度可定制:默认文字 UI、20 种内置 path effect、或者完全自定义
UIView都可以接入。 - 状态机明确:header 和 footer 都有独立状态,刷新、加载、结束、无更多数据都可控。
- 异步友好:支持 callback 和 async/await,并且可配置异步结束后自动收起。
- 避免重复触发:内部有 activity coordinator,默认拦截重复刷新、加载更多互相打架这类问题。
- 开箱即用动画样式:内置 20 个路径动画样式,先能用、再定制。✨

快速使用
最常见的下拉刷新:
import TTGRefresh
tableView.ttg.addHeaderRefresh {
await viewModel.reload()
}
上拉加载更多:
tableView.ttg.addFooterRefresh {
await viewModel.loadNextPage()
}
tableView.ttg.endFooterRefreshingWithNoMoreData()
tableView.ttg.resetFooterNoMoreData()
如果是 feed 流,希望滚动接近底部时自动加载下一页:
collectionView.ttg.addInfiniteLoad(preloadOffset: 180) {
await viewModel.loadNextPage()
}

这个 API 的重点是 scrollView.ttg。它不会要求你换一个列表基类,也不会把刷新逻辑藏到 ViewController 之外。它只是给已有的 UIScrollView 增加一个很薄的入口,业务代码仍然围绕原本的 tableView / collectionView 写。
Programmatic Control
刷新控件不能只响应手势,很多页面会在首次进入时自动刷新,或者在筛选条件变化后主动重新加载。这类入口也走同一套状态机:
tableView.ttg.triggerRefresh()
tableView.ttg.triggerLoadMore()
tableView.ttg.beginHeaderRefreshing(animated: true, triggerAction: true)
tableView.ttg.beginFooterRefreshing(animated: true, triggerAction: true)
tableView.ttg.endHeaderRefreshing()
tableView.ttg.endFooterRefreshing()
这样手动触发和用户手势触发的表现是一致的:动画、状态、回调、防重入都走同一条路径。这个地方如果分开写,后面很容易出现“首次加载正常,手动下拉又是另一套状态”的问题。
内置动画样式
TTGRefresh 内置了 20 种 path effect 样式,比如 auroraWave、pulseCircuit、quantumKnot、rocketOrbit 等。每个样式都可以同时作为 refresh header、pull load-more footer 或 infinite-load footer。
tableView.ttg.addPathEffectHeaderRefresh(style: .auroraWave) {
await viewModel.reload()
}
tableView.ttg.addPathEffectFooterRefresh(style: .rocketOrbit) {
await viewModel.loadNextPage()
}
collectionView.ttg.addPathEffectInfiniteLoad(style: .pulseCircuit, preloadOffset: 160) {
await viewModel.loadNextPage()
}

这些内置样式不是为了让每个项目都用花哨动画,而是给组件提供一个足够完整的默认能力:普通工具型 App 可以用默认文字 UI,内容型或品牌型 App 可以快速挑一个合适的动画,再用 template 调整颜色、高度、速度、线宽。
let template = TTGRefreshPathEffectTemplate(
style: .quantumKnot,
headerPreferredHeight: 120,
footerPreferredHeight: 82,
footerContentWidth: 300,
colors: [.systemCyan, .systemPurple],
animationSpeed: 1.2,
lineWidthScale: 1.1
)
tableView.ttg.addPathEffectHeaderRefresh(template: template) {
await viewModel.reload()
}

完全自定义 View
如果内置样式不够,任意 UIView 都可以作为 header 或 footer,只要实现一个很小的协议。比如自定义 header:
final class CustomHeaderView: UIView, TTGRefreshHeaderContentView {
var preferredHeight: CGFloat { 88 }
var triggerHeight: CGFloat { 72 }
func refreshHeaderDidChange(state: TTGRefreshHeaderState) {
switch state {
case .idle:
break
case .pulling(let progress):
updatePullProgress(progress)
case .willRefresh:
showReleaseState()
case .refreshing:
startLoadingAnimation()
case .ending:
stopLoadingAnimation()
}
}
func refreshHeaderDidUpdate(progress: CGFloat) {
updatePullProgress(progress)
}
}
tableView.ttg.addHeaderRefresh(contentView: CustomHeaderView()) {
await viewModel.reload()
}
库只规定状态协议,不规定你的 UI 长什么样。这样对宿主页面的侵入就会低很多:页面不需要知道 header 内部动画怎么画,header 也不需要知道业务怎么加载数据。
架构

内部大致分成几块:
UIScrollView+TTGRefresh.swift:通过 associated object 安装 header/footer,提供scrollView.ttg入口。TTGRefreshHeader/TTGRefreshFooter:负责 scroll observation、状态变化、触发条件和结束动画。TTGRefreshActivityCoordinator:协调 header/footer,避免重复任务和交叉触发。TTGRefreshInsetStore:记录和恢复 inset,处理 contentInset / overlay 模式。TTGRefreshDefaultHeaderView/TTGRefreshDefaultFooterView:默认文字 UI。TTGRefreshPathEffectStyle:内置路径动画样式和 template。
这里我刻意把“运行时行为”和“展示内容”拆开。刷新控件最容易变复杂的地方,就是 scroll 状态、inset、动画、业务回调混在一个类里。拆开之后,默认 UI、path effect、自定义 View 都能走同一个状态机。
总结
TTGRefresh 目前是 0.1.0,但它不是一个只做最小 Demo 的项目。它一开始就希望把 API、状态机、自定义能力和内置动画都放到一个相对完整的结构里。
我自己最看重的几个点是:API 足够简单、对宿主 UI 低侵入、定制能力足够强、内置样式开箱即用。后面如果继续扩展,也应该围绕这几个原则来做,而不是把它做成一个很重、很难接入的列表框架。