Manga Kotoba, Part 5: SwiftUI Polish, StoreKit Paywall, and Shipping to the App Store

Five parts, seven months, one shipped app. This is the finale of the Manga Kotoba series — the one where we stop building and start shipping. We'll polish the SwiftUI layer, wire up StoreKit 2 for subscriptions, fight our way through App Store review, and reflect on what it all meant.

--- ## 1. Where We Left Off
Series recap
  • Part 1 — Architecture decisions: SwiftUI + Symfony 7 + API Platform. The vocabulary extraction pipeline for manga pages.
  • Part 2 — Building the API: JWT auth, resource classes, custom state processors, OpenAPI docs.
  • Part 3 — SwiftUI foundations: NavigationStack, async data fetching, the first VocabCard component.
  • Part 4 — Production backend on Heroku + RDS, PostgreSQL migrations, staging/production environments, the vocabulary spaced-repetition engine (SM-2).
  • Part 5 (this post) — UI polish, StoreKit 2 paywall, onboarding, App Store submission and all the ways it went wrong.
The app was working at the end of Part 4 — you could sign in, browse manga, study vocabulary cards with spaced repetition. But "working" and "App Store ready" are not the same thing. Missing: monetisation, a real onboarding flow, polished animations, and the courage to actually submit. --- ## 2. SwiftUI Component Gallery {: class="marginalia" } SwiftUI previews in Xcode 15+ are dramatically faster than the old simulator-based ones. I ran the canvas at 1× on a Mac Studio and got near-instant refreshes for most components. Below is an interactive mock of the four main screens. Click the tab bar icons to switch. This runs entirely in the browser to give you a feel for the component architecture.
N3
仲間
なかま (nakama)
Companion; comrade; fellow
"俺の仲間を傷つけるな!"
← skip know →
Click card to simulate swipe
My Manga
🗡️
Demon Slayer
23 chapters
1,204 words
🍥
Naruto
72 chapters
3,891 words
⚔️
One Piece
108 chapters
5,120 words
🔒
Unlock Pro
+200 manga
Go Pro
Today's Progress
15 / 20 words
Daily goal: 20 words
Click to simulate studying a card
Settings
Dark Mode
Daily Notifications
Romaji hints
Sound effects
Manga Kotoba v2.1.0
🃏
Study
📚
Manga
📊
Progress
⚙️
Settings
--- ## 3. The Swipe-to-Know Gesture Getting swipe gestures right in SwiftUI took three iterations. ### Attempt 1: onTapGesture
// ❌ Wrong — no directional info
VocabCardView(card: card)
    .onTapGesture {
        // We know it was tapped, but left or right?
        viewModel.markKnown(card)
    }
Obvious in hindsight. `onTapGesture` gives you a tap location, but we need direction. ### Attempt 2: DragGesture with .onEnded
// ⚠️ Better, but no real-time feedback
VocabCardView(card: card)
    .gesture(
        DragGesture()
            .onEnded { value in
                if value.translation.width > 50 {
                    viewModel.markKnown(card)
                } else if value.translation.width < -50 {
                    viewModel.markSkipped(card)
                }
            }
    )
Works, but the card doesn't visually tilt while dragging. Feels unresponsive. ### Final: DragGesture with .onChanged + .onEnded {: class="marginalia" } The `.onChanged` closure fires every time the finger moves. Combining it with `withAnimation` keeps SwiftUI's layout engine happy and avoids dropped frames. On a real device this stays at 120 fps.
struct SwipeableCard: View {
    let card: VocabCard
    var onSwipe: (SwipeDirection) -> Void

    @State private var dragOffset: CGSize = .zero
    @State private var isDragging = false

    private var rotation: Double {
        Double(dragOffset.width / 20)
    }
    private var swipeIndicator: SwipeDirection? {
        if dragOffset.width > 60 { return .right }
        if dragOffset.width < -60 { return .left }
        return nil
    }

    var body: some View {
        ZStack {
            cardContent
            if let dir = swipeIndicator {
                SwipeLabel(direction: dir)
                    .transition(.opacity)
            }
        }
        .rotationEffect(.degrees(rotation))
        .offset(dragOffset)
        .gesture(
            DragGesture()
                .onChanged { value in
                    withAnimation(.interactiveSpring()) {
                        dragOffset = value.translation
                        isDragging = true
                    }
                }
                .onEnded { value in
                    let threshold: CGFloat = 100
                    if value.translation.width > threshold {
                        flyOut(to: .right)
                    } else if value.translation.width < -threshold {
                        flyOut(to: .left)
                    } else {
                        withAnimation(.spring()) { dragOffset = .zero }
                    }
                    isDragging = false
                }
        )
    }

