iOS首页进度卡实战深度对比:渐变进度条与状态边界全解析

2026-06-20阅读 0热度 0
ios

核心思路:从“变色卡片”升级为双状态组件

在健康、训练、打卡类应用中,首页经常出现一种“连续N天完成”的状态卡。这个模块看似简单,实则承载了进度展示、剩余天数提示、完成态切换、二次操作入口以及自定义弹窗等多重职责。如果仅靠临时堆叠控件,后期维护成本会急剧上升。

更优的解法是把它设计成一个显式双状态组件,而不是一张靠“变色”应付的卡片。

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

为什么单纯依赖 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 = 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。

关键原则总结

这类首页状态卡本质上是一个小型状态系统。若想使其易于长期维护,需坚持以下五点:

  1. 显式定义状态枚举,而非用大量 isHidden 打补丁
  2. 组件仅暴露事件回调,不承担业务判断职责
  3. 渐变进度条优先采用 CAGradientLayer
  4. 全屏 overlay 统一挂到 window
  5. 显示层绝不伪造真实进度数据

一句话总结:好用的状态卡不是靠控件堆砌,而是靠清晰的状态边界、交互边界和显示边界来构建的组件。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策