How to Build a Metronome App in SwiftUI

A metronome app gives musicians a precise, always-available tempo reference on their iPhone, with support for multiple time signatures and tap-tempo input. It's an ideal first real-device project for beginner iOS developers and a genuinely useful tool for practice sessions and teaching studios.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

Architecture overview

The app is three layers: a MetronomeEngine @Observable class owns all AVFoundation scheduling and a high-priority DispatchSourceTimer; SwiftData persists named tempo presets; and the SwiftUI view hierarchy reads engine state via @Bindable to drive beat animations. There are no network calls, no third-party dependencies, and no entitlements beyond the background audio mode.

MetronomeApp/
├── App/
│   └── MetronomeApp.swift
├── Engine/
│   └── MetronomeEngine.swift   ← AVFoundation + DispatchSourceTimer
├── Models/
│   └── TempoPreset.swift       ← @Model (SwiftData)
├── Views/
│   ├── ContentView.swift
│   └── BeatIndicator.swift
└── PrivacyInfo.xcprivacy

Step-by-step

1. Data model

Define a SwiftData @Model to persist named tempo presets so musicians can instantly switch between song tempos without re-dialing.

import SwiftData
import Foundation

@Model
final class TempoPreset {
    var name: String
    var bpm: Double
    var beatsPerMeasure: Int   // numerator:  2, 3, 4, 5, 6, 7…
    var noteValue: Int         // denominator: 4 = quarter, 8 = eighth

    init(
        name: String,
        bpm: Double,
        beatsPerMeasure: Int = 4,
        noteValue: Int = 4
    ) {
        self.name = name
        self.bpm = bpm
        self.beatsPerMeasure = beatsPerMeasure
        self.noteValue = noteValue
    }
}

// MetronomeApp.swift:
// WindowGroup { ContentView() }
//   .modelContainer(for: TempoPreset.self)

2. Core UI

Build the main view with a large animated BPM readout, a tempo slider, beat indicators, and a clearly labelled start/stop button.

struct ContentView: View {
    @State private var engine = MetronomeEngine()
    @State private var bpm: Double = 120

    var body: some View {
        VStack(spacing: 28) {
            Text("\(Int(bpm))")
                .font(.system(size: 96, weight: .bold, design: .rounded))
                .contentTransition(.numericText())
                .animation(.spring, value: bpm)

            Slider(value: $bpm, in: 40...240, step: 1)
                .onChange(of: bpm) { engine.bpm = bpm }
                .padding(.horizontal)

            BeatIndicatorRow(engine: engine)  // ForEach over currentBeat

            Button(engine.isRunning ? "Stop" : "Start") {
                if engine.isRunning { engine.stop() } else { engine.start() }
            }
            .buttonStyle(.borderedProminent)
            .tint(engine.isRunning ? .red : .accentColor)
            .controlSize(.large)
        }
        .padding()
    }
}

3. Tempo and time signatures (the metronome engine)

Drive tick audio with a high-priority DispatchSourceTimer — never use Timer or asyncAfter here, as both drift badly above 120 BPM under any load.

@Observable
final class MetronomeEngine {
    var bpm: Double = 120
    var beatsPerMeasure: Int = 4
    var currentBeat: Int = 0
    var isRunning = false

    private let audioEngine = AVAudioEngine()
    private let playerNode  = AVAudioPlayerNode()
    private var accentBuf: AVAudioPCMBuffer?
    private var tickBuf:   AVAudioPCMBuffer?
    private var timer: DispatchSourceTimer?

    func start() {
        isRunning = true; currentBeat = 0
        let src = DispatchSource.makeTimerSource(
            queue: .global(qos: .userInteractive))
        src.schedule(deadline: .now(),
                     repeating: 60.0 / bpm,
                     leeway: .milliseconds(1))
        src.setEventHandler { [weak self] in self?.tick() }
        src.resume(); timer = src
    }

    func stop() { isRunning = false; timer?.cancel(); timer = nil }

    private func tick() {
        let beat = (currentBeat % beatsPerMeasure) + 1
        DispatchQueue.main.async { self.currentBeat = beat }
        playerNode.scheduleBuffer(beat == 1 ? accentBuf! : tickBuf!,
                                  at: nil, options: .interrupts)
        playerNode.play()
    }
}

4. Privacy Manifest

Add PrivacyInfo.xcprivacy to your app target — not just the project — or Xcode 16 will block your App Store upload if any required-reason APIs are accessed.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyTrackingDomains</key>
  <array/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>C617.1</string></array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: One-time purchase

The simplest approach is a paid app ($2.99–$4.99) — no in-app friction and no subscription fatigue. Alternatively, ship free and gate "Pro" features behind a StoreKit 2 non-consumable IAP: custom time signatures beyond 4/4, alternate visual themes, a tap-tempo smoothing toggle, and a practice-session history view. Call Product.purchase() from the StoreKit framework, persist the entitlement with a transaction listener on app launch (Transaction.updates), and store a simple @AppStorage("isPro") bool. Always add a Restore Purchases button — App Store review guidelines require it for non-consumable IAPs and reviewers will reject the build without one.

Shipping this faster with Soarias

Soarias handles the parts of this project that aren't writing music logic: it scaffolds the Xcode project with SwiftData and AVFoundation already linked, generates a correctly structured PrivacyInfo.xcprivacy with the right API-reason codes pre-filled, configures fastlane with your App Store Connect credentials, captures App Store screenshots at every required device size, and submits the build — so you never touch the App Store Connect web UI manually.

For a beginner project at this scope, first-time App Store setup typically costs a full weekend even for developers who've done it before. With Soarias, that overhead compresses to under an hour: run one command after your archive builds, confirm the screenshots look right, and the build is live in TestFlight while you're still writing your App Store description copy.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes, for anything beyond the Simulator. A $99/year Apple Developer Program membership is required to install on a physical device with a stable provisioning profile, and it's mandatory for TestFlight distribution and App Store submission. Free provisioning works for 7-day sideloading windows but is cumbersome for ongoing testing and unsupported for distribution.

How do I submit this to the App Store?

Archive your app in Xcode (Product → Archive), upload the build to App Store Connect via Xcode Organizer or fastlane's upload_to_app_store action, fill in the required metadata (screenshots, description, support URL, privacy policy URL), set your price tier, and submit for review. First-time reviews typically resolve within 24–48 hours. Soarias automates every step after the archive.

Can the metronome keep ticking when the screen turns off?

Yes — enable the Audio, AirPlay, and Picture in Picture background mode under Signing & Capabilities in your Xcode target, and call AVAudioSession.sharedInstance().setCategory(.playback, mode: .measurement) before starting your audio engine. Both are required: the capability tells the OS to keep your process alive, and the session category tells iOS not to silence your audio stream when the display sleeps.

Last reviewed: 2026-05-12 by the Soarias team.