前言
在很多健康类、训练类、打卡类产品里,首页都会有一种“连续 N 天完成”的状态卡。
这类卡片表面上看只是一个模块,实际上往往同时包含:
- 进度展示
- 剩余提示
- 完成态切换
- 二次操作入口
- 自定义弹窗
如果只是临时堆几个控件,后面很快就会失控。
我这次做法比较明确:
把它当成一个双状态组件来设计,而不是一张“能变色的卡片”。
为什么这种进度卡不能只靠 isHidden 打补丁
很多人做进度卡时,会采用这种思路:
- 所有控件先堆上去
- tracking 状态隐藏一部分
- completed 状态再显示另一部分
这种方案短期看起来省事,长期通常会有两个问题:
第一,状态越来越难读。
第二,交互事件会混在一起。
所以我更推荐显式地建一个状态模型:
enum ProgressCardStyle {
case tracking(remainingDays: Int, completedDays: Int)
case completed
}
这样组件在 configure 的时候,就不需要靠“猜”来决定该显示什么。
组件层只管状态和事件,不直接管业务
我这次的进度卡最终暴露了几个清晰的事件:
onInfoTaponRecalculateTaponUnlockTap
也就是说,组件只负责把用户行为往外抛,至于点击之后弹什么、是否重置、是否进入下一步,由页面控制器来决定。
大致结构会像这样:
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 上时,会出现一个问题:
- 弹窗显示了
- 页面也 dim 了
- 但底部导航还露在外面
我最后的做法是,直接把这类 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 - 显示层不要伪造真实状态
一句话总结:
好用的状态卡,不是控件堆出来的,而是有清晰状态边界、交互边界和显示边界的组件。