    private func flyOut(to direction: SwipeDirection) {
        let xTarget: CGFloat = direction == .right ? 500 : -500
        withAnimation(.easeIn(duration: 0.25)) {
            dragOffset = CGSize(width: xTarget, height: 0)
        }
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            onSwipe(direction)
        }
    }
}
### Interactive Drag Demo Drag the card below left or right (or click the buttons):
勇気
ゆうき (yūki)
Courage; bravery
"勇気を出して!" — Be brave!
Drag left (skip) or right (know)
--- ## 4. StoreKit 2 Paywall {: class="marginalia" } StoreKit 2 was a complete rewrite released with iOS 15. The old StoreKit required parsing XML receipts and doing server-side OpenSSL verification. StoreKit 2 uses JWS tokens and async/await — it's dramatically simpler. The freemium model: **3 manga free, unlimited with Pro** ($2.99/month or $19.99/year). ### Fetching Products
actor StoreService {
    static let productIDs = [
        "com.mangakotoba.pro.monthly",
        "com.mangakotoba.pro.yearly"
    ]

    private(set) var products: [Product] = []

    func loadProducts() async throws {
        products = try await Product.products(for: Self.productIDs)
    }
}
### Purchasing and Handling Result
func purchase(_ product: Product) async {
    do {
        let result = try await product.purchase()
        switch result {
        case .success(let verification):
            switch verification {
            case .verified(let transaction):
                await transaction.finish()
                await updateProStatus()
            case .unverified:
                // JWS signature check failed — treat as no purchase
                break
            }
        case .userCancelled, .pending:
            break
        @unknown default:
            break
        }
    } catch {
        print("Purchase error: " + error.localizedDescription)
    }
}
### Checking Active Entitlements
func updateProStatus() async {
    var isActive = false
    for await result in Transaction.currentEntitlements {
        if case .verified(let tx) = result,
           tx.productType == .autoRenewableSubscription {
            isActive = true
        }
    }
    await MainActor.run { isPro = isActive }
}
No more OpenSSL. No more base64-decoding receipt blobs. The entire entitlement check is four lines of async Swift. ### Interactive Paywall Demo
Unlock Manga Kotoba Pro
Unlimited manga · Offline mode · Advanced stats
Monthly
$2.99
per month
Annual
$19.99
per year
Save 44%
Restore Purchases
⏳ Purchasing…
🎉
You're Pro!
Enjoy unlimited manga vocabulary.
--- ## 5. Onboarding Flow {: class="marginalia" } Apple's review team has gotten faster — average review time in 2025 is under 24 hours for most apps. But the first submission of a new app often takes 2-3 days as a human reviewer looks at it more carefully. First-launch onboarding shows exactly once and then is dismissed forever. Five screens.
🇯🇵
Welcome to Manga Kotoba
Learn Japanese vocabulary through the manga you already love. Real words, real context, real retention.
5 quick steps
Pick your manga
Choose 3 titles to start with. You can add more later.
⚔️
One Piece
🗡️
Demon Slayer
🍥
Naruto
💥
My Hero Academia
🏋️
Berserk
🎓
Spy x Family
Daily goal
How many words per day? Consistency beats intensity.
10
words per day
510152030
🔔
Stay on track
Enable daily reminders to keep your streak alive. We'll only ping you once a day.
🚀
You're ready!
Your journey to reading manga in Japanese starts now.
The SwiftUI implementation uses a `PageTabViewStyle` inside a `TabView`, which gives us the swipe-between-pages animation for free:
TabView(selection: $onboardingPage) {
    WelcomeView().tag(0)
    MangaPickerView().tag(1)
    DailyGoalView().tag(2)
    NotificationsView().tag(3)
    ReadyView().tag(4)
}
.tabViewStyle(.page(indexDisplayMode: .never))
.fullScreenCover(isPresented: $showOnboarding) { ... }
The `showOnboarding` flag is stored in `@AppStorage("hasCompletedOnboarding")` — it defaults to `false` on first launch and is set to `true` when the user taps "Start". --- ## 6. App Store Submission Horror Stories The app was ready in early April. Submission was… not clean. ### Rejection 1: Missing NSCameraUsageDescription
Rejection reason: "This app requires access to the camera. Please provide a usage description in your Info.plist."

The problem: We don't use the camera. But our Symfony API was serving an image picker component that referenced AVFoundation indirectly through a library. The iOS app's Swift Package dependency graph pulled in a package that declared a camera capability in its own Info.plist fragment, which Apple's binary analysis flagged.

The fix: Added NSCameraUsageDescription with "This app does not use the camera. This key is required by an indirect dependency." Resubmitted. Approved.
### Rejection 2: "App Does Not Function As Advertised"
Rejection reason: "We were unable to sign in with the credentials provided. The app appears to require a valid account to access its core functionality."

