iOS 首页进度卡实战:最难的不是渐变进度条,而是状态边界

前言

在很多健康类、训练类、打卡类产品里,首页都会有一种"连续 N 天完成"的状态卡。

这类卡片表面上看只是一个模块,实际上往往同时包含:

  • 进度展示
  • 剩余提示
  • 完成态切换
  • 二次操作入口
  • 自定义弹窗

如果只是临时堆几个控件,后面很快就会失控。

我这次做法比较明确:
把它当成一个双状态组件来设计,而不是一张"能变色的卡片"。

为什么这种进度卡不能只靠 isHidden 打补丁

很多人做进度卡时,会采用这种思路:

  • 所有控件先堆上去
  • tracking 状态隐藏一部分
  • completed 状态再显示另一部分

这种方案短期看起来省事,长期通常会有两个问题:

第一,状态越来越难读。

第二,交互事件会混在一起。

所以我更推荐显式地建一个状态模型:

swift 复制代码
enum ProgressCardStyle {
    case tracking(remainingDays: Int, completedDays: Int)
    case completed
}

这样组件在 configure 的时候,就不需要靠"猜"来决定该显示什么。

组件层只管状态和事件,不直接管业务

我这次的进度卡最终暴露了几个清晰的事件:

  • onInfoTap
  • onRecalculateTap
  • onUnlockTap

也就是说,组件只负责把用户行为往外抛,至于点击之后弹什么、是否重置、是否进入下一步,由页面控制器来决定。

大致结构会像这样:

swift 复制代码
final class ProgressCardView: UIView {
    var onInfoTap: (() -> Void)?
    var onRecalculateTap: (() -> Void)?
    var onUnlockTap: (() -> Void)?

    private let infoButton = UIButton(type: .system)
    private let recalculateButton = UIButton(type: .system)
    private let unlockButton = UIButton(type: .system)

