How to Build a Multiplication Table App in SwiftUI

A Multiplication Table app lets kids drill their times tables through a tap-to-reveal grid and an animated quiz mode with instant correct/wrong feedback. It's a fast build for solo indie developers targeting the kids' education category — one of the few App Store niches where a polished beginner app can earn steady ad revenue.

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

Prerequisites

Architecture overview

The app has a thin single-class model layer: TimesTableModel (marked @Observable) owns all quiz state — which table is selected, what the current multiplier is, the running score, and the streak. Two full-screen views, TableBrowserView and QuizView, live inside a TabView and each instantiate the model locally via @State. There is no network layer and no database — everything is transient in-memory state, which keeps this firmly beginner territory. A BannerAdView wraps GADBannerView via UIViewRepresentable for the ad revenue path.

MultiplicationTable/
├── MultiplicationTableApp.swift   ← @main entry point
├── ContentView.swift              ← TabView shell (Tables + Quiz tabs)
├── Models/
│   └── TimesTableModel.swift      ← @Observable — all quiz state lives here
├── Views/
│   ├── TableBrowserView.swift     ← Segmented picker + 3-col LazyVGrid
│   ├── TableCell.swift            ← Tap-to-reveal cell with spring animation
│   └── QuizView.swift             ← Animated Q&A with bounce + shake
├── Components/
│   └── BannerAdView.swift         ← UIViewRepresentable wrapping GADBannerView
└── PrivacyInfo.xcprivacy          ← Required for App Store (AdMob tracking)
      

Step-by-step

1. Create the Xcode project

In Xcode 16, choose File → New → Project → iOS → App. Set the interface to SwiftUI, language to Swift, and minimum deployment target to iOS 17.0. The TabView shell in ContentView is all the navigation structure this app needs.

// MultiplicationTableApp.swift
import SwiftUI

@main
struct MultiplicationTableApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

// ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        TabView {
            NavigationStack {
                TableBrowserView()
            }
            .tabItem { Label("Tables", systemImage: "tablecells") }

            NavigationStack {
                QuizView()
            }
            .tabItem { Label("Quiz", systemImage: "questionmark.circle.fill") }
        }
    }
}

#Preview { ContentView() }

2. Build the @Observable data model

Use the @Observable macro (iOS 17+) so SwiftUI automatically tracks only the properties each view reads — no @Published or objectWillChange needed. Each view that hosts this model creates it with @State private var model = TimesTableModel().

// Models/TimesTableModel.swift
import Observation

@Observable
final class TimesTableModel {
    var selectedTable: Int = 2
    var currentMultiplier: Int = 1
    var userAnswer: String = ""
    var score: Int = 0
    var streak: Int = 0
    var lastAnswerCorrect: Bool? = nil  // nil = no answer submitted yet

    var correctAnswer: Int { selectedTable * currentMultiplier }

    func submit() {
        guard let typed = Int(userAnswer) else { return }
        let isCorrect = typed == correctAnswer
        lastAnswerCorrect = isCorrect
        if isCorrect {
            score += 1
            streak += 1
        } else {
            streak = 0
        }
        userAnswer = ""
        currentMultiplier = currentMultiplier < 12 ? currentMultiplier + 1 : 1
    }

    func reset() {
        score = 0
        streak = 0
        currentMultiplier = 1
        userAnswer = ""
        lastAnswerCorrect = nil
    }
}

3. Design the times table grid

The browse tab gives kids a reference they can explore without pressure. Each cell starts showing "?" and reveals its answer on tap using contentTransition(.numericText()) — a one-line SwiftUI modifier that animates digits smoothly. The whole grid cross-fades when the selected table changes.

// Views/TableBrowserView.swift
import SwiftUI

struct TableBrowserView: View {
    @State private var selectedTable = 2

    var body: some View {
        VStack(spacing: 0) {
            Picker("Table", selection: $selectedTable) {
                ForEach(1...12, id: \.self) { n in
                    Text("\(n)×").tag(n)
                }
            }
            .pickerStyle(.segmented)
            .padding()

            ScrollView {
                LazyVGrid(
                    columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 3),
                    spacing: 10
                ) {
                    ForEach(1...12, id: \.self) { multiplier in
                        TableCell(a: selectedTable, b: multiplier)
                    }
                }
                .padding(.horizontal)
                .animation(.spring(response: 0.35), value: selectedTable)
            }
        }
        .navigationTitle("Times Tables")
    }
}

struct TableCell: View {
    let a: Int
    let b: Int
    @State private var revealed = false

