Featured image of post 全新开源项目 TTGRefresh:一个低侵入、高定制的刷新组件

全新开源项目 TTGRefresh:一个低侵入、高定制的刷新组件

前言

这次是一个全新的项目:TTGRefresh

iOS 里下拉刷新、上拉加载更多是非常常见的需求。自己写并不难,但真正放到项目里以后,会慢慢遇到一些细节:contentInset 怎么恢复、刷新和加载更多能不能同时触发、异步任务结束时 UI 怎么收起、列表内容不满一屏时 footer 怎么处理、想换一个动画样式是不是要改很多业务代码。

TTGRefresh 的目标就是把这些细节收拢起来,同时保持 API 尽量轻:通过一个 scrollView.ttg 命名空间,把 header refresh、footer refresh、infinite load、默认 UI、路径动画和自定义 View 都接进去。

TTGRefresh Poster

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 个路径动画样式,先能用、再定制。✨

Overview

快速使用

最常见的下拉刷新:

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()
}

Quick Start

这个 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 样式,比如 auroraWavepulseCircuitquantumKnotrocketOrbit 等。每个样式都可以同时作为 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()
}

Path Grid

这些内置样式不是为了让每个项目都用花哨动画,而是给组件提供一个足够完整的默认能力:普通工具型 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()
}

Custom Paths

完全自定义 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 也不需要知道业务怎么加载数据。

架构

Architecture

内部大致分成几块:

  • 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 低侵入、定制能力足够强、内置样式开箱即用。后面如果继续扩展,也应该围绕这几个原则来做,而不是把它做成一个很重、很难接入的列表框架。