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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift/SwiftUI knowledge — familiarity with
@State,VStack, andForEachis enough - A real iPhone or iPad for final testing — kids' touch targets must be verified on physical hardware, not only Simulator
- Google AdMob account (free) if you plan to implement the ad-supported model described in the monetization section
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
- COPPA violation → App Store rejection: Any app in the "Made for Kids" age bracket that shows behaviorally targeted ads will be rejected. If your primary audience is under 13, you must call
GADMobileAds.sharedInstance().requestConfiguration.tagForChildDirectedTreatment = truebefore starting the SDK. This limits ad fill but is non-negotiable for App Store approval. - Mixing
@Observablewith@Published: They are incompatible on the same class. The@Observablemacro rewrites stored-property access at compile time; adding@Publishedcauses a compiler error. Pick one — for iOS 17+ targets always use@Observable. - Missing
NSUserTrackingUsageDescription: Omitting this key fromInfo.plistwhile AdMob is linked causes a crash when the ATT system prompt fires on iOS 17. Automated App Review also flags this during binary analysis. - Testing only in Simulator: Kids have imprecise touch targeting. The recommended minimum interactive size is 44×44 pt. Run on the smallest supported physical device (iPhone SE 3rd gen) before submission.
- Staggered
DispatchQueue.main.asyncAftershake chains: Calling multipleasyncAfterblocks for a shake animation can accumulate on the main queue and skip frames mid-quiz. UsewithAnimation(.interpolatingSpring(...).delay(n))chaining instead, as shown inanimateShake()above — it's frame-budget-safe.
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.