Para revelar uma barra de progresso com gradiente em SwiftUI sem comprimir suas cores, mantenha o gradiente tão largo quanto a trilha completa e anime uma máscara alinhada à esquerda. Este exemplo para macOS implementa esse comportamento em um ProgressViewStyle personalizado.

A tarefa é criar um indicador de progresso redimensionável para desktop que represente uma atividade sintética. Com 25% de conclusão, ele deve revelar o primeiro quarto de uma sequência fixa de cores. As cores seguintes devem aparecer conforme o progresso avança, enquanto as cores já visíveis permanecem no lugar.

Por que as cores se comprimem quando a barra de progresso fica mais curta?

A Apple documenta que LinearGradient mapeia seus pontos inicial e final para o retângulo delimitador da forma que preenche. Consequentemente, dar ao gradiente uma largura proporcional ao progresso também altera o espaço no qual suas cores são distribuídas. Um gradiente com um quarto da largura ainda contém toda a sua sequência de cores. LinearGradient — Documentação para Desenvolvedores da Apple

Para obter um efeito de revelação, separe duas dimensões: a largura do gradiente corresponde à largura total da trilha, enquanto a largura visível corresponde à largura da trilha multiplicada pela fração concluída. Esse design deriva do comportamento documentado do gradiente, em vez de ser uma API específica para barras de progresso.

Considere uma trilha ilustrativa de 320 pontos, com paradas azul, roxa e laranja nas posições normalizadas 0, 0,5 e 1. O ponto médio roxo permanece a 160 pontos durante toda a revelação:

Progresso Largura total do gradiente Largura da máscara Resultado visível
0% 320 pontos 0 pontos Somente a trilha
25% 320 pontos 80 pontos Primeiro quarto do gradiente
50% 320 pontos 160 pontos Gradiente até o ponto médio roxo
100% 320 pontos 320 pontos Sequência completa de cores

Isso oferece um diagnóstico útil: a 25%, a parada laranja no final deve continuar oculta. Se ela já aparecer na borda que avança, verifique se a largura dependente do progresso está sendo aplicada ao gradiente.

Crie uma demonstração completa de barra de progresso com gradiente para macOS

Use um destino de app SwiftUI para macOS com destino de implantação no macOS 12 ou posterior. A sobrecarga de mask(alignment:_:) que considera o alinhamento, usada abaixo, está disponível a partir do macOS 12. A Apple a descreve como a aplicação do canal alfa de outra visualização à visualização mascarada. mask(alignment:_:) — Documentação para Desenvolvedores da Apple

Substitua o arquivo gerado de ponto de entrada do app por este programa completo. O arquivo gerado para a visualização de conteúdo pode permanecer sem uso. Mantenha exatamente uma declaração @main no destino, conforme exigido pelo contrato de ponto de entrada de apps da Apple. App — Documentação para Desenvolvedores da 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
        }
    }
}

O estilo personalizado implementa makeBody(configuration:), o ponto de extensão documentado para a aparência de uma visualização de progresso. Ele renderiza os dois rótulos fornecidos e lê a fração de conclusão da configuração. Manter a estilização neste protocolo preserva a interface ProgressView do chamador. ProgressViewStyle — Documentação para Desenvolvedores da Apple

O exemplo começa em 25%. Use os botões para conferir valores específicos de forma repetível, arraste o controle deslizante para inspecionar valores intermediários e redimensione a janela para examinar o layout. Esses controles alteram um estado local sintético; eles não representam trabalho medido. O código-fonte foi inspecionado, e os cálculos de largura foram verificados com Python, mas o programa SwiftUI não foi compilado nem executado no ambiente de pesquisa.

Como o layout mantém o gradiente estável

O GeometryReader mede o espaço alocado ao indicador. A Apple o descreve como um contêiner cujo conteúdo depende de seu próprio tamanho e sistema de coordenadas, com um tamanho preferencial flexível. Aplicar .frame(height: 14) fora dele dá a esse contêiner, que de outra forma seria flexível, uma altura explícita dentro da pilha vertical. GeometryReader — Documentação para Desenvolvedores da Apple

Dentro desse contêiner, o gradiente recebe a largura total medida. Somente o retângulo dentro da máscara recebe a largura multiplicada. Quando o progresso muda, o quadro do gradiente permanece constante para um determinado tamanho de janela.

O alinhamento .leading da máscara é essencial: ele posiciona a máscara menor junto à borda inicial do gradiente. O parâmetro de alinhamento de um quadro posiciona o conteúdo dentro desse quadro; ele não substitui o alinhamento entre a máscara e a visualização mascarada. A Apple documenta essas responsabilidades de alinhamento separadamente. mask(alignment:_:) — Documentação para Desenvolvedores da Apple, frame(width:height:alignment:) — Documentação para Desenvolvedores da Apple

