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.
