首页
看点啥
插画图片
首页 看点啥 iOS 首页进度卡实践:难点不是渐变进度条,而是状态边界

iOS 首页进度卡实践:难点不是渐变进度条,而是状态边界

2026-07-29 0

前言

许多健康类、训练类及打卡类产品,都会在首页设置一种“连续 N 天完成”的状态卡。
这种卡片看似只是单个模块,实际通常会同时包含:

iOS 首页进度卡实战:最难的不是渐变进度条,而是状态边界

若只是临时堆叠几个控件,后续很快便会失控。

我这次采用的做法很明确:
设计时把它视为一个双状态组件,而非一张“能变色的卡片”。

为什么不能只依靠 isHidden 打补丁

不少人在制作进度卡时,会沿用下面这种思路:

这种方式短期内似乎省事,长期使用通常会产生两个问题:

第一,状态变得越来越难读。
第二,交互事件容易混杂在一起。

因此,我更建议明确建立一个状态模型:

enum ProgressCardStyle {case tracking(remainingDays: Int, completedDays: Int)case completed}

这样组件处于 configure 时,就无需依靠“猜测”判断应该显示哪些内容。

组件层仅处理状态和事件,不直接负责业务

我这次最终为进度卡提供了几个明确事件:

换句话说,组件仅负责向外传递用户行为。点击后弹出什么、是否执行重置以及是否进入下一步,都交给页面控制器判断。

整体结构大致如下:

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 状态

也就是说,两种状态共享同一个组件入口,但内部布局和交互重点并不相同。

为什么进度条更适合使用 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

总结

这类首页状态卡表面只是一个模块,本质上却属于典型的“小型状态系统”。

想让它在后续保持易于维护,建议坚持以下几点:

  1. 明确建立状态,不依赖大量 isHidden 打补丁
  2. 组件仅向外提供事件,不直接负责业务判断
  3. 渐变进度条优先采用 CAGradientLayer
  4. 全屏 overlay 优先挂 window
  5. 显示层不可伪造实际状态

用一句话概括:

一张好用的状态卡并非由控件堆叠而成,而是具备清晰状态边界、交互边界和显示边界的组件。

喜欢(0)

上一篇

多数人搭 RAG,起步阶段就错了

多数人搭 RAG,起步阶段就错了

下一篇

从零构建电子书RAG问答系统:Milvus + LangChain完整实战

从零构建电子书RAG问答系统:Milvus + LangChain完整实战
猜你喜欢