SwiftUI ProgressView: Spinners, Progress Bars, and Custom Styles
From a one-line spinner to a determinate download bar to a fully custom ring, this guide covers ProgressView end to end — including clean async/await loading states with .task and how to tint and style the control. Real Swift 6 code.
ProgressView() with no arguments shows an indeterminate spinner; ProgressView(value:total:) shows a determinate bar. Switch appearance with .progressViewStyle(.linear) or .circular, or supply a custom type conforming to ProgressViewStyle for full control. Drive loading UI by toggling a state flag around an async .task, and recolor the indicator with .tint.
Indeterminate vs determinate
ProgressView has two fundamental modes. With no value it is indeterminate — a spinning indicator that communicates 'working' when you can't know how long the task will take, like a network request. Supply a value and total and it becomes determinate, drawing a bar that fills proportionally as work completes, appropriate for a file download or a multi-step import where progress is measurable. The rule of thumb: if you can compute a percentage, show a determinate bar so users get real feedback; if you genuinely can't, a spinner is honest. Never fake a determinate bar with a timer — it erodes trust when it stalls at 99%.
struct ProgressExamples: View {
@State private var downloaded = 0.4 // 40%
var body: some View {
VStack(spacing: 32) {
ProgressView() // indeterminate spinner
ProgressView("Downloading", value: downloaded, total: 1.0)
.progressViewStyle(.linear)
}
.padding()
}
}Linear, circular, and labels
Two built-in styles cover most needs. .linear renders a horizontal bar and is the default for determinate progress; .circular renders the spinning ring and is the default for indeterminate. You can force either style regardless of mode — a circular determinate ring is common in fitness apps. ProgressView also accepts a label and, in the value initializer, a currentValueLabel closure so you can show '3 of 10' beneath the bar. Labels inherit the surrounding font and foreground style, so style them at the call site. Combining a determinate value with a currentValueLabel gives users both a visual bar and a precise count.
- .progressViewStyle(.linear) — horizontal bar
- .progressViewStyle(.circular) — spinning ring / determinate ring
- Pass a label string or view for context
- currentValueLabel shows a precise count under the bar
struct ImportProgress: View {
let done: Int
let total: Int
var body: some View {
ProgressView(value: Double(done), total: Double(total)) {
Text("Importing Contacts")
} currentValueLabel: {
Text("\(done) of \(total)")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
}
}Loading states with async/await and .task
The cleanest way to show a spinner during async work is a state flag toggled around a .task modifier. .task runs when the view appears and is automatically cancelled if the view disappears, so it's safer than firing work in onAppear. Set isLoading true, await the call, and set it false in a defer so it clears even if the call throws. Better still, model the whole screen as a small enum — loading, loaded(data), failed(error) — and switch over it in the body. That eliminates the classic bug where a spinner and content both appear, or neither does, because two independent booleans drifted out of sync.
enum LoadingState<Value> {
case loading
case loaded(Value)
case failed(String)
}
struct ArticleView: View {
@State private var state: LoadingState<[String]> = .loading
var body: some View {
Group {
switch state {
case .loading:
ProgressView("Loading articles")
case .loaded(let items):
List(items, id: \.self) { Text($0) }
case .failed(let message):
Text(message).foregroundStyle(.red)
}
}
.task { await load() }
}
private func load() async {
do {
let items = try await fetchArticles()
state = .loaded(items)
} catch {
state = .failed(error.localizedDescription)
}
}
}
func fetchArticles() async throws -> [String] { ["One", "Two"] }Tint and quick customization
Recoloring a ProgressView is a one-liner: .tint(_:) sets the fill of the bar or the color of the spinner. This is the correct modern approach — older tint parameters on the style initializers are deprecated. Because tint cascades, setting an accent color once at a container level colors every progress indicator inside it consistently. You can also scale a spinner with .scaleEffect if the default feels small in a full-screen loading state, and center it with a frame plus .frame(maxWidth: .infinity, maxHeight: .infinity). For a determinate bar you control the fill purely through the value/total ratio, so animate value changes with withAnimation for a smooth sweep.
struct FullScreenLoader: View {
var body: some View {
ProgressView("Please wait")
.progressViewStyle(.circular)
.tint(.indigo)
.scaleEffect(1.4)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.ultraThinMaterial)
}
}A custom ProgressViewStyle
When you need a branded ring or a segmented bar, conform to ProgressViewStyle. makeBody(configuration:) receives a Configuration whose fractionCompleted is an optional Double — nil for indeterminate work, 0...1 for determinate. Read it to draw your own shape; a trimmed Circle makes a clean progress ring. Return your label from configuration.label if you want to keep call-site text. Because the style receives the fraction rather than the raw value/total, your custom style works with any ProgressView that uses it. Guard against the nil case so an accidental indeterminate ProgressView using your style degrades gracefully instead of drawing an empty ring.
struct RingProgressStyle: ProgressViewStyle {
var lineWidth: CGFloat = 8
func makeBody(configuration: Configuration) -> some View {
let fraction = configuration.fractionCompleted ?? 0
return ZStack {
Circle()
.stroke(.gray.opacity(0.2), lineWidth: lineWidth)
Circle()
.trim(from: 0, to: fraction)
.stroke(.accentColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
Text("\(Int(fraction * 100))%").font(.caption).bold()
}
.frame(width: 80, height: 80)
}
}
// Usage:
// ProgressView(value: 0.65).progressViewStyle(RingProgressStyle())Loading states without the guesswork
The Swift Kit wraps every network call in a LoadingState enum, so screens show a spinner, your content, or a friendly error automatically — no drifting booleans.
How to show a spinner during an async fetch
Use .task and a state enum so the spinner appears while loading and clears whether the call succeeds or fails.
- 1
Model the state
Use an enum with loading, loaded, and failed cases instead of loose booleans.
enum State { case loading, loaded([Item]), failed(String) } - 2
Switch in the body
Render ProgressView for loading, content for loaded, an error for failed.
switch state { case .loading: ProgressView(); default: EmptyView() } - 3
Kick off with .task
.task runs on appear and cancels on disappear — safer than onAppear.
.task { await load() } - 4
Update state after await
Assign loaded or failed once the async call returns.
do { state = .loaded(try await fetch()) } catch { state = .failed("\(error)") }
Frequently Asked Questions
When should I use an indeterminate SwiftUI ProgressView spinner versus a determinate bar?
Why does my SwiftUI ProgressView spinner and content both show at once, or neither appear?
How do I read the completion fraction inside a custom SwiftUI ProgressViewStyle?
What's the current way to change a SwiftUI ProgressView's color now that style tint parameters are deprecated?
Why is it better to start a SwiftUI loading fetch in .task instead of onAppear?
Keep exploring
Async plumbing, already solved
The Swift Kit is a $99 one-time SwiftUI boilerplate with a design system, multi-provider AI, and a LoadingState pattern that makes every async screen behave — so you focus on the feature.
Get The Swift Kit — $99One-time purchase · Lifetime updates · 14-day refund