How to Build a Guitar Tab Viewer App in SwiftUI
A Guitar Tab Viewer lets musicians read, scroll, and play along with tablature notation on their iPhone or iPad — no paper, no PDFs squinted at on a laptop. It's aimed at hobbyist and professional guitarists who want their whole tab library in one place, readable on stage or in the practice room.
Prerequisites
- Mac with Xcode 16+ (Swift 5.10)
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge; comfort with closures and property wrappers
- A real iPhone or iPad for audio/rendering performance testing — the Simulator throttles Canvas draw calls noticeably
- Familiarity with guitar tablature notation is helpful but not required to follow the code
Architecture overview
The app is organised around three layers. The data layer uses SwiftData (@Model) to store a library of Tab documents, each containing an array of Measure objects with beat/note data; a ModelContainer configured with iCloud sync keeps the library available across devices. The rendering layer uses SwiftUI Canvas for ASCII-style tab notation drawn frame-by-frame, and a PDFView wrapper (via PDFKit) for imported scanned or downloaded PDFs. The state layer lives in an @Observable TabPlayerModel that owns the playback cursor, tempo, and current scroll position, so any view in the hierarchy can react without manual objectWillChange plumbing.
GuitarTabApp/ ├── GuitarTabApp.swift # @main, ModelContainer setup ├── Models/ │ ├── Tab.swift # @Model — title, tuning, tempo, measures │ ├── Measure.swift # @Model — beats array, time signature │ └── Note.swift # struct — string, fret, duration ├── ViewModels/ │ └── TabPlayerModel.swift # @Observable — cursor, BPM, playback state ├── Views/ │ ├── LibraryView.swift # NavigationSplitView sidebar │ ├── TabDetailView.swift # routes Canvas vs PDFView │ ├── TabCanvasView.swift # SwiftUI Canvas renderer │ ├── PDFTabView.swift # UIViewRepresentable PDFView │ └── TempoToolbar.swift # BPM stepper + tap-tempo button ├── Utilities/ │ └── TabParser.swift # ASCII tab → Note array └── PrivacyInfo.xcprivacy # App Store required manifest
Step-by-step
1. Project setup
Create a new iOS App project in Xcode 16, choose SwiftUI for the interface, and enable SwiftData storage. Then declare the document types your app can open so the Files app and Share sheet can hand off tabs to you.
// GuitarTabApp.swift
import SwiftUI
import SwiftData
@main
struct GuitarTabApp: App {
var body: some Scene {
WindowGroup {
LibraryView()
}
.modelContainer(for: [Tab.self, Measure.self])
}
}
// Info.plist additions (add via Xcode target editor):
// CFBundleDocumentTypes:
// - LSItemContentTypes: [public.pdf, com.apple.plist]
// - CFBundleTypeName: Guitar Tab
// - CFBundleTypeRole: Viewer
2. Data model with SwiftData
Model your tab library with three types. Tab is the top-level document; Measure groups beats; Note is a lightweight struct (not a SwiftData entity) stored as JSON-encoded data in the parent measure.
// Models/Tab.swift
import SwiftData
import Foundation
@Model
final class Tab {
var title: String
var artist: String
var tuning: String // e.g. "EADGBe"
var tempo: Int // BPM
var isPDF: Bool
var pdfData: Data?
@Relationship(deleteRule: .cascade)
var measures: [Measure]
var createdAt: Date
init(title: String, artist: String = "",
tuning: String = "EADGBe", tempo: Int = 120) {
self.title = title
self.artist = artist
self.tuning = tuning
self.tempo = tempo
self.isPDF = false
self.measures = []
self.createdAt = .now
}
}
// Models/Measure.swift
@Model
final class Measure {
var beatsPerBar: Int
var notesData: Data // JSON-encoded [Note]
var tab: Tab?
init(beatsPerBar: Int = 4, notes: [Note] = []) {
self.beatsPerBar = beatsPerBar
self.notesData = (try? JSONEncoder().encode(notes)) ?? Data()
}
var notes: [Note] {
get { (try? JSONDecoder().decode([Note].self, from: notesData)) ?? [] }
set { notesData = (try? JSONEncoder().encode(newValue)) ?? Data() }
}
}
// Models/Note.swift
struct Note: Codable, Identifiable {
var id: UUID = UUID()
var string: Int // 0 = low E, 5 = high e
var fret: Int // 0–24; -1 = rest
var beat: Double // position in measure (0.0 – beatsPerBar)
var duration: Double // in beats
}
3. Tab library UI
Build the main library screen with a NavigationSplitView. The sidebar shows all saved tabs in a searchable list; selecting one opens the detail pane which routes to either the Canvas renderer or the PDF viewer based on tab.isPDF.
// Views/LibraryView.swift
import SwiftUI
import SwiftData
struct LibraryView: View {
@Environment(\.modelContext) private var context
@Query(sort: \Tab.createdAt, order: .reverse) private var tabs: [Tab]
@State private var selectedTab: Tab?
@State private var searchText = ""
@State private var showingImporter = false
var filtered: [Tab] {
guard !searchText.isEmpty else { return tabs }
return tabs.filter {
$0.title.localizedCaseInsensitiveContains(searchText) ||
$0.artist.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
NavigationSplitView {
List(filtered, selection: $selectedTab) { tab in
NavigationLink(value: tab) {
VStack(alignment: .leading, spacing: 2) {
Text(tab.title).font(.headline)
Text(tab.artist).font(.caption).foregroundStyle(.secondary)
}
}
}
.navigationTitle("My Tabs")
.searchable(text: $searchText, prompt: "Search tabs")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Import", systemImage: "plus") { showingImporter = true }
}
}
.fileImporter(
isPresented: $showingImporter,
allowedContentTypes: [.pdf, .plainText],
allowsMultipleSelection: false
) { result in
handleImport(result)
}
} detail: {
if let tab = selectedTab {
TabDetailView(tab: tab)
} else {
ContentUnavailableView("Select a tab", systemImage: "music.note.list")
}
}
}
private func handleImport(_ result: Result<[URL], Error>) {
guard let url = try? result.get().first,
url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
let newTab = Tab(title: url.deletingPathExtension().lastPathComponent)
if url.pathExtension.lowercased() == "pdf",
let data = try? Data(contentsOf: url) {
newTab.isPDF = true
newTab.pdfData = data
}
context.insert(newTab)
}
}
#Preview {
LibraryView()
.modelContainer(for: [Tab.self, Measure.self], inMemory: true)
}
4. Tab notation display with Canvas
This is the core feature. Use SwiftUI Canvas to draw six horizontal string lines, fret numbers at each beat column, and a moving playback cursor bar. Canvas gives you direct 2D drawing without CALayer overhead — essential for smooth 60 fps scrolling on a full song.
// Views/TabCanvasView.swift
import SwiftUI
struct TabCanvasView: View {
let tab: Tab
@Bindable var player: TabPlayerModel
private let stringCount = 6
private let stringSpacing: CGFloat = 22
private let beatWidth: CGFloat = 56
private let leftPad: CGFloat = 40
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
Canvas { ctx, size in
drawStrings(ctx: ctx, size: size)
drawNotes(ctx: ctx)
drawCursor(ctx: ctx, size: size)
}
.frame(
width: totalWidth,
height: CGFloat(stringCount) * stringSpacing + 40
)
.id("canvas")
}
}
}
private var totalWidth: CGFloat {
let beats = tab.measures.reduce(0) { $0 + $1.beatsPerBar }
return leftPad + CGFloat(beats) * beatWidth + 40
}
private func drawStrings(ctx: GraphicsContext, size: CGSize) {
for i in 0..= 0 {
let x = leftPad + CGFloat(beatOffset + Int(note.beat)) * beatWidth
let y = yFor(string: note.string)
// White background circle so digit sits above staff lines
ctx.fill(
Path(ellipseIn: CGRect(x: x - 10, y: y - 10, width: 20, height: 20)),
with: .color(.white)
)
ctx.draw(
Text("\(note.fret)").font(.system(size: 13, weight: .semibold, design: .monospaced)),
at: CGPoint(x: x, y: y)
)
}
beatOffset += measure.beatsPerBar
}
}
private func drawCursor(ctx: GraphicsContext, size: CGSize) {
guard player.isPlaying else { return }
let x = leftPad + player.cursorBeat * beatWidth
var path = Path()
path.move(to: CGPoint(x: x, y: 0))
path.addLine(to: CGPoint(x: x, y: size.height))
ctx.stroke(path, with: .color(.accentColor.opacity(0.7)), lineWidth: 2)
}
private func yFor(string: Int) -> CGFloat {
20 + CGFloat(string) * stringSpacing
}
}
#Preview {
let tab = Tab(title: "Smoke on the Water", artist: "Deep Purple", tempo: 112)
let measure = Measure(beatsPerBar: 4, notes: [
Note(string: 3, fret: 0, beat: 0, duration: 1),
Note(string: 3, fret: 3, beat: 1, duration: 1),
Note(string: 3, fret: 5, beat: 2, duration: 1),
])
tab.measures = [measure]
return TabCanvasView(tab: tab, player: TabPlayerModel(tab: tab))
.padding()
}
5. PDF tab import with PDFKit
Many guitarists download scanned tab PDFs from Ultimate Guitar or Musicnotes. Wrap PDFView in a UIViewRepresentable so they display natively, with pinch-to-zoom and page navigation that PDFKit provides for free.
// Views/PDFTabView.swift
import SwiftUI
import PDFKit
struct PDFTabView: UIViewRepresentable {
let data: Data
func makeUIView(context: Context) -> PDFView {
let view = PDFView()
view.autoScales = true
view.displayMode = .singlePageContinuous
view.displayDirection = .vertical
view.document = PDFDocument(data: data)
view.backgroundColor = UIColor.systemBackground
return view
}
func updateUIView(_ uiView: PDFView, context: Context) {
if let existingDoc = uiView.document,
let newDoc = PDFDocument(data: data),
existingDoc.dataRepresentation() != newDoc.dataRepresentation() {
uiView.document = newDoc
}
}
}
// Views/TabDetailView.swift
struct TabDetailView: View {
let tab: Tab
@State private var player: TabPlayerModel
init(tab: Tab) {
self.tab = tab
_player = State(wrappedValue: TabPlayerModel(tab: tab))
}
var body: some View {
Group {
if tab.isPDF, let data = tab.pdfData {
PDFTabView(data: data)
} else {
TabCanvasView(tab: tab, player: player)
}
}
.navigationTitle(tab.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar { TempoToolbar(player: player) }
}
}
#Preview {
NavigationStack {
TabDetailView(tab: Tab(title: "Preview Tab"))
}
}
6. Playback cursor and tempo control
Drive a scrolling beat cursor using a Timer publisher connected to an @Observable model. Users can set BPM with a stepper or use tap-tempo — double-tap the toolbar button to let the model compute average interval.
// ViewModels/TabPlayerModel.swift
import SwiftUI
import Combine
@Observable
final class TabPlayerModel {
var isPlaying = false
var cursorBeat: CGFloat = 0
var bpm: Int
private let tab: Tab
private var cancellable: AnyCancellable?
private var tapTimes: [Date] = []
init(tab: Tab) {
self.tab = tab
self.bpm = tab.tempo
}
var totalBeats: CGFloat {
CGFloat(tab.measures.reduce(0) { $0 + $1.beatsPerBar })
}
func togglePlayback() {
isPlaying ? pause() : play()
}
func play() {
isPlaying = true
let interval = 60.0 / Double(bpm)
cancellable = Timer.publish(every: interval, on: .main, in: .common)
.autoconnect()
.sink { [weak self] _ in
guard let self else { return }
cursorBeat += 1
if cursorBeat >= totalBeats { cursorBeat = 0 }
}
}
func pause() {
isPlaying = false
cancellable = nil
}
func tapTempo() {
let now = Date()
tapTimes.append(now)
tapTimes = tapTimes.filter { now.timeIntervalSince($0) < 4 }
guard tapTimes.count >= 2 else { return }
let intervals = zip(tapTimes, tapTimes.dropFirst()).map { $1.timeIntervalSince($0) }
let avg = intervals.reduce(0, +) / Double(intervals.count)
bpm = max(40, min(300, Int(60.0 / avg)))
if isPlaying { pause(); play() }
}
}
// Views/TempoToolbar.swift
struct TempoToolbar: ToolbarContent {
@Bindable var player: TabPlayerModel
var body: some ToolbarContent {
ToolbarItemGroup(placement: .bottomBar) {
Button(player.isPlaying ? "Pause" : "Play",
systemImage: player.isPlaying ? "pause.fill" : "play.fill") {
player.togglePlayback()
}
Spacer()
Stepper("\(player.bpm) BPM", value: $player.bpm, in: 40...300, step: 2)
.fixedSize()
Spacer()
Button("Tap", systemImage: "hand.tap") {
player.tapTempo()
}
}
}
}
7. Privacy Manifest
Apple requires a PrivacyInfo.xcprivacy file in every app submitted to the App Store. Without it, App Store Connect will reject the upload. Declare your actual data usage — for a local-only tab viewer with no analytics SDK, this is minimal.
<!-- PrivacyInfo.xcprivacy — add to your app target in Xcode -->
<?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>
<!-- If you add StoreKit for subscriptions, also declare:
NSPrivacyAccessedAPICategoryUserDefaults → CA92.1 -->
Common pitfalls
- Canvas redraws thrashing the main thread. If you call
withAnimationon every beat tick the Canvas re-evaluates its entire closure each frame. Store only aCGFloat cursorBeatin your@Observablemodel and keep all expensive layout math in a cached struct computed outside the Canvas closure. - SwiftData relationship orphans. Deleting a
TabwithoutdeleteRule: .cascadeon itsmeasuresrelationship leaves orphanedMeasurerows that bloat the store. Always set the delete rule explicitly — the default is.nullify. - PDF security-scoped resources not released. When importing a file via
fileImporter, you must callurl.stopAccessingSecurityScopedResource()in adeferblock. Forgetting this causes the app to accumulate sandboxing grants and eventually receive anEPERMerror mid-session. - App Store rejection for missing purpose strings. If you later add a microphone (for tuner or tap-tempo via audio), you must add
NSMicrophoneUsageDescriptionto Info.plist. App Store review consistently rejects builds where the key is missing even if the permission dialog never appears during review. - Horizontal scroll and safe area on iPad. Using
ScrollView(.horizontal)inside aNavigationSplitViewdetail column clips content at the trailing edge in landscape on iPad. Wrap the Canvas in.ignoresSafeArea(.container, edges: .horizontal)and test with Stage Manager enabled.
Adding monetization: Subscription
Implement a subscription with StoreKit 2's Product.products(for:) API. Define one or two subscription groups in App Store Connect — for example "Pro Monthly" at $2.99 and "Pro Annual" at $19.99 — then use Transaction.currentEntitlements to gate premium features like unlimited tab imports, PDF annotation, and iCloud sync. Present the paywall from a Sheet triggered when a free-tier user hits the tab limit (e.g. 10 tabs). Use StoreView(ids:) (available iOS 17+) to get a pre-built, App Store-compliant purchase UI in under 20 lines of SwiftUI. Make sure your subscription group has a clear localised description and that your screenshot set includes the paywall — reviewers check that the subscription terms and cancellation policy are clearly stated.
Shipping this faster with Soarias
Soarias generates the full Xcode project scaffold — PrivacyInfo.xcprivacy, SwiftData model stubs, NavigationSplitView skeleton, and the fileImporter + security-scoped-resource boilerplate — from a single prompt. It also wires up fastlane lanes for screenshot capture, build signing, and App Store Connect submission, so you never touch Keychain manually or hand-write a Deliverfile. The StoreKit 2 subscription integration (product IDs, entitlement check, paywall sheet) is generated and connected to your model in the scaffold step rather than bolted on at the end.
For an intermediate-complexity app like this Guitar Tab Viewer, the typical from-scratch timeline is five to seven days of part-time coding. With Soarias handling scaffolding, Privacy Manifest, fastlane config, and ASC metadata upload, most developers hit TestFlight by day two and submit to the App Store by day four — cutting roughly half the calendar time and almost all of the DevOps setup work.
Related guides
FAQ
Does this work on iOS 16?
The Canvas renderer and fileImporter modifier work on iOS 16, but SwiftData requires iOS 17. If you need iOS 16 support, replace SwiftData with Core Data or a JSON file store. The #Preview macro also requires Xcode 15+ targeting iOS 17+. Dropping to iOS 16 also means giving up @Observable — use ObservableObject with @Published instead.
Do I need a paid Apple Developer account to test?
You can run the app on a personal device with a free Apple ID, but you cannot test StoreKit in-app purchases on a free account (they always return a "StoreKit unavailable" error in the Simulator without the sandbox entitlement). For any subscription or IAP testing, you need the $99/year Apple Developer Program and a StoreKit Configuration file or sandbox tester account.
How do I add this to the App Store?
Create an app record in App Store Connect, upload a build via Xcode Organizer or xcrun altool, fill in metadata (name, subtitle, description, keywords, screenshots for iPhone 6.9" and iPad 13"), set your price, attach subscription products, and submit for review. First-time submissions typically take two to five business days for review. Make sure your PrivacyInfo.xcprivacy is present and your subscription terms link is live before submitting.
How do I keep the Canvas renderer fast for very long songs?
For songs with 100+ measures the Canvas closure can get slow if you iterate every note on every frame. Clip rendering to the visible scroll region: read the ScrollView's offset with a PreferenceKey, compute which beat columns are on-screen, and skip drawing notes outside that window. This keeps the draw call count constant regardless of song length and maintains 60 fps even on older devices like iPhone 12.
Last reviewed: 2026-05-12 by the Soarias team.