BS
BleepingSwift
Published on
4 min read

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

Share:

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:

Swift
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:

Swift
ProgressView("Loading...")

Or use the more flexible label closure:

Swift
ProgressView {
    Text("Fetching data")
        .font(.caption)
        .foregroundStyle(.secondary)
}

Styling the Spinner

Adjust the color and size using standard modifiers:

Swift
ProgressView()
    .tint(.blue)  // Changes spinner color

To make the spinner larger, apply a scale effect:

Swift
ProgressView()
    .scaleEffect(2)

Or use the controlSize modifier for standard sizes:

Swift
ProgressView()
    .controlSize(.large)

Conditional Loading States

Show the spinner only while loading:

Swift
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:

Swift
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:

Swift
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:

Swift
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:

Swift
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:

Swift
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:

Swift
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:

Swift
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:

Swift
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:

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