    var body: some View {
        VStack(spacing: 4) {
            Text("\(a) × \(b)")
                .font(.caption.monospacedDigit())
                .foregroundStyle(.secondary)
            Text(revealed ? "\(a * b)" : "?")
                .font(.title2.bold().monospacedDigit())
                .contentTransition(.numericText())
        }
        .frame(maxWidth: .infinity)
        .padding(.vertical, 14)
        .background(revealed ? Color.blue : Color(.systemGray6))
        .foregroundStyle(revealed ? .white : .primary)
        .clipShape(RoundedRectangle(cornerRadius: 12))
        .scaleEffect(revealed ? 1.06 : 1.0)
        .onTapGesture {
            withAnimation(.spring(response: 0.25, dampingFraction: 0.55)) {
                revealed.toggle()
            }
        }
    }
}

#Preview {
    NavigationStack { TableBrowserView() }
}

4. Add the animated quiz view

This is the core feature. A correct answer plays a spring scale-up on the question card; a wrong answer plays a horizontal shake — both implemented in pure SwiftUI using withAnimation with staggered .delay() calls, no third-party library required.

// Views/QuizView.swift
import SwiftUI

struct QuizView: View {
    @State private var model = TimesTableModel()
    @State private var cardScale: CGFloat = 1.0
    @State private var shakeOffset: CGFloat = 0

    var body: some View {
        VStack(spacing: 28) {
            Picker("Table", selection: $model.selectedTable) {
                ForEach(1...12, id: \.self) { n in Text("\(n)×").tag(n) }
            }
            .pickerStyle(.segmented)
            .padding(.horizontal)
            .onChange(of: model.selectedTable) { _, _ in model.reset() }

            Spacer()

            // Question card
            VStack(spacing: 10) {
                Text("Question \(model.currentMultiplier) of 12")
                    .font(.caption)
                    .foregroundStyle(.secondary)

                Text("\(model.selectedTable) × \(model.currentMultiplier) = ?")
                    .font(.system(size: 48, weight: .bold, design: .rounded))
                    .offset(x: shakeOffset)
                    .scaleEffect(cardScale)

                if let correct = model.lastAnswerCorrect {
                    Image(systemName: correct ? "checkmark.circle.fill" : "xmark.circle.fill")
                        .font(.system(size: 40))
                        .foregroundStyle(correct ? .green : .red)
                        .transition(.scale.combined(with: .opacity))
                }
            }
            .padding(36)
            .frame(maxWidth: .infinity)
            .background(Color(.systemGray6))
            .clipShape(RoundedRectangle(cornerRadius: 24))
            .padding(.horizontal)
            .animation(.default, value: model.lastAnswerCorrect)

            // Score row
            HStack(spacing: 48) {
                VStack(spacing: 2) {
                    Text("\(model.score)").font(.title.bold())
                    Text("Score").font(.caption).foregroundStyle(.secondary)
                }
                VStack(spacing: 2) {
                    Text("\(model.streak)")
                        .font(.title.bold())
                        .foregroundStyle(.orange)
                    Text("Streak 🔥").font(.caption).foregroundStyle(.secondary)
                }
            }

            // Answer input row
            HStack(spacing: 12) {
                TextField("= ?", text: $model.userAnswer)
                    .keyboardType(.numberPad)
                    .font(.title2.monospacedDigit())
                    .multilineTextAlignment(.center)
                    .padding()
                    .background(Color(.systemGray6))
                    .clipShape(RoundedRectangle(cornerRadius: 14))

                Button(action: handleSubmit) {
                    Image(systemName: "checkmark.circle.fill")
                        .font(.system(size: 48))
                        .foregroundStyle(model.userAnswer.isEmpty ? .secondary : .blue)
                }
                .disabled(model.userAnswer.isEmpty)
            }
            .padding(.horizontal)

            Spacer()
        }
        .navigationTitle("Quiz")
    }

    private func handleSubmit() {
        let wasCorrect = Int(model.userAnswer) == model.correctAnswer
        model.submit()

        if wasCorrect {
            withAnimation(.spring(response: 0.2, dampingFraction: 0.45)) {
                cardScale = 1.14
            }
            withAnimation(.spring(response: 0.2, dampingFraction: 0.45).delay(0.15)) {
                cardScale = 1.0
            }
        } else {
            animateShake()
        }
    }

    private func animateShake() {
        let s = Animation.interpolatingSpring(stiffness: 500, damping: 10)
        withAnimation(s)             { shakeOffset = -16 }
        withAnimation(s.delay(0.07)) { shakeOffset =  16 }
        withAnimation(s.delay(0.14)) { shakeOffset = -10 }
        withAnimation(s.delay(0.21)) { shakeOffset =   0 }
    }
}

#Preview {
    NavigationStack { QuizView() }
}

5. Add the Privacy Manifest

