Чтобы показать панель прогресса с градиентом SwiftUI без сжатия её цветов, сохраните градиент такой же ширины, как полная дорожка, и анимируйте выравненную по левому краю маску. Этот пример для macOS реализует такое поведение в пользовательском ProgressViewStyle.
Задача — создать изменяемый по размеру индикатор прогресса для рабочего стола для синтетической активности. При завершённости 25% он должен показывать первую четверть фиксированной цветовой последовательности. Последующие цвета должны появляться по мере продвижения прогресса, а существующие цвета остаются на месте.
Почему цвета сжимаются, когда панель прогресса становится короче?
Apple документирует, что LinearGradient отображает свои начальную и конечную точки в ограничивающий прямоугольник фигуры, которую он заполняет. Следовательно, задание градиенту ширины, пропорциональной прогрессу, также меняет пространство, по которому распределяются его цвета. Градиент ширины в четверть всё ещё содержит всю свою цветовую последовательность. LinearGradient — Apple Developer Documentation
Для эффекта появления разделите два измерения: ширина градиента равна полной ширине дорожки, а видимая ширина — ширине дорожки, умноженной на долю завершённости. Это дизайн, вытекающий из документированного поведения градиента, а не отдельный 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 описывает её как применение альфа-канала другого представления к маскируемому представлению. mask(alignment:_:) — Apple Developer Documentation
Замените сгенерированный файл точки входа приложения этой полной программой. Сгенерированный файл представления содержимого может остаться неиспользуемым. Сохраняйте ровно одно объявление @main в цели, как требует контракт точки входа приложения Apple. 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 документирует их как отдельные обязанности по выравниванию. mask(alignment:_:) — Apple Developer Documentation, frame(width:height:alignment:) — Apple Developer Documentation
Непрозрачный прямоугольник показывает градиент везде, где существует прямоугольник. Его оттенок не определяет показанные цвета, потому что маскирование использует альфа. Замена его на прозрачный прямоугольник скроет градиент. Сохраните серую дорожку снаружи маски, чтобы она оставалась видимой при нулевом прогрессе.
Финальная капсула обрезает объединённую дорожку и показанный градиент до одной и той же внешней силуэтной формы. Apple документирует обрезку как сохранение частей, покрытых фигурой обрезки. Этот пример намеренно сохраняет прямую продвигающуюся границу, пока она не достигнет закругленного конца капсулы; маска появления в форме капсулы дала бы закруглённую продвигающуюся границу. clipShape(_:style:) — Apple Developer Documentation
Изменение размера окна меняет полную ширину дорожки, поэтому градиент пересчитывается через эту новую ширину. «Стабильные цвета» здесь означают стабильные позиции относительно дорожки во время обновлений прогресса, а не фиксированные экранные координаты при изменении размера окна.
Сохранять значения прогресса, типы и поведение жизненного цикла
Стиль получает fractionCompleted как необязательный Double. Apple определяет его числовой диапазон как от 0 до 1 и использует nil для неопределённого прогресса или прогресса относительно временного интервала. Преобразование этой доли в CGFloat при расчёте ширины делает числовую границу явной. fractionCompleted — Apple Developer Documentation
Пример защищённо ограничивает конечные доли и направляет отсутствующую или неконечную долю к стандартному неопределённому индикатору. Такая обработка неконечных значений — явная политика этого примера. Она избегает интерпретации недоступной доли как нулевого завершения. Внутренний индикатор явно выбирает .circular, чтобы он использовал встроенный стиль.
В месте вызова предоставьте конечное завершённое количество и конечный положительный итог. Инициализатор на основе значения принимает nil для неопределённого прогресса и документирует закрепление внедиапазонных значений в своих границах. Пример использует итог 1 и ограничивает свой ползунок тем же интервалом. init(value:total:label:currentValueLabel:) — Apple Developer Documentation
Родительское представление владеет состоянием прогресса, а стиль выводит свой рендеринг из конфигурации. SwiftUI обновляет зависимые представления при изменении состояния. Поэтому этому примеру не нужен таймер, обратный вызов внешнего вида или фоновая задача для поддержания визуального состояния. State — Apple Developer Documentation
Анимация принадлежит прямоугольнику появления. Модификатор с областью значения применяет анимацию при изменении отслеживаемой доли; выбранная длительность в четверть секунды — иллюстративный выбор представления. Она не вычисляет прогресс задачи. Удалите модификатор, если немедленные обновления лучше подходят вашему интерфейсу. animation(_:value:) — Apple Developer Documentation
Существует документированная граница для прогресса на таймере: Apple говорит, что представления прогресса, зависящие от даты, не поддерживают пользовательские стили. Для этого градиентного внешнего вида используйте числовой инициализатор прогресса и заставьте владельца активности предоставлять свою завершённую долю. Если вам нужны автоматические обновления Apple, относительные к дате, используйте этот инициализатор с его поддерживаемым представлением. init(timerInterval:countsDown:label:currentValueLabel:) — Apple Developer Documentation
Проверить появление с короткой диагностической последовательностью
Используйте эти проверки после сборки цели macOS. Они отличают проблемы отображения цветов от проблем выравнивания и размеров:
- Выберите
Reset. Серая дорожка должна остаться, без видимого градиента. - Выберите
25%. Должны появиться только ранние цвета градиента. Видимый оранжевый конец указывает на то, что сам градиент укорачивается. - Выберите
50%. С предоставленными остановками граница появления должна достичь фиолетовой середины. - Выберите
Complete. Градиент должен заполнить дорожку до оранжевого. - Измените размер окна при 50%. Видимая часть должна оставаться половиной недавно оразмеренной дорожки.
- Включите
Indeterminate. Числовая заливка должна уступить место встроенному индикатору, пока ярлыки задачи и текущего значения остаются присутствующими.
Если появление растёт от центра, проверьте .mask(alignment: .leading). Если индикатор занимает чрезмерное вертикальное пространство, проверьте кадр снаружи GeometryReader. Если изменение прогресса двигает каждую цветовую остановку, проверьте, что только маска использует множитель завершения.
Повторно используемое правило простое: задайте размер градиента для полной дорожки, затем используйте отдельно размеренную маску для показа прогресса. Разделение этих обязанностей делает частичное завершение, изменение размера и анимацию легче для рассуждения, сохраняя документированную конфигурацию панели прогресса.
Источники
- LinearGradient — Apple
- mask(alignment:_:) — Apple
- App — Apple
- ProgressViewStyle — Apple
- GeometryReader — Apple
- frame(width:height:alignment:) — Apple
- clipShape(_:style:) — Apple
- fractionCompleted — Apple
- init(value:total:label:currentValueLabel:) — Apple
- State — Apple
- animation(_:value:) — Apple
- init(timerInterval:countsDown:label:currentValueLabel:) — Apple