若要呈现 SwiftUI 渐变进度条,同时避免颜色压缩,请让渐变的宽度与完整轨道相同,并为一个左对齐的遮罩添加动画。这个 macOS 示例通过自定义 ProgressViewStyle 实现了这种行为。

任务是为一项模拟活动构建一个可调整大小的桌面进度指示器。完成度为 25% 时,应显示固定颜色序列的前四分之一。随着进度推进,后续颜色逐渐出现,同时已显示的颜色保持原位。

为什么进度条变短时颜色会压缩?

Apple 文档说明,LinearGradient 会将起点和终点映射到其填充形状的边界矩形中。因此,如果让渐变的宽度与进度成比例,颜色分布的空间也会随之改变。四分之一宽度的渐变仍包含完整的颜色序列。LinearGradient — Apple 开发者文档

要实现揭示效果,需要将两个维度分开:渐变的宽度等于完整轨道的宽度,而可见宽度等于轨道宽度乘以已完成比例。这种设计是根据已文档说明的渐变行为推导出来的,并非单独的进度条 API。

例如,假设有一条 320 点宽的轨道,在标准化位置 0、0.5 和 1 处分别设置蓝色、紫色和橙色。揭示过程中,紫色中点始终位于 160 点处:

进度 完整渐变宽度 遮罩宽度 可见效果
0% 320 点 0 点 仅显示轨道
25% 320 点 80 点 渐变的前四分之一
50% 320 点 160 点 渐变显示至紫色中点
100% 320 点 320 点 完整颜色序列

这也提供了一个实用的诊断方法:进度为 25% 时,末端的橙色应保持隐藏。如果橙色已经出现在推进边缘,请检查是否将依赖进度的宽度应用到了渐变上。

构建完整的 macOS 渐变进度演示

使用 macOS SwiftUI 应用目标,并将部署目标设为 macOS 12 或更高版本。下面使用的支持对齐方式的 mask(alignment:_:) 重载从 macOS 12 起可用。Apple 将其描述为把另一个视图的 alpha 通道应用到被遮罩视图上。mask(alignment:_:) — Apple 开发者文档

用下面这段完整程序替换生成的应用入口文件。生成的内容视图文件可以保留但不使用。根据 Apple 对应用入口的约定,目标中应恰好包含一个 @main 声明。App — Apple 开发者文档

import SwiftUI

@main
struct GradientRevealDemoApp: App {
    var body: some Scene {
        WindowGroup {
            GradientRevealDemo()
        }
    }
}

struct GradientRevealDemo: View {
    @State private var progress = 0.25
    @State private var isIndeterminate = false

    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            ProgressView(
                value: isIndeterminate ? nil : progress,
                total: 1.0
            ) {
                Text("Sample activity")
            } currentValueLabel: {
                if isIndeterminate {
                    Text("In progress")
                } else {
                    Text("\(Int((progress * 100).rounded()))%")
                }
            }
            .progressViewStyle(GradientRevealStyle())

            Slider(value: $progress, in: 0...1, step: 0.01) {
                Text("Sample completion")
            }
            .disabled(isIndeterminate)

            HStack {
                Button("Reset") { progress = 0 }
                Button("25%") { progress = 0.25 }
                Button("50%") { progress = 0.5 }
                Button("Complete") { progress = 1 }
            }
            .disabled(isIndeterminate)

            Toggle("Indeterminate", isOn: $isIndeterminate)
        }
        .padding(24)
        .frame(minWidth: 360, idealWidth: 520, minHeight: 240)
    }
}

@MainActor
struct GradientRevealStyle: ProgressViewStyle {
    func makeBody(configuration: Configuration) -> some View {
        let fraction = configuration.fractionCompleted.flatMap {
            $0.isFinite ? min(max($0, 0), 1) : nil
        }

        VStack(alignment: .leading, spacing: 8) {
            configuration.label

            if let fraction = fraction {
                GeometryReader { geometry in
                    ZStack(alignment: .leading) {
                        Rectangle()
                            .fill(Color.gray.opacity(0.2))

                        LinearGradient(
                            stops: [
                                .init(color: .blue, location: 0),
                                .init(color: .purple, location: 0.5),
                                .init(color: .orange, location: 1)
                            ],
                            startPoint: .leading,
                            endPoint: .trailing
                        )
                        .frame(width: geometry.size.width, height: 14)
                        .mask(alignment: .leading) {
                            Rectangle()
                                .frame(
                                    width: geometry.size.width
                                        * CGFloat(fraction),
                                    height: 14
                                )
                                .animation(
                                    .easeInOut(duration: 0.25),
                                    value: fraction
                                )
                        }
                    }
                    .frame(width: geometry.size.width, height: 14)
                    .clipShape(Capsule())
                }
                .frame(height: 14)
            } else {
                ProgressView()
                    .progressViewStyle(.circular)
            }

            configuration.currentValueLabel
        }
    }
}

这个自定义样式实现了 makeBody(configuration:),即文档所述、用于自定义进度视图外观的扩展点。它会呈现提供的两个标签,并从配置中读取完成比例。将样式逻辑放在这个协议中,可以保留调用方的 ProgressView 接口。ProgressViewStyle — Apple 开发者文档

