색상을 압축하지 않고 SwiftUI 그라디언트 진행률 막대를 표시하려면 그라디언트를 전체 트랙 너비로 유지하고, 앞쪽에 정렬된 마스크에 애니메이션을 적용하세요. 이 macOS 예제는 사용자 지정 ProgressViewStyle에서 이 동작을 구현합니다.

목표는 가상의 작업을 위한 크기 조정 가능한 데스크톱 진행 표시기를 만드는 것입니다. 25% 완료 시 고정된 색상 시퀀스의 첫 4분의 1을 표시해야 합니다. 진행률이 높아지면 뒤쪽 색상이 나타나고 기존 색상은 제자리에 유지됩니다.

진행률 막대가 짧아지면 색상이 압축되는 이유는 무엇인가요?

Apple 문서에 따르면 LinearGradient는 시작점과 끝점을 자신이 채우는 도형의 경계 사각형에 매핑합니다. 따라서 그라디언트 너비를 진행률에 비례하게 설정하면 색상이 분포되는 공간도 달라집니다. 너비가 4분의 1인 그라디언트에도 전체 색상 시퀀스가 들어 있습니다. LinearGradient — Apple 개발자 문서

표시 효과를 만들 때는 두 가지 크기를 분리하세요. 그라디언트 너비는 전체 트랙 너비이고, 표시 너비는 트랙 너비에 완료 비율을 곱한 값입니다. 이는 문서화된 그라디언트 동작에서 도출한 설계이며, 별도의 진행률 막대 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 개발자 문서

생성된 앱 진입점 파일을 다음 전체 프로그램으로 바꾸세요. 생성된 콘텐츠 뷰 파일은 사용하지 않은 채로 두어도 됩니다. 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 개발자 문서

불투명한 사각형이 존재하는 곳마다 그라디언트가 드러납니다. 마스킹은 알파를 사용하므로 사각형의 색조는 드러나는 색상에 영향을 주지 않습니다. 투명한 사각형으로 바꾸면 그라디언트가 가려집니다. 진행률이 0일 때도 보이도록 회색 트랙은 마스크 바깥에 두세요.

마지막 캡슐은 트랙과 드러난 그라디언트를 같은 바깥 윤곽으로 잘라 냅니다. Apple 문서에 따르면 클리핑은 클리핑 도형이 덮는 부분을 유지합니다. 이 예제는 진행 가장자리가 캡슐의 둥근 끝에 도달할 때까지 직선으로 유지합니다. 캡슐 모양의 표시 마스크를 사용하면 진행 가장자리도 둥글게 됩니다. clipShape(_:style:) — Apple 개발자 문서

창 크기를 조절하면 전체 트랙 너비가 바뀌므로 새 너비에 맞춰 그라디언트가 다시 계산됩니다. 여기서 “안정적인 색상”은 창 크기 조절 중 화면 좌표에 고정되는 것이 아니라, 진행률이 바뀌는 동안 트랙에 대한 상대 위치가 유지됨을 뜻합니다.

진행률 값, 타입 및 생명 주기 동작 유지하기

스타일은 fractionCompleted을 선택적 Double로 받습니다. Apple은 이 값의 숫자 범위가 0부터 1까지라고 정의하며, 진행률을 알 수 없거나 날짜 구간 기준 진행률을 나타낼 때는 nil을 사용합니다. 너비 계산에서 이 비율을 CGFloat로 변환하면 숫자 타입의 경계가 명확해집니다. fractionCompleted — Apple 개발자 문서

이 예제는 유한한 비율을 방어적으로 범위 제한하고, 누락되었거나 유한하지 않은 비율은 표준 불확정 진행 표시기로 보냅니다. 유한하지 않은 값에 대한 처리는 이 예제에서 명시적으로 정한 정책입니다. 사용할 수 없는 비율을 완료율 0으로 해석하지 않도록 합니다. 내부 표시기는 기본 제공 스타일을 사용하도록 .circular를 명시적으로 선택합니다.

호출부에는 유한한 완료량과 유한하고 양수인 전체량을 전달하세요. 값 기반 이니셜라이저는 불확정 진행률에 nil을 허용하며 범위를 벗어난 값을 상한과 하한에 맞춰 고정한다고 문서에 설명되어 있습니다. 이 예제는 전체량으로 1을 사용하고 슬라이더도 같은 구간으로 제한합니다. init(value:total:label:currentValueLabel:) — Apple 개발자 문서

상위 뷰가 진행률 상태를 소유하고, 스타일은 구성에서 표시 내용을 도출합니다. 상태가 바뀌면 SwiftUI가 이에 의존하는 뷰를 업데이트합니다. 따라서 이 예제는 시각적 상태를 유지하기 위해 타이머, 표시 콜백 또는 백그라운드 작업이 필요하지 않습니다. State — Apple 개발자 문서

애니메이션은 표시 사각형에 적용합니다. 값 범위 지정 수정자는 감시 중인 비율이 바뀔 때 애니메이션을 적용합니다. 여기서 선택한 0.25초 지속 시간은 예시용 표현 선택이며 작업 진행률을 계산하지 않습니다. 인터페이스에서 즉시 업데이트하는 편이 적절하다면 이 수정자를 제거하세요. 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 바깥의 프레임을 확인하세요. 진행률을 바꿀 때 모든 색상 중지점이 움직인다면 완료 비율을 곱하는 곳이 마스크뿐인지 확인하세요.

재사용할 규칙은 간단합니다. 전체 트랙에 맞춰 그라디언트 크기를 정한 다음 별도로 크기를 정한 마스크로 진행률을 드러내세요. 이 두 역할을 분리하면 진행률 뷰의 문서화된 구성을 유지하면서 부분 완료, 크기 조절, 애니메이션을 더 쉽게 파악할 수 있습니다.

출처