在 iOS 开发中,MJRefresh 几乎成为了下拉刷新组件的代名词。它稳定、易用,但默认的灰色菊花样式往往难以满足设计团队的审美要求。不过,MJRefresh 的真正魅力在于其高度的可定制性。本文将带你从 0 到 1,构建一个融合 Lottie 动画的工业级自定义刷新组件,并深入剖析其背后的控制机制。
一、MJRefresh 的核心控制维度
在动手编码之前,理解 MJRefresh 的两个核心控制维度至关重要,这决定了我们能否实现“跟手”的动画效果。
- pullingPercent(过程控制):这是一个随手势实时变化的数值,范围在 0 到 1 之间,表示当前下拉距离与触发刷新高度的比值。它非常适合用来驱动与手势进度强相关的动画。
- state(阶段控制):这是刷新组件的生命周期状态机,典型流转为 idle → pulling → refreshing → idle。需要注意的是,在下拉刚开始时,状态仍然是 idle,直到超过临界点才会切换。
理解这两个维度的区别,是区分“能用”和“好用”的分水岭。很多开发者只关注 state,却忽略了 pullingPercent 在交互细节上的强大作用。
二、第一层:构建可运行的 Lottie 刷新组件
我们的第一步是快速实现一个基础版本,让动画能够跑起来。首先,通过 CocoaPods 集成依赖:
pod 'MJRefresh', '~> 3.7.9'
pod 'lottie-ios'
接下来,创建自定义 Header 类,并继承自 MJRefreshHeader。在 prepare() 方法中完成初始化配置:
import UIKit
import MJRefresh
import Lottie
import ZMCommon
@objcMembers
class ZMLottieRefreshHeader: MJRefreshHeader {
private let lottieView = LottieAnimationView()
....
}
override func prepare() {
super.prepare()
// 设置触发高度
mj_h = triggerOffset
let bundle = Bundle.main
lottieView.animation = LottieAnimation.named(
"loading",
bundle: bundle
)
lottieView.contentMode = .scaleAspectFit
lottieView.loopMode = .loop
lottieView.backgroundColor = .clear
addSubview(lottieView)
}
这里我们做了几件事:设置了触发刷新的高度(例如 80pt)、加载了本地 Lottie 动画文件、设置了循环播放模式,并最终添加到视图层级中。最后,在 placeSubviews() 中完成布局:
override func placeSubviews() {
super.placeSubviews()
let size: CGFloat = 40
lottieView.frame = CGRect(
x: (bounds.width - size) / 2,
y: (bounds.height - size) / 2,
width: size,
height: size
)
}
最后,通过重写状态回调来控制动画的播放与停止:
override var state: MJRefreshState {
didSet {
switch state {
case .idle:
lottieView.stop()
lottieView.currentProgress = 0
case .pulling:
// 到达临界点
lottieView.play()
case .refreshing:
refreshBeginTime = Date().timeIntervalSince1970
lottieView.play(
fromProgress: 0,
toProgress: 1,
loopMode: .loop
)
default:
break
}
}
}
✅ 至此,一个可以运行的基础版 Lottie 刷新组件已经完成。但你会发现,它在下拉初始阶段没有动画,且刷新动画可能因接口返回过快而一闪而过。
三、第二层:让动画“跟手” —— 利用 pullingPercent
为了解决“下拉初始无动画”的问题,我们需要将注意力从 state 转移到 pullingPercent 上。因为在下拉过程中,只有 pullingPercent 是连续变化的。

我们的优化思路是:在 pullingPercent 大于 0 时,控制动画播放到对应的预览帧;当达到临界值(pullingPercent == 1)时,刚好播放到设计稿指定的预览进度。这样,松手后动画就能无缝衔接到刷新循环中。
override var pullingPercent: CGFloat
override var pullingPercent: CGFloat {
didSet {
guard state != .refreshing else { return }
if pullingPercent > 0 {
if !lottieView.isAnimationPlaying {
lottieView.play()
}
} else {
lottieView.stop()
lottieView.currentProgress = 0
}
}
}
override var pullingPercent: CGFloat {
didSet {
guard state != .refreshing else { return }
let percent = min(1, max(0, pullingPercent))
let progress = percent * previewProgress
lottieView.currentProgress = progress
}
}
同时,我们需要删除在 .refreshing 状态下无条件播放动画的代码,让职责更加清晰:
case .pulling:
lottieView.play()
这一层优化后,用户体验有了质的飞跃:下拉多少,动画就播放多少,整个过程完全由手势驱动,非常自然。
四、第三层:产品级细节 —— 最少停留时间
即使动画“跟手”了,仍有一个常见痛点:如果网络请求极快,刷新动画刚出现就消失,会造成视觉闪烁。为了解决这个问题,我们引入“最少停留时间”机制。
首先,定义两个属性来记录刷新开始时间和最小持续时间:
/// 刷新时间
private var refreshBeginTime: TimeInterval = 0
/// 最小停留时间
private let minimumStayDuration: TimeInterval = 1.5
在状态切换为 .refreshing 时,记录当前时间戳:
refreshBeginTime = Date().timeIntervalSince1970
最后,重写状态切换方法,在结束刷新时判断是否满足最短时间要求:
override func endRefreshing() {
let now = Date().timeIntervalSince1970
let elapsed = now - refreshBeginTime
if elapsed >= minimumStayDuration {
super.endRefreshing()
} else {
DispatchQueue.main.asyncAfter(
deadline: .now() + (minimumStayDuration - elapsed)
) {
super.endRefreshing()
}
}
}
这样,无论接口响应多快,刷新动画都至少会展示 1.5 秒,避免了因动画瞬间消失带来的突兀感。这种细节处理,在 Go、TypeScript、Java、Python 或 C++ 等任何语言的客户端开发中,都是提升用户体验的关键。
五、技术延伸与性能考量
除了上述核心逻辑,在实际项目中我们还可以考虑以下优化点:
- 动态帧率控制:在 pullingPercent 驱动下,可以考虑降低动画的帧率(如 30fps),以节省 CPU 资源。
- 内存优化:确保 Lottie 动画文件在不需要时被正确释放,避免内存泄漏。
- 状态恢复:处理 App 进入后台再返回时的动画状态同步问题。
这些实践不仅能提升刷新组件的质量,也能为你在其他 UI 动画开发中提供宝贵的经验。
六、结语
回顾整个优化过程,我们从“能跑”的基础版,到“跟手”的进度控制,再到“稳定”的产品级优化,这不仅是代码的演进,更是对 MJRefresh 内部机制理解的深化。一个看似简单的下拉刷新,实则蕴含着对用户交互体验的极致追求。当动画顺滑、节奏稳定、衔接自然时,用户感受到的是 App 的品质感,而这正是技术的价值所在。希望这篇文章能帮助你打造出更出色的刷新体验。
[AFFILIATE_SLOT_2]
浙公网安备 33010602011771号