iOS首页进度卡实战深度对比:渐变进度条与状态边界全解析
核心思路:从“变色卡片”升级为双状态组件
在健康、训练、打卡类应用中,首页经常出现一种“连续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 上会出现问题——弹窗覆盖后底部导航仍外露。推荐直接挂接到当前 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层 - 显示层绝不伪造真实进度数据
一句话总结:好用的状态卡不是靠控件堆砌,而是靠清晰的状态边界、交互边界和显示边界来构建的组件。
