许多健康类、训练类和打卡类产品,都会在首页设置一种展示“连续 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 = 0override init(frame: CGRect) {super.init(frame: frame)trackView.backgroundColor = UIColor(hex: "#ECEBF6")trackView.layer.cornerRadius = 5trackView.layer.masksToBounds = truefillView.layer.cornerRadius = 5fillView.layer.masksToBounds = true[trackView].forEach {$0.translatesAutoresizingMaskIntoConstraints = falseaddSubview($0)}fillView.translatesAutoresizingMaskIntoConstraints = falsetrackView.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 = truegradientLayer.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 * progressRatiogradientLayer.frame = fillView.bounds}func updateProgress(_ ratio: CGFloat) {progressRatio = max(0, min(1, ratio))fillWidthConstraint?.constant = trackView.bounds.width * progressRatiolayoutIfNeeded()}}
这种方式最大的好处是:
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.boundsreturn}window.addSubview(overlay)overlay.frame = window.bounds}
这一招对“自定义底部导航 + 自定义弹窗”的组合特别有效。
这次我还遇到了一个典型问题:
重置进度之后,视觉效果竟然仍像是已经完成了第 1 天。
问题并非数据没有清空,而是 view 层为进度条设置了“最小显示宽度”,从而导致 0 天 从视觉上看也像存在一小段进度。
这里有一项非常重要的原则:
如果实际状态为 0,UI 就必须如实显示 0。
这类首页状态卡表面只是一个模块,本质上却是典型的“小型状态系统”。
若希望后续维护不会变得困难,我建议始终遵守下面几点:
isHidden 打补丁CAGradientLayerwindow用一句话概括:
真正好用的状态卡并非由控件堆叠而成,而是具备明确状态边界、交互边界与显示边界的组件。