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+

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.