What You Will Build
Two dot-based loading spinners: one with opacity fading, one with scale pulsing. Both arrange 8 dots in a circle and cycle through them with a timer. These replace ProgressView when you need a branded spinner.
Why This Pattern Matters
Custom loading indicators are essential for branded apps. This pattern teaches circular layout math and timer-driven state — skills that apply to clocks, activity rings, and dial controls.
The Opacity Spinner
struct OpacitySpinner: View {
@State private var activeIndex = 0
let dotCount = 8
let radius: CGFloat = 25
let timer = Timer.publish(every: 0.12, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
ForEach(0..<dotCount, id: \.self) { index in
Circle()
.frame(width: 10, height: 10)
.foregroundStyle(.white)
.opacity(index == activeIndex ? 1.0 : 0.0)
.animation(.spring(duration: 1.0), value: activeIndex)
.offset(y: -radius)
.rotationEffect(.degrees(Double(index) / Double(dotCount) * 360))
}
}
.frame(width: 80, height: 80)
.onReceive(timer) { _ in
activeIndex = (activeIndex + 1) % dotCount
}
}
}
The Scale Variant
Swap opacity for scaleEffect to make dots pop instead of fade:
Circle()
.frame(width: 10, height: 10)
.scaleEffect(index == activeIndex ? 1.5 : 0.5)
.animation(.spring(duration: 0.8), value: activeIndex)
Tips
- Spring duration creates the trail. The previous dot fades while the next lights up.
- Keep the frame explicit (80x80) so the spinner does not push layout.
- Timer interval: 0.12s for opacity, 0.10s for scale.
iOS Version: iOS 15+