O retângulo opaco revela o gradiente onde quer que esteja presente. Sua tonalidade não determina as cores reveladas, pois a máscara usa o alfa. Substituí-lo por um retângulo transparente ocultaria o gradiente. Mantenha a trilha cinza fora da máscara para que permaneça visível quando o progresso for zero.

A cápsula final recorta a trilha combinada e o gradiente revelado para que tenham a mesma silhueta externa. A Apple documenta o recorte como a preservação das partes cobertas pela forma de recorte. Este exemplo mantém deliberadamente uma borda de avanço reta até ela alcançar a extremidade arredondada da cápsula; uma máscara de revelação em forma de cápsula produziria uma borda de avanço arredondada. clipShape(_:style:) — Documentação para Desenvolvedores da Apple

Redimensionar a janela altera a largura total da trilha, então o gradiente é recalculado para essa nova largura. Aqui, “cores estáveis” significa posições estáveis em relação à trilha durante as atualizações de progresso, e não coordenadas fixas na tela durante o redimensionamento da janela.

Preserve os valores de progresso, os tipos e o comportamento do ciclo de vida

O estilo recebe fractionCompleted como um Double opcional. A Apple define seu intervalo numérico como de 0 a 1 e usa nil para progresso indeterminado ou progresso relativo a um intervalo de datas. Converter essa fração para CGFloat no cálculo da largura torna explícita a fronteira entre os tipos numéricos. fractionCompleted — Documentação para Desenvolvedores da Apple

O exemplo limita defensivamente as frações finitas e direciona uma fração ausente ou não finita para um indicador indeterminado padrão. Esse tratamento de valores não finitos é uma política explícita deste exemplo. Ele evita interpretar uma fração indisponível como conclusão zero. O indicador interno seleciona .circular explicitamente para usar um estilo integrado.

No ponto de chamada, forneça um valor concluído finito e um total finito e positivo. O inicializador baseado em valor aceita nil para progresso indeterminado e documenta que valores fora do intervalo são limitados aos seus extremos. O exemplo usa um total de 1 e restringe o controle deslizante ao mesmo intervalo. init(value:total:label:currentValueLabel:) — Documentação para Desenvolvedores da Apple

A visualização pai mantém o estado do progresso, enquanto o estilo deriva sua renderização da configuração. O SwiftUI atualiza as visualizações dependentes quando o estado muda. Portanto, este exemplo não precisa de temporizador, callback de aparência ou tarefa em segundo plano para manter seu estado visual. State — Documentação para Desenvolvedores da Apple

A animação pertence ao retângulo de revelação. O modificador com valor monitorado aplica uma animação quando a fração monitorada muda; a duração escolhida, de um quarto de segundo, é uma opção ilustrativa de apresentação. Ela não calcula o progresso da tarefa. Remova o modificador se atualizações imediatas forem mais adequadas à sua interface. animation(_:value:) — Documentação para Desenvolvedores da Apple

Há um limite documentado para o progresso controlado por temporizador: a Apple afirma que visualizações de progresso relativas a datas não oferecem suporte a estilos personalizados. Para essa aparência com gradiente, use o inicializador de progresso numérico e faça com que a atividade forneça sua fração concluída. Se precisar das atualizações automáticas relativas a datas da Apple, use esse inicializador com a apresentação compatível. init(timerInterval:countsDown:label:currentValueLabel:) — Documentação para Desenvolvedores da Apple

Confira a revelação com uma sequência breve de diagnóstico

Use estas verificações depois de criar o destino para macOS. Elas ajudam a distinguir problemas de mapeamento de cores de problemas de alinhamento e dimensionamento:

  1. Selecione Reset. A trilha cinza deve permanecer, sem gradiente visível.
  2. Selecione 25%. Somente as cores iniciais do gradiente devem aparecer. Uma extremidade laranja visível sugere que o próprio gradiente está sendo encurtado.
  3. Selecione 50%. Com as paradas fornecidas, o limite da revelação deve alcançar o ponto médio roxo.
  4. Selecione Complete. O gradiente deve preencher a trilha até o laranja.
  5. Redimensione a janela com 50% de progresso. A parte visível deve continuar correspondendo à metade da trilha recém-redimensionada.
  6. Ative Indeterminate. O preenchimento numérico deve dar lugar ao indicador integrado, enquanto os rótulos da tarefa e do valor atual permanecem visíveis.

Se a revelação se expandir a partir do centro, verifique .mask(alignment: .leading). Se o indicador ocupar espaço vertical excessivo, verifique o quadro fora do GeometryReader. Se mudar o progresso mover todas as paradas de cor, verifique se somente a máscara usa o multiplicador de conclusão.

A regra reutilizável é simples: dimensione o gradiente para a trilha completa e, em seguida, use uma máscara dimensionada separadamente para revelar o progresso. Manter essas responsabilidades separadas facilita compreender a conclusão parcial, o redimensionamento e a animação, preservando a configuração documentada da visualização de progresso.

Fontes