在 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 进入后台再返回时的动画状态同步问题。
[AFFILIATE_SLOT_1]

这些实践不仅能提升刷新组件的质量,也能为你在其他 UI 动画开发中提供宝贵的经验。

六、结语

回顾整个优化过程,我们从“能跑”的基础版,到“跟手”的进度控制,再到“稳定”的产品级优化,这不仅是代码的演进,更是对 MJRefresh 内部机制理解的深化。一个看似简单的下拉刷新,实则蕴含着对用户交互体验的极致追求。当动画顺滑、节奏稳定、衔接自然时,用户感受到的是 App 的品质感,而这正是技术的价值所在。希望这篇文章能帮助你打造出更出色的刷新体验。

[AFFILIATE_SLOT_2]