拉斯维加斯的故事
44.25M · 2026-03-17
在很多健康类、训练类、打卡类产品里,首页都会有一种“连续 N 天完成”的状态卡。
这类卡片表面上看只是一个模块,实际上往往同时包含:
如果只是临时堆几个控件,后面很快就会失控。
我这次做法比较明确:
把它当成一个双状态组件来设计,而不是一张“能变色的卡片”。
isHidden 打补丁很多人做进度卡时,会采用这种思路:
这种方案短期看起来省事,长期通常会有两个问题:
第一,状态越来越难读。
第二,交互事件会混在一起。
所以我更推荐显式地建一个状态模型:
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?() }
}
这种做法的优点是:
后续你不管怎么改页面流程,卡片本身都不需要掺杂业务判断。
我一般会这么拆:
Recalculate 按钮也就是两种状态共用一个组件入口,但内部布局和交互重心不同。
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 打补丁CAGradientLayerwindow一句话总结:
好用的状态卡,不是控件堆出来的,而是有清晰状态边界、交互边界和显示边界的组件。