    override init(frame: CGRect) {
        super.init(frame: frame)

        infoButton.addTarget(self, action: #selector(infoTapped), for: .touchUpInside)
        recalculateButton.addTarget(self, action: #selector(recalculateTapped), for: .touchUpInside)
        unlockButton.addTarget(self, action: #selector(unlockTapped), for: .touchUpInside)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    @objc private func infoTapped() { onInfoTap?() }
    @objc private func recalculateTapped() { onRecalculateTap?() }
    @objc private func unlockTapped() { onUnlockTap?() }
}

这种做法的优点是:

后续你不管怎么改页面流程,卡片本身都不需要掺杂业务判断。

tracking 和 completed 两个状态该怎么落

我一般会这么拆:

tracking 状态

  • 白底卡片
  • 左侧信息图标
  • 中部文案提示还差几天
  • 下方进度条和天数刻度

completed 状态

  • 高亮卡片背景
  • 完成态文案
  • Recalculate 按钮
  • 主 CTA 按钮

也就是两种状态共用一个组件入口,但内部布局和交互重心不同。

进度条为什么更适合用 CAGradientLayer

如果设计稿里的进度条是渐变色,而且宽度会动态变化,我更推荐 CAGradientLayer,不要用图片平铺或者 patternImage

一个简单示例:

swift 复制代码
final class GradientProgressView: UIView {
    private let trackView = UIView()
    private let fillView = UIView()
    private let gradientLayer = CAGradientLayer()
    private var fillWidthConstraint: NSLayoutConstraint?
    private var progressRatio: CGFloat = 0

    override init(frame: CGRect) {
        super.init(frame: frame)

        trackView.backgroundColor = UIColor(hex: "#ECEBF6")
        trackView.layer.cornerRadius = 5
        trackView.layer.masksToBounds = true
        fillView.layer.cornerRadius = 5
        fillView.layer.masksToBounds = true

        [trackView].forEach {
            $0.translatesAutoresizingMaskIntoConstraints = false
            addSubview($0)
        }
        fillView.translatesAutoresizingMaskIntoConstraints = false

        trackView.addSubview(fillView)
        fillView.layer.addSublayer(gradientLayer)

        NSLayoutConstraint.activate([
            trackView.leadingAnchor.constraint(equalTo: leadingAnchor),
            trackView.trailingAnchor.constraint(equalTo: trailingAnchor),
            trackView.topAnchor.constraint(equalTo: topAnchor),
            trackView.bottomAnchor.constraint(equalTo: bottomAnchor),
            fillView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor),
            fillView.topAnchor.constraint(equalTo: trackView.topAnchor),
            fillView.bottomAnchor.constraint(equalTo: trackView.bottomAnchor)
        ])
        fillWidthConstraint = fillView.widthAnchor.constraint(equalToConstant: 0)
        fillWidthConstraint?.isActive = true

        gradientLayer.colors = [
            UIColor(hex: "#7B39ED").cgColor,
            UIColor(hex: "#9B59F0").cgColor
        ]
        gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
        gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        fillWidthConstraint?.constant = trackView.bounds.width * progressRatio
        gradientLayer.frame = fillView.bounds
    }

    func updateProgress(_ ratio: CGFloat) {
        progressRatio = max(0, min(1, ratio))
        fillWidthConstraint?.constant = trackView.bounds.width * progressRatio
        layoutIfNeeded()
    }
}

这种方式最大的好处是:

  • 动态宽度更稳定
  • 圆角端点更自然
  • 颜色和方向更容易精确贴设计稿

自定义弹窗为什么建议挂到 window

如果项目里已经有自定义 tabbar,或者底部有持续置顶的容器,那么很多 overlay 加到当前页面 view 上时,会出现一个问题:

  • 弹窗显示了
  • 页面也 dim 了
  • 但底部导航还露在外面

我最后的做法是,直接把这类 overlay 挂到当前 window

swift 复制代码
func presentDimOverlay(_ overlay: UIView, from hostView: UIView) {
    guard let window = hostView.window else {
        hostView.addSubview(overlay)
        overlay.frame = hostView.bounds
        return
    }

    window.addSubview(overlay)
    overlay.frame = window.bounds
}

这一招对"自定义底部导航 + 自定义弹窗"的组合特别有效。

一个非常容易被忽略的问题:显示层不要伪造状态

我这次还踩到一个典型坑:

进度重置后,视觉上居然还像已经完成了第 1 天。

原因不是数据没清,而是 view 层给进度条做了"最小显示宽度",导致 0 天 看起来也像有一截进度。

这里的原则很重要:

显示层可以美化样式,但不能篡改真实状态。

如果真实状态是 0,那 UI 就应该真的显示 0

总结

这类首页状态卡,看起来只是一个模块,实际上是很典型的"小型状态系统"。

如果你想让它后面不难维护,我建议坚持这几条:

  1. 显式建状态,不靠一堆 isHidden 打补丁
  2. 组件只暴露事件,不直接承担业务判断
  3. 渐变进度条优先用 CAGradientLayer
  4. 全屏 overlay 优先挂 window
  5. 显示层不要伪造真实状态

一句话总结:

好用的状态卡,不是控件堆出来的,而是有清晰状态边界、交互边界和显示边界的组件。

相关推荐
甲维斯2 小时前
六大Coding Plan 速度和tokens消耗测试!
ai编程
智星云算力2 小时前
实验室无GPU如何深度学习
人工智能·深度学习·阿里云·智星云·gpu算力租用
用户5191495848452 小时前
Struts2 S2-045 远程代码执行漏洞检测工具(CVE-2017-5638)
人工智能·aigc
星辰_mya2 小时前
NLP((Natural Language Processing)简介
人工智能·自然语言处理
深瞳智检2 小时前
lesson-01 NLP 概述学习笔记 & 学习心得
人工智能·笔记·学习·自然语言处理·llm·大语言模型
泉木2 小时前
objc_class 结构体逐行解析
ios·objective-c
赫尔·普莱蒂科萨·帕塔2 小时前
重构AI漫剧工业化
人工智能·重构·动画·agi
米小虾2 小时前
从MCP到A2A:AI Agent 互操作性协议的演进与实战
人工智能
2301_764441332 小时前
GitNexus:AI智能体代码库索引知识图谱
人工智能·数据挖掘·知识图谱