How to Add an Activity Indicator in SwiftUI

How to Add an Activity Indicator in SwiftUI

Written by

in

Quick answer: Use SwiftUI’s built-in ProgressView() to display a standard spinning activity indicator on iOS 14 and later. To create a full-screen loading state, place ProgressView() inside a ZStack or .overlay modifier and disable user interaction on the underlying content while loading.

Why this happens

Developers coming from UIKit often look for a UIActivityIndicatorView equivalent in SwiftUI and cannot find one with the same name. In the first release of SwiftUI (iOS 13), Apple did not include a native spinner view, so developers had to wrap UIActivityIndicatorView using UIViewRepresentable.

Starting with iOS 14 and macOS 11, Apple introduced ProgressView. When initialized without a progress value, ProgressView renders an indeterminate spinning indicator that matches the platform’s default UIActivityIndicatorView appearance.

Building a full-screen activity indicator also requires more than just placing a spinner on screen. Without a container such as ZStack or .overlay combined with .disabled(true), users can still tap buttons, scroll lists, or trigger duplicate network requests behind the spinner.

How to fix it

1. Use SwiftUI’s native ProgressView (iOS 14 and later)

For any app targeting iOS 14 or newer, ProgressView() is the standard way to show a loading spinner. By default, an indeterminate ProgressView uses the circular style.

import SwiftUI

struct SpinnerExampleView: View {
    var body: some View {
        VStack(spacing: 16) {
            ProgressView()

            ProgressView("Loading...")
                .progressViewStyle(.circular)
        }
    }
}

If you need to adjust the spinner’s size or color on iOS 15 and later, apply .controlSize(_:) and .tint(_:):

ProgressView()
    .controlSize(.large)
    .tint(.blue)

To display ProgressView only while an asynchronous task runs, bind it to a @State boolean inside a ZStack or .overlay:

import SwiftUI

struct ContentView: View {
    @State private var isLoading = false

    var body: some View {
        List(1...10, id: \.self) { item in
            Text("Row \(item)")
        }
        .disabled(isLoading)
        .overlay {
            if isLoading {
                ZStack {
                    Color.black.opacity(0.25)
                        .ignoresSafeArea()

                    ProgressView("Loading...")
                        .padding(24)
                        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
                }
            }
        }
    }
}

Using .disabled(isLoading) prevents taps and scrolling on the underlying List while the overlay is visible. Adding a semi-transparent background with .ignoresSafeArea() ensures the loading state covers the entire screen, including behind navigation bars and safe areas.

2. Create a reusable full-screen loading modifier

If multiple screens in your app need the same full-screen activity indicator, repeating ZStack and .overlay code in every view becomes messy. Wrapping the pattern in a ViewModifier keeps your views clean and ensures consistent behavior.

import SwiftUI

struct LoadingOverlayModifier: ViewModifier {
    let isLoading: Bool
    let message: String?

    func body(content: Content) -> some View {
        ZStack {
            content
                .disabled(isLoading)
                .blur(radius: isLoading ? 2 : 0)

            if isLoading {
                Color.black.opacity(0.2)
                    .ignoresSafeArea()

                VStack(spacing: 12) {
                    ProgressView()
                        .controlSize(.large)

                    if let message {
                        Text(message)
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                    }
                }
                .padding(24)
                .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
            }
        }
        .animation(.easeInOut(duration: 0.2), value: isLoading)
    }
}

extension View {
    func loadingOverlay(isLoading: Bool, message: String? = "Loading...") -> some View {
        modifier(LoadingOverlayModifier(isLoading: isLoading, message: message))
    }
}

You can then attach .loadingOverlay(isLoading: isLoading) to any view:

struct ProfileView: View {
    @State private var isLoading = true

    var body: some View {
        NavigationStack {
            List {
                Text("Account Details")
                Text("Preferences")
            }
            .navigationTitle("Profile")
        }
        .loadingOverlay(isLoading: isLoading)
    }
}

3. Wrap UIActivityIndicatorView for iOS 13 or UIKit customization

If you maintain a legacy deployment target of iOS 13, or you need direct access to UIKit properties on UIActivityIndicatorView, wrap it in UIViewRepresentable:

import SwiftUI
import UIKit

struct ActivityIndicator: UIViewRepresentable {
    @Binding var isAnimating: Bool
    var style: UIActivityIndicatorView.Style = .medium
    var color: UIColor? = nil

    func makeUIView(context: Context) -> UIActivityIndicatorView {
        let indicator = UIActivityIndicatorView(style: style)
        indicator.hidesWhenStopped = true
        return indicator
    }

    func updateUIView(_ uiView: UIActivityIndicatorView, context: Context) {
        uiView.style = style
        if let color {
            uiView.color = color
        }
        if isAnimating {
            uiView.startAnimating()
        } else {
            uiView.stopAnimating()
        }
    }
}

Use this wrapper just like a standard SwiftUI view:

ActivityIndicator(isAnimating: $isLoading, style: .large, color: .systemBlue)

Notice that updateUIView(_:context:) checks isAnimating on every state change and calls startAnimating() or stopAnimating() accordingly. Without that check, the spinner will not respond when your @State variable toggles.

How to prevent it

  • Default to ProgressView(): Avoid writing UIViewRepresentable wrappers unless you must support iOS 13 or require a UIKit-only property.
  • Always block interaction during full-screen loads: Pair your overlay with .disabled(isLoading) so users cannot tap controls or submit forms twice while a request is in flight.
  • Test both Light and Dark Mode: Use semantic materials (.regularMaterial) or dynamic system colors rather than hardcoded white or black backgrounds so the spinner and label remain legible in all appearances.
  • Prefer inline spinners when possible: Full-screen overlays block the entire UI; for localized updates like a single button or row, place a small ProgressView() inline instead of locking the whole screen.

FAQ

How do I change the size of ProgressView in SwiftUI?

On iOS 15 and later, apply .controlSize(.large) to make the spinner larger, or .controlSize(.small) for a compact spinner. If you need an exact custom pixel size, you can also apply .scaleEffect(_:) to the ProgressView.

Why is my ProgressView showing a horizontal bar instead of a spinner?

When you pass a value parameter—such as ProgressView(value: 0.5)—SwiftUI defaults to a linear progress bar. Omit the value parameter (ProgressView()) or explicitly apply .progressViewStyle(.circular) to display a spinning activity indicator.

Should I use ZStack or .overlay for a full-screen activity indicator?

Both work well and produce the same visual result when configured properly. .overlay keeps the base view’s layout sizing untouched, while ZStack makes the layering order explicit when you want to blur or dim the underlying content.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.