SwiftUI Tutorial

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.

Last updated: 2026-07-17 9 min read By Ahmed Gagan, iOS Engineer
Quick Answer

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
ProgressView() — a spinner when duration is unknown
Determinate
ProgressView(value:total:) — a bar with known progress
Styling
.progressViewStyle(.linear/.circular) or a custom ProgressViewStyle
Swift Kit support
The Swift Kit wraps network calls in a LoadingState enum so every screen shows a spinner, content, or error without ad-hoc flags.

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%.

Spinner and determinate bar
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
A labelled determinate ProgressView
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.

A LoadingState enum driving the UI
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.

Tinted, scaled full-screen loader
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.

A custom ring ProgressViewStyle
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.

Get The Swift Kit — $99

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. 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. 2

    Switch in the body

    Render ProgressView for loading, content for loaded, an error for failed.

    switch state { case .loading: ProgressView(); default: EmptyView() }
  3. 3

    Kick off with .task

    .task runs on appear and cancels on disappear — safer than onAppear.

    .task { await load() }
  4. 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?
Use a determinate ProgressView(value:total:) whenever you can compute real progress — a download with a known byte count, an import with N steps — because it gives honest feedback. Use the indeterminate ProgressView() spinner only when the duration is genuinely unknown. Never fake a determinate bar with a timer; it stalls and erodes trust.
Why does my SwiftUI ProgressView spinner and content both show at once, or neither appear?
That happens when two independent booleans (isLoading, hasData) drift out of sync. Replace them with a single state enum — loading, loaded, failed — and switch over it in the body. One source of truth makes the mutually exclusive states impossible to violate.
How do I read the completion fraction inside a custom SwiftUI ProgressViewStyle?
makeBody(configuration:) gives you configuration.fractionCompleted, an optional Double that is nil for indeterminate work and 0...1 for determinate. Read it to draw your shape — for example trimming a Circle — and handle the nil case so an indeterminate ProgressView using your style degrades gracefully.
What's the current way to change a SwiftUI ProgressView's color now that style tint parameters are deprecated?
Apply the standalone .tint(_:) modifier to the ProgressView, which colors the bar fill or the spinner. Because tint cascades down the hierarchy, setting it once on a container tints every progress indicator inside consistently. The old tint arguments on style initializers are deprecated.
Why is it better to start a SwiftUI loading fetch in .task instead of onAppear?
.task starts async work when the view appears and automatically cancels it if the view disappears before it finishes, which prevents wasted work and updates to a gone view. onAppear has no cancellation and doesn't provide an async context, so you'd have to spin up a Task manually and manage cancellation yourself.

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 — $99

One-time purchase · Lifetime updates · 14-day refund