- Published on
> How to Add an Activity Indicator (Spinner) in SwiftUI
- Authors

- Name
- Mick MacCallum
- @0x7fs
SwiftUI provides ProgressView for showing loading indicators. Unlike UIKit's UIActivityIndicatorView, you don't need to manage starting and stopping the animation—it spins automatically when visible.
Basic Spinner
The simplest activity indicator is an indeterminate ProgressView:
struct LoadingView: View {
var body: some View {
ProgressView()
}
}
This displays the system's default spinning indicator. On iOS it's a circular spinner; on macOS it's a spinning gear.
Adding a Label
Provide context with a label:
ProgressView("Loading...")
Or use the more flexible label closure:
ProgressView {
Text("Fetching data")
.font(.caption)
.foregroundStyle(.secondary)
}
Styling the Spinner
Adjust the color and size using standard modifiers:
ProgressView()
.tint(.blue) // Changes spinner color
To make the spinner larger, apply a scale effect:
ProgressView()
.scaleEffect(2)
Or use the controlSize modifier for standard sizes:
ProgressView()
.controlSize(.large)
Conditional Loading States
Show the spinner only while loading:
struct DataView: View {
@State private var isLoading = true
@State private var data: [String] = []
var body: some View {
Group {
if isLoading {
ProgressView("Loading items...")
} else {
List(data, id: \.self) { item in
Text(item)
}
}
}
.task {
data = await fetchData()
isLoading = false
}
}
}
Overlay Pattern
A common pattern is overlaying the spinner on content:
struct ContentView: View {
@State private var isLoading = false
var body: some View {
List {
ForEach(items) { item in
ItemRow(item: item)
}
}
.overlay {
if isLoading {
ZStack {
Color.black.opacity(0.3)
.ignoresSafeArea()
ProgressView()
.padding()
.background(.regularMaterial)
.cornerRadius(10)
}
}
}
}
}
Full-Screen Loading
For blocking operations, cover the entire screen:
struct FullScreenLoader: View {
let message: String
var body: some View {
ZStack {
Color(.systemBackground)
.ignoresSafeArea()
VStack(spacing: 20) {
ProgressView()
.scaleEffect(1.5)
Text(message)
.foregroundStyle(.secondary)
}
}
}
}
Use it by presenting conditionally:
struct AppView: View {
@State private var isInitializing = true
var body: some View {
ZStack {
MainContent()
.disabled(isInitializing)
if isInitializing {
FullScreenLoader(message: "Setting up...")
}
}
.task {
await initializeApp()
isInitializing = false
}
}
}
Button Loading State
Replace button content with a spinner while processing:
struct LoadingButton: View {
let title: String
let action: () async -> Void
@State private var isLoading = false
var body: some View {
Button {
Task {
isLoading = true
await action()
isLoading = false
}
} label: {
if isLoading {
ProgressView()
.tint(.white)
} else {
Text(title)
}
}
.frame(minWidth: 100)
.disabled(isLoading)
}
}
Custom Spinner Using Animation
If you want a custom look, build your own with rotation animation:
struct CustomSpinner: View {
@State private var isAnimating = false
var body: some View {
Circle()
.trim(from: 0, to: 0.7)
.stroke(Color.blue, lineWidth: 4)
.frame(width: 50, height: 50)
.rotationEffect(Angle(degrees: isAnimating ? 360 : 0))
.animation(
.linear(duration: 1)
.repeatForever(autoreverses: false),
value: isAnimating
)
.onAppear {
isAnimating = true
}
}
}
Or use multiple dots:
struct DotsSpinner: View {
@State private var activeIndex = 0
var body: some View {
HStack(spacing: 8) {
ForEach(0..<3, id: \.self) { index in
Circle()
.fill(Color.blue)
.frame(width: 10, height: 10)
.scaleEffect(activeIndex == index ? 1.2 : 0.8)
.opacity(activeIndex == index ? 1 : 0.5)
}
}
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { _ in
withAnimation(.easeInOut(duration: 0.3)) {
activeIndex = (activeIndex + 1) % 3
}
}
}
}
}
Determinate Progress
When you know the progress percentage, show a linear or circular progress bar:
struct DownloadView: View {
@State private var progress: Double = 0
var body: some View {
VStack(spacing: 20) {
// Circular progress
ProgressView(value: progress)
.progressViewStyle(.circular)
// Linear progress
ProgressView(value: progress)
.progressViewStyle(.linear)
Text("\(Int(progress * 100))%")
}
.padding()
}
}
Pull-to-Refresh Integration
SwiftUI's pull-to-refresh shows its own indicator, but you can combine it with inline loading states:
struct RefreshableList: View {
@State private var items: [Item] = []
@State private var isRefreshing = false
var body: some View {
List(items) { item in
ItemRow(item: item)
}
.refreshable {
isRefreshing = true
items = await fetchItems()
isRefreshing = false
}
.overlay(alignment: .top) {
if isRefreshing {
ProgressView()
.padding()
}
}
}
}
Accessibility
ProgressView automatically announces its presence to VoiceOver. For custom spinners, add accessibility labels:
CustomSpinner()
.accessibilityLabel("Loading")
.accessibilityValue("In progress")
For labeled progress views, the label text is used automatically.
When to Use Which Pattern
Use a simple inline ProgressView for quick, non-blocking loads. Use an overlay pattern when the user can still see the content but shouldn't interact with it. Use full-screen loading for initial app setup or critical blocking operations where nothing should be visible until complete. Consider skeleton views instead of spinners for content that loads incrementally—they often feel faster to users even when the actual load time is the same.
// Continue_Learning
Creating Skeleton Loading Views (Shimmer Effect) in SwiftUI
Learn how to build skeleton loading screens with animated shimmer effects in SwiftUI using gradients, AnimatableModifier, and reusable view modifiers for a polished loading experience.
Supporting Dark Mode in a SwiftUI App
Learn how to properly support dark mode in SwiftUI using semantic colors, adaptive color assets, and color scheme detection.
Using SF Symbols in SwiftUI
Learn how to use SF Symbols in your SwiftUI apps, including sizing, coloring, animations, and finding the right symbol for your needs.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.