BS
BleepingSwift
Published on

> How to Add an Activity Indicator (Spinner) in SwiftUI

Authors
  • avatar
    Name
    Mick MacCallum
    Twitter
    @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.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.