示例从 25% 开始。使用按钮可重复检查指定进度,拖动滑块可查看中间值,调整窗口大小则可检查布局。这些控件会修改模拟的本地状态,并不代表实际测量的工作量。我们检查了源代码,并用 Python 核对了宽度计算,但研究环境没有编译或运行这段 SwiftUI 程序。

布局如何保持渐变稳定

GeometryReader 会测量分配给指示器的空间。Apple 将它描述为一个容器,其内容取决于自身大小和坐标空间,并且具有灵活的首选尺寸。将 .frame(height: 14) 应用于它的外部,可以在垂直堆栈中为这个原本尺寸灵活的容器指定明确高度。GeometryReader — Apple 开发者文档

在该容器内部,渐变使用完整的测量宽度。只有遮罩中的矩形使用乘以完成比例后的宽度。进度变化时,只要窗口尺寸不变,渐变的框架就保持不变。

遮罩的 .leading 对齐至关重要:它会让较小的遮罩贴合渐变的起始边缘。框架自身的对齐参数用于定位框架内部的内容,并不能取代遮罩与被遮罩视图之间的对齐。Apple 将这两者列为不同的对齐职责。mask(alignment:_:) — Apple 开发者文档,frame(width:height:alignment:) — Apple 开发者文档

不透明矩形会在其覆盖的区域显示渐变。矩形的色调不会决定显示哪些颜色,因为遮罩使用的是 alpha 通道。将它替换为透明矩形会隐藏渐变。将灰色轨道放在遮罩外部,这样进度为零时它仍会显示。

最后的胶囊形状会将轨道和已显示的渐变裁剪为相同的外轮廓。Apple 文档说明,裁剪会保留被裁剪形状覆盖的部分。本示例会让推进边缘保持平直,直到它到达胶囊的圆角末端;若使用胶囊形状作为揭示遮罩,推进边缘也会呈圆角。clipShape(_:style:) — Apple 开发者文档

调整窗口大小会改变完整轨道的宽度,因此渐变会根据新的宽度重新计算。这里所说的“颜色稳定”,是指在进度更新期间颜色相对于轨道的位置保持不变,而非窗口大小改变时颜色在屏幕上的坐标固定不变。

保留进度值、类型和生命周期行为

该样式会将 fractionCompleted 作为可选的 Double 接收。Apple 将其数值范围定义为 0 到 1;对于不确定进度或相对于日期区间的进度,则使用 nil。在宽度计算处将该比例转换为 CGFloat,可以明确数值类型的边界。fractionCompleted — Apple 开发者文档

示例会防御性地将有限的比例限制在有效范围内,并将缺失或非有限的比例转交给标准的不确定进度指示器。对非有限值的处理是此示例明确采用的策略,可避免把无法获取的比例误解为零完成度。内部指示器明确选择 .circular,以使用内置样式。

在调用处,请提供有限的已完成数量和有限的正数总量。基于数值的初始化方法接受 nil 表示不确定进度,并说明会将超出范围的值限制在边界内。示例将总量设为 1,并将滑块限制在相同区间内。init(value:total:label:currentValueLabel:) — Apple 开发者文档

父视图拥有进度状态,而样式根据配置生成显示内容。状态改变时,SwiftUI 会更新依赖该状态的视图。因此,此示例无需定时器、外观回调或后台任务来维持视觉状态。State — Apple 开发者文档

动画应用于揭示矩形。按值触发的修饰符会在受监视的比例发生变化时应用动画;四分之一秒的时长只是示意性的呈现选择,不负责计算任务进度。如果即时更新更适合你的界面,可以移除此修饰符。animation(_:value:) — Apple 开发者文档

定时器驱动的进度有一项文档说明的限制:Apple 指出,基于日期的进度视图不支持自定义样式。若要使用这种渐变外观,请使用数值进度初始化方法,并由活动的所有者提供已完成比例。如果需要 Apple 自动更新基于日期的进度,请使用该初始化方法及其支持的呈现方式。init(timerInterval:countsDown:label:currentValueLabel:) — Apple 开发者文档

用简短的诊断步骤检查揭示效果

构建 macOS 目标后,按以下步骤检查。这些步骤可以帮助区分颜色映射问题与对齐、尺寸问题:

  1. 选择 Reset。灰色轨道应保持可见,渐变则完全隐藏。
  2. 选择 25%。此时只应显示渐变中靠前的颜色。如果能看到橙色端点,可能是渐变本身被缩短了。
  3. 选择 50%。根据提供的色标,揭示边界应到达紫色中点。
  4. 选择 Complete。渐变应填满轨道,直至橙色端点。
  5. 在进度为 50% 时调整窗口大小。可见部分应始终占新轨道的一半。
  6. 启用 Indeterminate。数字填充应切换为内置指示器,同时保留任务标签和当前值标签。

如果揭示内容从中心向外扩展,请检查 .mask(alignment: .leading)。如果指示器占据了过多的垂直空间,请检查 GeometryReader 外部的框架。如果改变进度时所有颜色色标都在移动,请检查是否只有遮罩使用了完成比例乘数。

可复用的规则很简单:让渐变覆盖完整轨道,再用单独调整尺寸的遮罩揭示进度。将这两项职责分开后,部分完成、窗口尺寸变化和动画都更容易理解,同时也保留了进度视图文档规定的配置。

来源