前言
在健康、训练与打卡类应用中,首页常展示一种“连续 N 天完成”的状态卡片。表面上看只是一个普通模块,但实际它集成了进度展示、剩余天数提示、完成状态切换、二次操作入口及自定义弹窗等多重功能。若仅临时叠加多个控件,后期维护将变得极其困难。
一个更清晰的思路是:将其设计为一个双状态组件,而非一张“会变色的卡片”。

为何进度卡不能仅靠 isHidden 打补丁
许多开发者在实现进度卡时倾向于:先堆砌所有控件,在tracking状态隐藏部分元素,completed状态再显示另一部分。短期内看似简便,长期却导致两大典型问题:状态逻辑难以维护,交互事件相互干扰。
更推荐的做法是显式建立一个状态枚举模型:
enum ProgressCardStyle {case tracking(remainingDays: Int, completedDays: Int)case completed}
这样,组件在 configure 时无需猜测显示内容,状态清晰明了。
组件层仅处理状态和事件,不涉及业务逻辑
本次设计的进度卡最终暴露了三个清晰的事件:onInfoTap、onRecalculateTap、onUnlockTap。组件只负责传递用户行为,至于点击后弹出内容、是否重置、如何跳转等,全部交由页面控制器处理。
大致结构如下:
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。以下是一个简易实现示例:
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 上时常常遇到一个问题:弹窗显示、页面变暗,但底部导航依然可见。推荐的解决方案是直接将 overlay 挂载到当前 window:
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。
总结
这类首页状态卡片看似只是一个模块,实则是一个典型的“小型状态系统”。若希望后期易于维护,请务必遵循以下原则:
- 显式定义状态枚举,避免依赖大量
isHidden补丁 - 组件仅暴露事件接口,不直接参与业务判断
- 渐变进度条优先使用
CAGradientLayer - 全屏 overlay 优先挂载到
window层级 - 显示层绝不可伪造真实状态
一句话总结:优秀的状态卡片并非控件堆砌而成,而是具有清晰状态边界、交互边界和显示边界的组件。
