Custom Progress Indicator with SwiftUI Symbol Effects


Greetings, traveler!

Modern iOS apps often rely on subtle motion and clear visual feedback to communicate progress. While SwiftUI includes ProgressView, sometimes you need a lighter, more flexible, and fully stylable indicator that fits seamlessly into your brand’s design system.

Let’s walk through how to build a reusable ProgressIndicator component that adapts to different control sizes, respects accessibility preferences, and takes advantage of SwiftUI’s SymbolEffect API.

Why a Custom Progress Indicator?

System components like ProgressView are great for standard use cases, but they have limitations:

  • They don’t integrate with SF Symbols’ animated effects.
  • Styling and scaling options are limited.
  • Accessibility customization is minimal.

By using SymbolEffect, we can create a more flexible progress indicator that:

  • Adapts to different ControlSizes.
  • Responds to system motion settings.
  • Supports localization for accessibility labels.
  • Allows configurable symbol, effect, and animation speed.

Implementation Overview

Here’s the core implementation of the SymbolIndicator:

import SwiftUI

public struct SymbolIndicator<T>: View  where T: IndefiniteSymbolEffect, T: SymbolEffect {
    
    private var overriddenControlSize: ControlSize?
    private var speed: Double = 1.5
    private var effect: T
    private var symbol: String = "ellipsis"
    private var accessibilityLabel: LocalizedStringKey = "loading indicator"
    private var controlSize: ControlSize {
        overriddenControlSize ?? envControlSize
    }
    
    @Environment(\.controlSize) private var envControlSize
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @ScaledMetric private var base: CGFloat = 40
    
    private var size: CGFloat {
        switch controlSize {
        case .mini: base * 0.5
        case .small: base * 0.75
        case .regular: base
        case .large: base * 1.5
        case .extraLarge: base * 2.5
        @unknown default: base * 1.75
        }
    }
    
    private var repeating: SymbolEffectOptions {
        if #available(iOS 18.0, *) {
            .repeat(.continuous)
        } else {
            .repeating
        }
    }
    
    public init(
        _ controlSize: ControlSize? = nil,
        effect: T = VariableColorSymbolEffect.variableColor
    ) {
        self.overriddenControlSize = controlSize
        self.effect = effect
    }
    
    public var body: some View {
        if reduceMotion {
            image
        } else {
            image
                .symbolEffect(
                    effect,
                    options: repeating.speed(speed)
                )
        }
    }
    
    private var image: some View {
        Image(systemName: symbol)
            .font(.system(size: size, weight: .heavy))
            .foregroundStyle(.primary)
            .accessibilityLabel(accessibilityLabel)
    }
}

Builder Methods for Easy Configuration

Instead of exposing all parameters through the initializer, the component uses a builder pattern to keep usage concise and readable:

public extension SymbolIndicator {
    func symbol(_ symbol: String) -> Self {
        var copy = self
        copy.symbol = symbol
        return copy
    }
    
    func speed(_ speed: Double) -> Self {
        var copy = self
        copy.speed = speed
        return copy
    }
    
    func accessibilityLabel(_ key: LocalizedStringKey) -> Self {
        var copy = self
        copy.accessibilityLabel = key
        return copy
    }
    
    func accessibilityLabel(_ text: String) -> Self {
        var copy = self
        copy.accessibilityLabel = LocalizedStringKey(text)
        return copy
    }
}

This makes the API expressive yet type-safe, allowing fluent configuration in SwiftUI views.

Example Usage

A simple example using the default style:

SymbolIndicator()

Customizing the symbol, speed, effect and size:

SymbolIndicator(.large, effect: .rotate)
    .symbol("arrow.triangle.2.circlepath")
    .speed(2.0)

Accessibility and Reduce Motion

The component automatically checks the user’s Reduce Motion setting through:

@Environment(\.accessibilityReduceMotion)

When enabled, all symbol animations are turned off to respect the user’s preferences.
Additionally, the accessibilityLabel property supports LocalizedStringKey, making it compatible with your .strings files for effortless localization.

Example:

SymbolIndicator()
    .accessibilityLabel(Localization.loading)

Conclusion

This component demonstrates how SwiftUI’s symbol effects can transform small UI details into responsive micro-interactions — all while respecting user preferences and accessibility.

Swift Package GitHub