色を圧縮せずに SwiftUI のグラデーションプログレスバーを表示するには、グラデーションの幅をトラック全体と同じに保ち、先頭に揃えたマスクをアニメーションさせます。この macOS の例では、カスタム ProgressViewStyle でその動作を実装します。

ここでは、合成アクティビティ用のサイズ変更可能なデスクトッププログレスインジケーターを作成します。完了率が 25% のとき、固定された色の並びの最初の 4 分の 1 が表示されます。進捗に応じて後続の色が現れ、すでに表示された色は位置を保ちます。

プログレスバーが短くなると、なぜ色が圧縮されるのでしょうか?

Apple のドキュメントによると、LinearGradient は開始点と終了点を、塗りつぶす図形の境界矩形に対応付けます。そのため、進捗に比例した幅をグラデーションに与えると、色が分布する範囲も変わります。幅が 4 分の 1 のグラデーションにも、色の並び全体が含まれます。LinearGradient — Apple Developer Documentation

表示する範囲を広げていく効果を作るには、2 つの寸法を分けます。グラデーションの幅はトラック全体の幅にし、表示幅はトラック幅に完了率を掛けた値にします。これは、ドキュメントに記載されたグラデーションの動作から導いた設計であり、プログレスバー専用の別 API ではありません。

例として、正規化位置 0、0.5、1 に青、紫、オレンジの色停止点を置いた、幅 320 ポイントのトラックを考えます。紫の中間点は、表示中ずっと 160 ポイントの位置にとどまります。

進捗 グラデーション全体の幅 マスク幅 表示結果
0% 320 ポイント 0 ポイント トラックのみ
25% 320 ポイント 80 ポイント グラデーションの最初の 4 分の 1
50% 320 ポイント 160 ポイント 紫の中間点までのグラデーション
100% 320 ポイント 320 ポイント 色の並び全体

これを目安に表示を確認できます。25% の時点では、末尾のオレンジの色停止点は隠れているはずです。進捗の先端にすでに現れている場合は、進捗に応じた幅がグラデーションに適用されていないか確認してください。

macOS 向けのグラデーションプログレスのデモを作成する

macOS 12 以降をデプロイメントターゲットにした macOS SwiftUI アプリのターゲットを使います。以下で使用するアラインメント対応の mask(alignment:_:) オーバーロードは macOS 12 以降で利用できます。Apple は、マスク対象のビューに別のビューのアルファチャンネルを適用するものとして説明しています。mask(alignment:_:) — Apple Developer Documentation

生成されたアプリのエントリーポイントファイルを、次の完全なプログラムに置き換えます。生成されたコンテンツビューファイルは使わないままで構いません。Apple のアプリエントリーポイントの仕様に従い、ターゲット内の @main 宣言は 1 つだけにしてください。App — Apple Developer Documentation

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 Developer Documentation

サンプルは 25% から始まります。ボタンを使って同じチェックポイントを繰り返し確認し、スライダーをドラッグして途中の値を調べ、ウィンドウをサイズ変更してレイアウトを確認してください。これらのコントロールは合成されたローカル状態を変更するもので、実際の作業量を示すものではありません。ソースを確認し、幅の計算を Python で検証しましたが、調査環境では SwiftUI プログラムのコンパイルや実行は行っていません。

レイアウトでグラデーションの位置を保つ方法

GeometryReader はインジケーターに割り当てられた領域を測定します。Apple は、自身のサイズと座標空間に応じて内容が決まり、柔軟な希望サイズを持つコンテナーとして説明しています。その外側に .frame(height: 14) を適用すると、柔軟なこのコンテナーに垂直スタック内で明示的な高さを与えられます。GeometryReader — Apple Developer Documentation

このコンテナー内では、グラデーションに測定した幅全体を与えます。掛け算した幅を与えるのは、マスク内の矩形だけです。進捗が変わっても、ウィンドウサイズが同じならグラデーションのフレームは一定です。

マスクの .leading アラインメントは欠かせません。小さなマスクをグラデーションの先頭に揃えるためです。フレーム自身のアラインメント引数は、そのフレーム内のコンテンツを配置します。マスクとマスク対象ビューの間のアラインメントを指定するものではありません。Apple のドキュメントでは、この 2 つは別のアラインメントの役割として説明されています。mask(alignment:_:) — Apple Developer Documentation、frame(width:height:alignment:) — Apple Developer Documentation

不透明な矩形が存在する部分では、グラデーションが表示されます。マスクではアルファ値を使うため、矩形の色相は表示される色を決めません。透明な矩形に置き換えると、グラデーションは隠れます。進捗が 0 のときにも表示されるよう、灰色のトラックはマスクの外側に置いてください。

