一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

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

时间:2026-07-29 12:35:49 编辑:袖梨 来源:一聚教程网

前言

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

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

  • 进度展示
  • 剩余提示
  • 完成态切换
  • 二次操作入口
  • 自定义弹窗

若只是临时把几个控件堆在一起,后续很快便会变得难以控制。

这次我的处理思路很明确:
设计时把它视为双状态组件,而不是一张所谓“能变色的卡片”。

这种进度卡为什么不能只依赖 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 = 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 时就会遇到一个问题:

  • 弹窗已经显示
  • 页面也完成 dim 处理
  • 但底部导航仍然露在外面

我最终选择将这类 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. 显示层不能伪造实际状态

用一句话概括:

真正好用的状态卡并非由控件堆叠而成,而是具备明确状态边界、交互边界与显示边界的组件。

热门栏目