最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
iOS 首页进度卡实践:难点不在渐变进度条,而在状态边界
时间:2026-07-29 12:35:49 编辑:袖梨 来源:一聚教程网
前言
许多健康类、训练类和打卡类产品,都会在首页设置一种展示“连续 N 天完成”的状态卡。
看似只是一个卡片模块,实际通常会同时承载这些内容:

- 进度展示
- 剩余提示
- 完成态切换
- 二次操作入口
- 自定义弹窗
若只是临时把几个控件堆在一起,后续很快便会变得难以控制。
这次我的处理思路很明确:
设计时把它视为双状态组件,而不是一张所谓“能变色的卡片”。
这种进度卡为什么不能只依赖 isHidden 打补丁
不少人在制作进度卡时,会沿用下面这种思路:
- 先把所有控件放上去
- 在 tracking 状态下隐藏一部分
- 切到 completed 状态后显示另一部分
这种方案短期内似乎省事,长期使用却通常会暴露两个问题:
第一,状态会越来越难理解。
第二,各种交互事件会彼此混杂。
因此,我更建议明确建立一个状态模型:
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?() }}
这种做法的优点是:
后续你不管怎么改页面流程,卡片本身都不需要掺杂业务判断。
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。
总结
这类首页状态卡表面只是一个模块,本质上却是典型的“小型状态系统”。
若希望后续维护不会变得困难,我建议始终遵守下面几点:
- 明确建立状态,不依赖大量
isHidden打补丁 - 组件仅向外暴露事件,不直接负责业务判断
- 渐变进度条应优先采用
CAGradientLayer - 全屏 overlay 优先挂
window - 显示层不能伪造实际状态
用一句话概括:
真正好用的状态卡并非由控件堆叠而成,而是具备明确状态边界、交互边界与显示边界的组件。
相关文章
- 第七史诗雷奎安洛兰技能属性/觉醒材料一览 07-29
- ps怎么制作一款复古风格的立体艺术字体 07-29
- ps怎么制作风景剪纸文字 07-29
- ps怎么为文字添加背景图片 07-29
- 逆战未来赛季功勋怎么获取一览 07-29
- ps怎么制作一款立体的英文字母 07-29