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 writingUIViewRepresentablewrappers 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.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