最後に、カプセル形状でトラックと表示されたグラデーション全体をクリップし、同じ外形に収めます。Apple のドキュメントによると、クリッピングではクリッピング形状に覆われた部分が残ります。この例では、進捗の先端がカプセルの丸い端に達するまでは直線のままです。カプセル形状の表示マスクを使うと、進捗の先端も丸くなります。clipShape(_:style:) — Apple Developer Documentation

ウィンドウのサイズを変更するとトラック全体の幅が変わるため、その新しい幅に合わせてグラデーションが再計算されます。ここでいう「色の位置が安定する」とは、進捗の更新中にトラックに対する相対位置が保たれるという意味であり、ウィンドウのサイズを変更しても画面上の座標が固定されるという意味ではありません。

進捗値、型、ライフサイクルの動作を保つ

スタイルは、fractionCompleted をオプショナルな Double として受け取ります。Apple はその数値範囲を 0 から 1 と定義し、不確定な進捗や日付間隔に対する進捗には nil を使います。幅の計算時にこの割合を CGFloat に変換すると、数値型の境界が明確になります。fractionCompleted — Apple Developer Documentation

この例では有限の割合を防御的に 0 から 1 の範囲に収め、割合がない場合や有限値でない場合は標準の不確定インジケーターに切り替えます。有限値でない場合の扱いは、このサンプルで明示的に定めた方針です。取得できない割合を完了率 0 として扱うことを避けます。内側のインジケーターでは .circular を明示し、組み込みのスタイルを使います。

呼び出し側では、有限の完了量と、有限かつ正の合計値を指定します。値を使うイニシャライザーは、不確定な進捗を nil で指定でき、範囲外の値は上下限に固定されると説明されています。このサンプルでは合計値に 1 を使い、スライダーも同じ範囲に制限しています。init(value:total:label:currentValueLabel:) — Apple Developer Documentation

親ビューが進捗状態を所有し、スタイルは構成から表示を導きます。状態が変わると、SwiftUI は依存するビューを更新します。そのため、この例では表示状態を維持するためのタイマー、表示時コールバック、バックグラウンドタスクは必要ありません。State — Apple Developer Documentation

アニメーションは表示用の矩形に適用します。値スコープのモディファイアは、監視対象の割合が変わったときにアニメーションを適用します。ここで選んだ 0.25 秒という長さは、表示上の例です。タスクの進捗を計算するものではありません。インターフェイスに合わせて即時更新したい場合は、このモディファイアを削除してください。animation(_:value:) — Apple Developer Documentation

タイマー駆動の進捗には、ドキュメントに記載された制約があります。Apple によると、日付相対のプログレスビューはカスタムスタイルに対応していません。このグラデーション表示には数値進捗のイニシャライザーを使い、アクティビティを所有する側から完了率を渡してください。Apple による日付相対の自動更新が必要な場合は、対応する表示方法とともにそのイニシャライザーを使います。init(timerInterval:countsDown:label:currentValueLabel:) — Apple Developer Documentation

短い診断手順で表示範囲を確認する

macOS ターゲットをビルドした後、次の項目を確認してください。色の対応付けの問題と、アラインメントやサイズの問題を切り分けられます。

  1. Reset を選択します。グレーのトラックは残り、グラデーションは表示されないはずです。
  2. 25% を選択します。グラデーションの初期の色だけが表示されるはずです。オレンジの端が見える場合は、グラデーション自体の幅が短くなっている可能性があります。
  3. 50% を選択します。指定された色停止点では、表示境界が紫の中間点まで達するはずです。
  4. Complete を選択します。グラデーションがオレンジまでトラック全体を埋めるはずです。
  5. 50% の状態でウィンドウのサイズを変更します。表示部分は、新しい幅のトラックの半分に保たれるはずです。
  6. Indeterminate を有効にします。数値の塗りつぶしが組み込みインジケーターに切り替わり、タスクと現在値のラベルは表示されたままになります。

表示範囲が中央から外側へ広がる場合は、.mask(alignment: .leading) を確認してください。インジケーターが縦方向に大きくなりすぎる場合は、GeometryReader の外側にあるフレームを確認してください。進捗を変えるたびにすべての色停止点が動く場合は、完了率を掛ける幅がマスクだけに適用されているか確認してください。

再利用できるルールはシンプルです。グラデーションはトラック全体に合わせてサイズを決め、別にサイズを指定したマスクで進捗を表示します。この 2 つの役割を分けると、プログレスビューのドキュメント化された構成を保ちながら、部分的な完了、サイズ変更、アニメーションを扱いやすくなります。

参考資料