The problem: We'd been developing against staging (api-staging.mangakotoba.io) and shipped the production binary (api.mangakotoba.io). The test account credentials in the submission notes were for the staging environment. The reviewer tried to log in to production with a staging-only account. It failed.

The fix: Created a dedicated App Review account on production, updated submission notes with the new credentials, triple-checked. Resubmitted.
### The 3-Day Human Review Wait After automated checks passed (usually ~30 minutes), we sat in "Waiting for Review" for 68 hours before a human looked at it. This is normal for first-time app submissions. After that initial friction, subsequent updates reviewed in under 8 hours. ### Rejection 3: Screenshot Metadata Mismatch
Rejection reason (metadata, not binary): "Your screenshots do not accurately represent the app's current UI."

The screenshots were from a design mockup created two sprints before the final UI. The paywall screen in the screenshots had a "Start 7-day Free Trial" button; the actual app shipped with "Start Free Trial" (we removed the 7-day duration wording after legal review). One reviewer noticed.

The fix: Re-shot all screenshots with Xcode Simulator on iPhone 15 Pro, uploaded, resubmitted metadata only (no binary needed).
### Lessons Learned - **Test App Review accounts on production before submitting.** This is obvious in retrospect. - **Audit your dependency tree** for privacy manifest keys. Even if *your* code doesn't use a capability, your dependencies might declare one. - **Screenshots should be taken from the final binary**, not mockups. Automate this if you can (Fastlane's `snapshot` tool). - **Metadata changes don't require binary resubmission**, but they still go through review. Budget time. - **Keep an App Store rejection log.** It becomes an invaluable reference for the next app. --- ## 7. "Would I Do It Again?" Retrospective {: class="marginalia" } The App Store takes a 30% cut (15% for small developers under $1M/year revenue via the Small Business Program). For a $2.99/month subscription, you net ~$2.09 after Apple's cut.
Part 1 — Architecture
🏗️ Chose SwiftUI + Symfony 7 + API Platform. Built the vocabulary extraction pipeline.
Part 2 — API
🔌 JWT auth, API Platform resources, custom state processors, OpenAPI docs.
Part 3 — SwiftUI Core
📱 NavigationStack, async data layer, first VocabCard. The app became real.
Part 4 — Production
🚀 Heroku + RDS, staging environment, SM-2 spaced repetition engine.
Part 5 — Shipped
🎉 StoreKit 2, onboarding, polish, 3 rejections, and finally: live on the App Store.
How do I feel about building this?
😩 Never again
🚀 Absolutely
Worth it — but I'd do it differently. The Symfony backend with API Platform was the right call: the OpenAPI docs alone saved me hours of debugging the Swift HTTP layer. I'd keep it. For the iOS side, I'm proud of the SwiftUI architecture, but I underestimated how much Apple-specific ceremony (provisioning, App Store Connect, review) would dominate the last 20% of development time. Next time I'd budget two weeks just for that.
### What I'm Proud Of - **The vocabulary extraction pipeline** — parsing manga pages, normalising Japanese text, linking to JLPT word lists, and surfacing the most useful vocabulary per chapter. This is the product's actual moat. - **The SwiftUI architecture** — clean separation between `View`, `ViewModel`, and the async `APIClient`. Easy to test, easy to extend. - **Actually shipping.** 95% of side projects don't make it to the App Store. This one did. ### What I'd Change - **Earlier monetisation thinking.** StoreKit integration came in at the very end. It would have been easier to design the feature gates earlier in the codebase. - **Expo/React Native instead of SwiftUI?** Considered it. Decided against it for the "real native" experience. I don't regret it, but for a solo developer who needs Android too, RN is worth the trade-off. - **Keep Symfony?** Yes, without question. The API Platform overhead is real — writing resource classes and state processors feels verbose at first — but the auto-generated OpenAPI docs and the Symfony ecosystem (Messenger, Doctrine, the security system) were worth every line. --- ## 8. What's Next
  • Android version — The backend is already there. The question is native Kotlin vs React Native. Leaning React Native for speed.
  • More manga — The current pipeline requires manual QA per manga. A self-serve submission form (or a web scraping pipeline with human review) would scale this to hundreds of titles.
  • Community features — Shared word lists, user annotations, "this word appeared in chapter 47 of X" context links.
  • Open-sourcing the backend — The vocabulary engine and API layer are reasonably generic. Considering it under MIT once the business model is validated.
  • Sentence mining mode — Anki-style sentence cards generated automatically from manga panels, with audio via a TTS API.
---

Thanks for following along through all five parts. If you built something with this series, I'd love to hear about it — @malukenho on Twitter/X.