GitHub周趋势2026W25 | Headroom 压缩 95% Token、NVIDIA 开源 AI Agent 安全扫描器、…
2026-07-28
2026-07-29 0
许多健康类、训练类及打卡类产品,都会在首页设置一种“连续 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用一句话概括:
一张好用的状态卡并非由控件堆叠而成,而是具备清晰状态边界、交互边界和显示边界的组件。