Every app submitted to the App Store since May 2024 must include a PrivacyInfo.xcprivacy file. Because AdMob reads the device advertising identifier, you must declare it here. In Xcode, choose File → New File → Resource → App Privacy. Paste the XML below, then add NSUserTrackingUsageDescription to your Info.plist.

<!-- PrivacyInfo.xcprivacy -->
<?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>NSPrivacyAccessedAPITypes</key>
  <array/>

  <key>NSPrivacyTracking</key>
  <true/>

  <key>NSPrivacyTrackingDomains</key>
  <array>
    <string>googleadservices.com</string>
    <string>googlesyndication.com</string>
    <string>doubleclick.net</string>
  </array>

  <key>NSPrivacyCollectedDataTypes</key>
  <array>
    <dict>
      <key>NSPrivacyCollectedDataType</key>
      <string>NSPrivacyCollectedDataTypeDeviceID</string>
      <key>NSPrivacyCollectedDataTypeLinked</key>
      <false/>
      <key>NSPrivacyCollectedDataTypeTracking</key>
      <true/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array>
        <string>NSPrivacyCollectedDataTypePurposeThirdPartyAdvertising</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

<!-- Also add to Info.plist: -->
<key>NSUserTrackingUsageDescription</key>
<string>We show ads to keep this app free. Allow tracking for relevant ads.</string>

Common pitfalls

Adding monetization: Ad-supported

Add the GoogleMobileAds Swift Package (https://github.com/googleads/swift-package-manager-google-mobile-ads), set your GADApplicationIdentifier in Info.plist, then create a BannerAdView: UIViewRepresentable that instantiates GADBannerView(adSize: GADAdSizeBanner) and calls banner.load(GADRequest()) in makeUIView. Place the banner at the bottom of each tab inside the TabView. Initialize the SDK once in MultiplicationTableApp.init() with GADMobileAds.sharedInstance().start(completionHandler: nil). If you later want a premium upgrade path, a StoreKit 2 Product.purchase call behind a "Remove Ads" button converts well in education apps targeting parents who buy for their kids — the IAP monetization pattern pairs cleanly with the initial ad-supported model.

Shipping this faster with Soarias

Soarias scaffolds the full project structure above from a single concept description — TimesTableModel, the TabView shell, TableBrowserView, QuizView, and the PrivacyInfo.xcprivacy with the correct AdMob tracking domains already declared. Fastlane lanes for App Store screenshots across all required device sizes (iPhone 6.9", 6.5", and iPad 13" if you target it), App Store Connect metadata upload, and the TestFlight build pipeline are wired automatically — no manual Organizer trips or screenshot session setup.

For a beginner-complexity app like this, the typical time sink is not the SwiftUI code itself (which takes a weekend) but the surrounding scaffolding: Privacy Manifest research, AdMob SDK integration, fastlane configuration, and the first round of App Store Connect metadata. Soarias compresses that overhead to under an hour, leaving the full 1–2 weekends for the parts that actually matter: tuning the spring animations, adding haptic feedback, and testing with real kids.

Related guides

FAQ

Does this work on iOS 16?

No — the hard floor is iOS 17. The @Observable macro requires iOS 17, and the #Preview macro requires Xcode 15 with an iOS 17 target. If you need iOS 16 support, swap @Observable for class TimesTableModel: ObservableObject with @Published properties, replace each @State private var model = TimesTableModel() with @StateObject private var model = TimesTableModel(), and replace #Preview blocks with struct X_Previews: PreviewProvider. Everything else in the guide compiles on iOS 16.

Do I need a paid Apple Developer account to test?

No for Simulator — you can build and run for free. You need the $99/year Apple Developer Program membership to install on a physical device, distribute via TestFlight, or submit to the App Store. For this app specifically, a real device is strongly recommended before submission because touch target sizes and animation frame rates look different from Simulator.

How do I add this to the App Store?

Archive the app in Xcode (Product → Archive), upload via Organizer, then complete your App Store Connect listing: screenshots for iPhone 6.9" and 6.5" (required), an app description, a privacy policy URL (required if AdMob is included — you're collecting device identifiers), and the age-rating questionnaire. Set the primary category to Education and the secondary to Games for maximum discoverability. First-time submissions typically take 1–3 business days for review.

My shake animation works in Simulator but stutters on device — why?

The most common cause on beginner projects is a cascade of DispatchQueue.main.asyncAfter calls that accumulate between quiz submissions and then fire all at once. The fix is the staggered withAnimation(.interpolatingSpring(...).delay(n)) pattern shown in animateShake() — SwiftUI batches these into a single render pass rather than queuing separate closures. Also check that no @Observable property is being mutated from a background thread; all model writes must happen on the main actor.

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