A Document Scanner in an Afternoon: VisionKit's Camera, Core Image Clean-Up and AirPrint


Originally published at bestlatestapps.com/blog/a-docu…

Apple's document camera on iOS 13+, a Vision rectangle-detection fallback for older devices, enhancing scans with Core Image, saving locally, sharing PNGs and printing

Scan Via Cam app icon

Document scanning used to be a serious computer-vision project: find the page's edges, correct the perspective, flatten the lighting, sharpen the text. Today, most of that is a system view controller, but there's still plenty to get right around it. That includes older devices, enhancement that doesn't destroy a receipt's faint print, storage that never leaves the phone, and sharing and printing that just work.

Scan Via Cam is my take on the simple version: point the camera at a receipt, note, invoice, whiteboard, business card or certificate, and get "clear & sharp" images you can share as PNGs or print over AirPrint. Its listing adds two promises I care about: "Documents are saved on your phone, and we do not save them on our server", and "No Signup, No Credit Card".

This article covers how I'd build that on iOS: VNDocumentCameraViewController for capture, a fallback for the app's iOS 12 minimum, Core Image enhancement, local storage, sharing and AirPrint. API names and availability are checked against Apple's docs. As always, this is the approach I'd use, not a line-by-line tour of the app.


1. The scanner pipeline

capture ──▶ detect edges ──▶ perspective-correct ──▶ enhance ──▶ save (PNG) ──▶ share / print
   │                 └──── VNDocumentCameraViewController does these on iOS 13+ ────┘
   └── iOS 12 fallback: camera photo → VNDetectRectanglesRequest → CIPerspectiveCorrection

On iOS 13 and later, Apple's document camera handles capture, edge detection, auto-capture and perspective correction. The app supports iOS 12.0 and later, so it also needs a path for devices that can't use it. Both paths end up with plain UIImages, and everything after that is shared.

2. Capture with VNDocumentCameraViewController (iOS 13+)


VNDocumentCameraViewController (VisionKit, iOS 13+) presents the full scanning UI: live edge highlighting, automatic or manual capture, multi-page scans and per-page crop adjustment. You get back a VNDocumentCameraScan with one corrected image per page.

import UIKit
import VisionKit

@available(iOS 13.0, *)
final class DocumentCameraCoordinator: NSObject, VNDocumentCameraViewControllerDelegate {
    var onPages: (([UIImage]) -> Void)?

    static var isSupported: Bool { VNDocumentCameraViewController.isSupported }

    func makeController() -> VNDocumentCameraViewController {
        let controller = VNDocumentCameraViewController()
        controller.delegate = self
        return controller
    }

    func documentCameraViewController(_ controller: VNDocumentCameraViewController,
                                      didFinishWith scan: VNDocumentCameraScan) {
        let pages = (0..<scan.pageCount).map { scan.imageOfPage(at: $0) }
        controller.dismiss(animated: true) { self.onPages?(pages) }
    }

    func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
        controller.dismiss(animated: true)
    }

    func documentCameraViewController(_ controller: VNDocumentCameraViewController,
                                      didFailWithError error: Error) {
        controller.dismiss(animated: true)
        print("Scan failed: \(error)")
    }
}

Notes:
  • Camera permission is still yours to declare. Add NSCameraUsageDescription, for example "Scan Via Cam uses the camera to scan your documents." The system shows the prompt the first time the scanner opens.
  • Check isSupported before offering the button. If it's false, use the fallback below.
  • The scanner's images are already cropped and flattened, so don't run your own perspective correction on them again.

3. The iOS 12 fallback: Vision rectangles and perspective correction


Vision's VNDetectRectanglesRequest has been around since iOS 11. It finds quadrilaterals in an image, which you can then flatten with Core Image's perspective-correction filter. Take the photo with UIImagePickerController, then:

import UIKit
import Vision
import CoreImage

enum ManualDocumentDetector {

    /// Finds the most prominent page-like rectangle and returns a flattened crop.
    static func flatten(_ image: UIImage, completion: @escaping (UIImage?) -> Void) {
        guard let cgImage = image.cgImage else { return completion(nil) }
        let orientation = CGImagePropertyOrientation(image.imageOrientation)

        let request = VNDetectRectanglesRequest { request, _ in
            guard let rect = (request.results as? [VNRectangleObservation])?.first else {
                return DispatchQueue.main.async { completion(image) }   // no page found: keep original
            }
            let output = correct(CIImage(cgImage: cgImage).oriented(orientation), using: rect)
            DispatchQueue.main.async { completion(output) }
        }
        request.minimumAspectRatio = 0.3          // receipts are tall and thin
        request.maximumAspectRatio = 1.0
        request.minimumSize = 0.3                 // page should fill a fair part of the frame
        request.minimumConfidence = 0.6
        request.maximumObservations = 1

        DispatchQueue.global(qos: .userInitiated).async {
            try? VNImageRequestHandler(cgImage: cgImage, orientation: orientation).perform([request])
        }
    }

    private static func correct(_ image: CIImage, using r: VNRectangleObservation) -> UIImage? {
        // Vision returns normalised coordinates (0...1, origin bottom-left); scale to pixels.
        let size = image.extent.size
        func point(_ p: CGPoint) -> CIVector { CIVector(x: p.x * size.width, y: p.y * size.height) }

        guard let filter = CIFilter(name: "CIPerspectiveCorrection") else { return nil }
        filter.setValue(image, forKey: kCIInputImageKey)
        filter.setValue(point(r.topLeft), forKey: "inputTopLeft")
        filter.setValue(point(r.topRight), forKey: "inputTopRight")
        filter.setValue(point(r.bottomLeft), forKey: "inputBottomLeft")
        filter.setValue(point(r.bottomRight), forKey: "inputBottomRight")

        guard let output = filter.outputImage,
              let cg = CIContext().createCGImage(output, from: output.extent) else { return nil }
        return UIImage(cgImage: cg)
    }
}

extension CGImagePropertyOrientation {
    init(_ o: UIImage.Orientation) {
        switch o {
        case .up: self = .up; case .down: self = .down; case .left: self = .left; case .right: self = .right
        case .upMirrored: self = .upMirrored; case .downMirrored: self = .downMirrored
        case .leftMirrored: self = .leftMirrored; case .rightMirrored: self = .rightMirrored
        @unknown default: self = .up
        }
    }
}

The string-keyed CIFilter(name:) form is used on purpose. The type-safe CIFilter.perspectiveCorrection() builders are iOS 13+, so on an iOS 12 target the classic API is the one that works everywhere.

Show the detected quadrilateral over the photo and let the user drag the corners before you flatten. Automatic detection fails on low-contrast pages (white paper on a white desk), and a manual adjust step rescues those.


4. "Optimise scan quality": enhancing with Core Image


The listing says "Smart cropping and auto enhancing make the texts and graphics look clear and sharp." Cropping is done by now. For enhancement, Core Image has a filter built for exactly this, CIDocumentEnhancer, which "adjusts an image's shadows and contrast", plus the general-purpose CIColorControls:

import CoreImage
import CoreImage.CIFilterBuiltins
import UIKit

enum ScanEnhancer {
    enum Mode { case original, enhanced, blackAndWhite }

    private static let context = CIContext()          // create once; it's expensive

    static func apply(_ mode: Mode, to image: UIImage) -> UIImage {
        guard mode != .original, let input = CIImage(image: image) else { return image }
        var output = input

        if #available(iOS 13.0, *) {
            let enhancer = CIFilter.documentEnhancer()
            enhancer.inputImage = output
            enhancer.amount = 1.0
            output = enhancer.outputImage ?? output

            if mode == .blackAndWhite {
                let controls = CIFilter.colorControls()
                controls.inputImage = output
                controls.saturation = 0          // greyscale
                controls.contrast = 1.3          // push faint print darker
                output = controls.outputImage ?? output
            }
        } else {
            // iOS 12: string-keyed filters only.
            let controls = CIFilter(name: "CIColorControls")!
            controls.setValue(output, forKey: kCIInputImageKey)
            controls.setValue(mode == .blackAndWhite ? 0 : 1, forKey: kCIInputSaturationKey)
            controls.setValue(1.2, forKey: kCIInputContrastKey)
            output = controls.outputImage ?? output
        }

        guard let cg = context.createCGImage(output, from: input.extent) else { return image }
        return UIImage(cgImage: cg, scale: image.scale, orientation: .up)
    }
}

A few rules I'd follow:
  • Keep the original. Store the unenhanced image and apply the filter for display and export. Users switch modes, and enhancement can't be undone on pixels you've thrown away.
  • Offer three modes, not ten sliders: Original, Enhanced and Black & White. That covers receipts, colourful whiteboards and text-heavy pages.
  • Black & White isn't thresholding. A hard threshold destroys faint thermal-printer receipts. A contrast push on greyscale keeps them readable.
  • One shared CIContext. Creating one per image is the most common Core Image performance mistake.

5. "Saved on your phone": local storage done properly


There's no server and no account, so the files on the device are the product. Store each scan as a folder with the original and its settings:

import UIKit

struct ScanRecord: Codable, Identifiable {
    let id: UUID
    let createdAt: Date
    var title: String
    var pageCount: Int
    var mode: String           // "original", "enhanced", "bw"
}

final class ScanLibrary {
    private let root: URL = {
        let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
        let dir = docs.appendingPathComponent("Scans", isDirectory: true)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir
    }()

    func save(pages: [UIImage], title: String) throws -> ScanRecord {
        let record = ScanRecord(id: UUID(), createdAt: Date(), title: title,
                                pageCount: pages.count, mode: "enhanced")
        let folder = root.appendingPathComponent(record.id.uuidString, isDirectory: true)
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)

        for (i, page) in pages.enumerated() {
            guard let png = page.pngData() else { continue }
            // Encrypted at rest while the device is locked.
            try png.write(to: folder.appendingPathComponent("page-\(i + 1)-original.png"),
                          options: [.atomic, .completeFileProtection])
        }
        try JSONEncoder().encode(record).write(to: folder.appendingPathComponent("record.json"),
                                               options: [.atomic, .completeFileProtection])
        return record
    }
}

Two privacy decisions are worth calling out:
  • .completeFileProtection. Scans are receipts, IDs and certificates, so they should be unreadable while the phone is locked. Unlike a reminder app, a scanner never needs to write in the background, so the strongest protection costs nothing.
  • Think about backups deliberately. Files in Documents are included in the user's own iCloud or computer backup, which is usually what people want for their documents. If you'd rather exclude them, set URLResourceValues.isExcludedFromBackup and say so in the app.

6. Share as PNG, and print with AirPrint


The listing calls out both: "Easily share documents in Png format… via social media, email attachment" and "Instantly print out docs… with nearby printer via AirPrint".

import UIKit

enum ScanOutput {

    /// Share sheet: Mail, Messages, social apps, Files, AirDrop… and Print.
    static func share(_ pages: [UIImage], from source: UIViewController, anchor: UIView) {
        let items: [Any] = pages.compactMap { $0.pngData() }
        let sheet = UIActivityViewController(activityItems: items, applicationActivities: nil)
        sheet.popoverPresentationController?.sourceView = anchor     // required on iPad
        source.present(sheet, animated: true)
    }

    /// Direct AirPrint, for a dedicated Print button.
    static func print(_ pages: [UIImage], jobName: String) {
        guard UIPrintInteractionController.isPrintingAvailable else { return }
        let info = UIPrintInfo(dictionary: nil)
        info.outputType = .grayscale        // documents: cheaper and usually crisper
        info.jobName = jobName

        let controller = UIPrintInteractionController.shared
        controller.printInfo = info
        controller.printingItems = pages    // one image per page
        controller.present(animated: true, completionHandler: nil)
    }
}

Sharing PNG Data rather than UIImage objects means the receiving app gets the lossless file you saved, not a re-compressed JPEG. And popoverPresentationController.sourceView isn't optional on iPad: without it, presenting the share sheet crashes.

Set outputType to .photo for scanned photos or colour certificates. For text documents, .grayscale is the better default.


7. Real-world example: Scan Via Cam


This is the approach I'd use, and recommend, for Scan Via Cam.

Scan Via Cam App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Productivity and Utilities
  • First released in August 2020
  • 6 ratings in the US store, averaging about 4.7 stars
  • Supports iOS 12.0 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Use your phone camera to scan receipts, notes, invoices, whiteboard discussions, business cards, certificates"Document camera on iOS 13+, Vision fallback on iOS 12 (sections 2–3)
"Smart cropping and auto enhancing make the texts and graphics look clear and sharp"Rectangle detection and perspective correction; CIDocumentEnhancer and colour controls (sections 3–4)
"Easily share documents in Png format… via social media, email attachment"UIActivityViewController with PNG data (section 6)
"Instantly print out docs… via AirPrint"UIPrintInteractionController (section 6)
"Documents are saved on your phone, and we do not save them on our server" / "No Signup, No Credit Card"Local folder storage with complete file protection, no account (section 5)


Scan Via Cam App Store screenshot 2

The iOS 12 minimum is the most interesting constraint here. It's why the article carries a Vision-based fallback and string-keyed Core Image filters, while devices on iOS 13 and later get Apple's full document camera.


8. Before you ship a scanner: a checklist


  1. Camera permission has a clear purpose string, and a denied state links to Settings.
  2. VNDocumentCameraViewController.isSupported is checked, and older devices get a working fallback.
  3. Detected corners are adjustable before flattening.
  4. Originals are kept, and enhancement is re-applied, never baked in.
  5. Faint receipts survive Black & White mode. Test with a real thermal-paper receipt.
  6. Files use complete protection, and your backup behaviour is a deliberate choice.
  7. Sharing sends PNG data, and the share sheet has a source view on iPad.
  8. AirPrint is tested on a real printer, in greyscale and colour.
  9. Your privacy claims are literally true. No upload, no analytics on document content, and App Privacy details to match.

Wrapping up


The hardest parts of document scanning are now system features: on iOS 13 and later, VNDocumentCameraViewController gives you capture, edge detection and flattening in a few lines. What's left is product craft: a fallback for older devices, enhancement that respects faint print, private local storage, and output that works with every app and printer the user has.

Try it, or explore my other apps:

What's the hardest document you've had to scan? Glossy certificates under office lights are my nemesis. Tell me yours in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/a-docu…


A Practical Guide to iOS Haptics: UIFeedbackGenerator, SwiftUI sensoryFeedback and Core Haptics with AHAP


Originally published at bestlatestapps.com/blog/a-prac…

The three layers of haptic APIs, when to use each, building custom patterns with intensity and sharpness, AHAP files, looping and speed control, and two deprecations to know about in iOS 27

Haptics Explorer app icon

Haptics are the most under-designed part of most iOS apps. Developers add .impactOccurred() to a button, feel it once on their own phone and move on. But haptics are a vocabulary: a light tick for a selection, a crisp double-tap for success, a heavier, duller buzz for an error. Used well, they make an app feel physical. Used badly, they make it feel noisy.

The trouble is that you can't see a haptic. You can read that .rigid is "sharper" than .soft, but you have to feel the difference. That's why I built Haptics Explorer: a developer tool for browsing, testing and generating code for haptic feedback. Its library spans impacts, notifications, selection, rhythms like heartbeat and tick-tock, gesture and game patterns, plus a custom pattern creator, a sequence editor and AHAP import and export.

This article is the guide that sits behind that app: the three layers of haptic APIs on iOS, how to choose between them, how Core Haptics patterns are built from intensity and sharpness, how AHAP files work, and how to loop and speed up patterns. API names and availability are checked against Apple's documentation, and there are two iOS 27 deprecations worth knowing about.


1. Three layers, three jobs


iOS gives you haptics at three levels of control:

LayerAPIUse it for
System feedback (UIKit)UIImpactFeedbackGenerator, UISelectionFeedbackGenerator, UINotificationFeedbackGenerator (iOS 10+)Standard UI moments: taps, toggles, picker ticks, success/error
System feedback (SwiftUI).sensoryFeedback(_:trigger:) (iOS 17+)The same, declaratively, tied to state changes
Custom patternsCore Haptics: CHHapticEngine, CHHapticPattern (iOS 13+)Rhythms, textures, game effects, anything timed or shaped


My rule: start at the top and go down only when you must. System feedback matches what users feel everywhere else on iOS, respects their settings and needs no engine management. Core Haptics is powerful, but every custom pattern is a design decision you now own.


2. UIKit feedback generators, and what changed


The three generators map to three meanings:

import UIKit

final class HapticsDemoViewController: UIViewController {

    // iOS 17.5+: generators can be tied to a view, which lets the system
    // place feedback appropriately (and is required by the newer API).
    private lazy var impact = UIImpactFeedbackGenerator(style: .medium, view: view)
    private lazy var selection = UISelectionFeedbackGenerator(view: view)
    private lazy var notification = UINotificationFeedbackGenerator(view: view)

    @objc func buttonTouchDown(_ sender: UIButton) {
        impact.prepare()                          // wake the Taptic Engine; reduces latency
    }

    @objc func buttonTapped(_ sender: UIButton) {
        impact.impactOccurred(intensity: 0.8)     // iOS 13+: 0.0...1.0
    }

    func pickerValueChanged() {
        selection.selectionChanged()              // the light "tick" of a picker wheel
    }

    func saveFinished(success: Bool) {
        notification.notificationOccurred(success ? .success : .error)
    }
}

Things worth knowing:
  • Impact styles: .light, .medium and .heavy, plus .soft and .rigid (iOS 13+). Rigid is short and crisp, soft is rounder. Feel them side by side; the names don't do them justice.
  • prepare() before you need it. It puts the Taptic Engine into a ready state for a short time, so the feedback lands with the gesture rather than a beat late. Calling it on touch-down for a touch-up action is the classic pattern.
  • Use notifications for outcomes only. Success, warning and error are distinct, recognisable patterns. Don't use .success as a generic "tap" feel.

What's new. iOS 17.5 added view-based initialisers, such as init(style:view:), and location-aware calls like impactOccurred(intensity:at:). Apple's current documentation now marks the original UIImpactFeedbackGenerator.init(style:) as deprecated in iOS 27, with init(style:view:) as the replacement. If you support iOS 17.4 or earlier, branch with if #available(iOS 17.5, *). There's also a new UICanvasFeedbackGenerator (iOS 17.5) for drawing apps: alignment and path-completion moments.


3. SwiftUI: sensoryFeedback


In SwiftUI (iOS 17+), feedback is declared against a value. Whenever the trigger changes, the feedback plays:

import SwiftUI

struct FavoriteButton: View {
    @State private var isFavorite = false
    @State private var saveResult: Bool?

    var body: some View {
        VStack(spacing: 24) {
            Button { isFavorite.toggle() } label: {
                Image(systemName: isFavorite ? "heart.fill" : "heart").font(.largeTitle)
            }
            .sensoryFeedback(.impact(weight: .medium, intensity: 0.8), trigger: isFavorite)

            Button("Save") { saveResult = Bool.random() }
                // Choose the feedback from the new value: success or error.
                .sensoryFeedback(trigger: saveResult) { _, newValue in
                    guard let newValue else { return nil }
                    return newValue ? .success : .error
                }
        }
    }
}

SensoryFeedback includes .selection, .success, .warning, .error, .increase, .decrease, .start, .stop, .alignment, .levelChange and .impact(weight:intensity:) / .impact(flexibility:intensity:). Several of these, such as .increase and .decrease, describe meaning rather than feel, and the system picks an appropriate sensation per platform. That's exactly the abstraction you want in SwiftUI.

4. Core Haptics: the engine and its lifecycle


For anything beyond system feedback, such as a heartbeat, an explosion, a texture or a rhythm, you need Core Haptics. Everything starts with an engine, and the engine's lifecycle is where most bugs live:

import CoreHaptics

final class HapticEngineManager {
    private(set) var engine: CHHapticEngine?
    let supportsHaptics = CHHapticEngine.capabilitiesForHardware().supportsHaptics

    func start() {
        guard supportsHaptics else { return }            // e.g. most iPads, the Simulator
        do {
            let engine = try CHHapticEngine()
            engine.playsHapticsOnly = true               // no audio events in this engine
            engine.isAutoShutdownEnabled = true          // let the system power it down when idle

            // The engine can stop on its own: app backgrounded, audio session interrupted…
            engine.stoppedHandler = { reason in
                print("Haptic engine stopped: \(reason.rawValue)")
            }
            // …or the haptic server can reset. Restart and rebuild players.
            engine.resetHandler = { [weak self] in
                try? self?.engine?.start()
            }
            try engine.start()
            self.engine = engine
        } catch {
            print("Haptic engine failed: \(error)")
        }
    }

    func play(_ pattern: CHHapticPattern) {
        guard let engine else { return }
        do {
            let player = try engine.makePlayer(with: pattern)
            try engine.start()               // cheap if already running; needed after auto-shutdown
            try player.start(atTime: CHHapticTimeImmediate)
        } catch {
            print("Play failed: \(error)")
        }
    }
}

The three lines that save you from support emails:
  • Check capabilitiesForHardware().supportsHaptics. Most iPads and the Simulator don't have a Taptic Engine. Hide or disable haptic-only features there, rather than showing buttons that do nothing.
  • Handle stoppedHandler and resetHandler. The engine will stop when your app backgrounds, and the server can reset. Without a reset handler, haptics silently die after the first interruption.
  • isAutoShutdownEnabled saves power between plays. Pair it with calling start() before each play.

5. Building patterns: intensity, sharpness and time


A Core Haptics pattern is a list of events on a timeline. There are two kinds:

  • Transient: a single tap, like a click or knock.
  • Continuous: a sustained vibration with a duration, like a buzz or rumble.

Each event has two key parameters, both from 0 to 1:

  • Intensity: how strong it is.
  • Sharpness: how crisp it is. Low is round and dull; high is precise and clicky.

Here's a heartbeat, one of the rhythm patterns in the app's library, built from four transients:

import CoreHaptics

enum Patterns {
    static func tap(at time: TimeInterval, intensity: Float, sharpness: Float) -> CHHapticEvent {
        CHHapticEvent(eventType: .hapticTransient, parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness)
        ], relativeTime: time)
    }

    /// "lub-dub … lub-dub": strong-soft pairs, about 0.8 s apart.
    static func heartbeat() throws -> CHHapticPattern {
        let beats: [CHHapticEvent] = [
            tap(at: 0.00, intensity: 1.0, sharpness: 0.3),
            tap(at: 0.15, intensity: 0.6, sharpness: 0.2),
            tap(at: 0.80, intensity: 1.0, sharpness: 0.3),
            tap(at: 0.95, intensity: 0.6, sharpness: 0.2)
        ]
        return try CHHapticPattern(events: beats, parameters: [])
    }

    /// A rumble that swells and fades, e.g. for a game explosion.
    static func explosion() throws -> CHHapticPattern {
        let rumble = CHHapticEvent(eventType: .hapticContinuous, parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.1)
        ], relativeTime: 0.05, duration: 0.8)

        let swell = CHHapticParameterCurve(parameterID: .hapticIntensityControl, controlPoints: [
            .init(relativeTime: 0.0, value: 0.2),
            .init(relativeTime: 0.15, value: 1.0),
            .init(relativeTime: 0.85, value: 0.0)
        ], relativeTime: 0.05)

        return try CHHapticPattern(events: [tap(at: 0, intensity: 1, sharpness: 1), rumble],
                                   parameterCurves: [swell])
    }
}

CHHapticParameterCurve (iOS 13+) shapes a parameter over time. Here it makes the rumble swell and decay instead of starting and stopping flat. A sharp transient on top gives the "crack" of the explosion before the rumble.

Design tip: use sharpness to carry meaning. Sharp taps read as precise and positive (selection, success); dull, low-sharpness buzzes read as heavy or negative (errors, collisions). The same intensity can feel completely different depending on sharpness.


6. AHAP: patterns as files


Writing patterns in code is fine for a few. For a library, or for handing patterns between designers and developers, Apple defines AHAP (Apple Haptic and Audio Pattern), a JSON format describing the same events and curves:

{
  "Version": 1.0,
  "Pattern": [
    { "Event": { "Time": 0.0, "EventType": "HapticTransient",
      "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 1.0 },
                           { "ParameterID": "HapticSharpness", "ParameterValue": 0.3 } ] } },
    { "Event": { "Time": 0.15, "EventType": "HapticTransient",
      "EventParameters": [ { "ParameterID": "HapticIntensity", "ParameterValue": 0.6 },
                           { "ParameterID": "HapticSharpness", "ParameterValue": 0.2 } ] } }
  ]
}

Loading and saving:
import CoreHaptics

extension HapticEngineManager {
    /// Play an .ahap file straight from the bundle or a user-imported URL.
    func playAHAP(at url: URL) throws {
        guard let engine else { return }
        try engine.start()
        try engine.playPattern(from: url)            // iOS 13+
    }

    /// iOS 16+: build a pattern object from a file (e.g. to edit or loop it).
    func loadAHAP(at url: URL) throws -> CHHapticPattern {
        try CHHapticPattern(contentsOf: url)
    }
}

/// Export any pattern you built in code as AHAP JSON.
func ahapData(for pattern: CHHapticPattern) throws -> Data {
    let dictionary = try pattern.exportDictionary()  // iOS 13+
    return try JSONSerialization.data(withJSONObject: dictionary, options: [.prettyPrinted, .sortedKeys])
}

exportDictionary() is what makes a "design in the app, export the file" workflow possible. AHAP is also the honest format to give developers. When generating Swift code from a pattern, a system impact should stay a UIKit or SwiftUI call, and only genuinely custom patterns need Core Haptics.

7. Looping, speed and "record a rhythm"


Testing a haptic once isn't enough; you need to feel it repeatedly, faster and slower. CHHapticAdvancedPatternPlayer (iOS 13+) adds looping and playback rate:

import CoreHaptics

extension HapticEngineManager {
    func makeLoopingPlayer(_ pattern: CHHapticPattern, rate: Float = 1.0) throws -> CHHapticAdvancedPatternPlayer? {
        guard let engine else { return nil }
        let player = try engine.makeAdvancedPlayer(with: pattern)
        player.loopEnabled = true
        player.loopEnd = pattern.duration + 0.2      // a short gap between repetitions
        player.playbackRate = rate                   // 0.5× … 2×
        return player
    }
}

// Usage: "long test" for 15 seconds, then stop.
func longTest(_ pattern: CHHapticPattern, manager: HapticEngineManager) async throws {
    guard let player = try manager.makeLoopingPlayer(pattern) else { return }
    try manager.engine?.start()
    try player.start(atTime: CHHapticTimeImmediate)
    try await Task.sleep(for: .seconds(15))
    try player.stop(atTime: CHHapticTimeImmediate)
}

Recording a rhythm is pleasingly simple. Record tap timestamps, normalise them so the first tap is at zero, and turn each into a transient:
func pattern(fromTapTimes times: [TimeInterval], maxDuration: TimeInterval = 4) throws -> CHHapticPattern {
    guard let first = times.first else { return try CHHapticPattern(events: [], parameters: []) }
    let events = times
        .map { $0 - first }
        .filter { $0 <= maxDuration }
        .map { Patterns.tap(at: $0, intensity: 0.9, sharpness: 0.5) }
    return try CHHapticPattern(events: events, parameters: [])
}

Capture the timestamps with CACurrentMediaTime() in the tap handler, not Date(). It's monotonic and won't jump if the clock changes.

8. Real-world example: Haptics Explorer


Everything above comes from building Haptics Explorer. The code is the approach I'd recommend, written for this article, not the app's source.

Haptics Explorer App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free with an optional Premium tier, in Developer Tools and Utilities
  • First released in December 2022
  • 4 ratings in the US store, all 5 stars
  • Supports iOS 17.0 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Impact (Light, Medium, Heavy, Rigid, Soft)", "Notifications (Success, Error, Warning)", "Selection feedback"UIKit generators and SwiftUI sensoryFeedback (sections 2–3)
"Rhythm patterns (Heartbeat, Double/Triple Tap, Tick Tock)", "Game patterns (Explosion…)"Core Haptics events, sharpness and parameter curves (section 5)
"Adjust intensity and sharpness" / "Custom Pattern Creator"CHHapticEventParameter values (section 5)
"Code Generation… UIKit, SwiftUI, Combine, Async/Await"System feedback stays UIKit or SwiftUI; custom patterns use Core Haptics (sections 2–6)
"AHAP - Import a haptic file. Exporting an AHAP file is Premium."playPattern(from:), CHHapticPattern(contentsOf:), exportDictionary() (section 6)
"Test haptic feedbacks instantly with play, loop, a long test, and speed controls" / "Speed control (0.5x, 1x, 2x)"CHHapticAdvancedPatternPlayer with loopEnabled and playbackRate (section 7)
"Record and audio - Tap a rhythm"; release notes: "Tap the timeline for up to 4 seconds"Tap timestamps → transient events (section 7)


Haptics Explorer App Store screenshot 2

The listing also mentions widgets, Control Center and Siri support for replaying the last pattern, and a "Tell them apart" mode that plays success, warning and error back to back. That last one is my favourite design exercise. If your app's success and error haptics can't be told apart with your eyes closed, they aren't doing their job.


9. Before you ship haptics: a checklist


  1. Use system feedback first. Core Haptics only for genuinely custom patterns.
  2. Every haptic has a meaning. Selection, impact, success and error are used consistently across the app.
  3. prepare() is called ahead of latency-sensitive feedback.
  4. There's a hardware check. supportsHaptics gates custom features; the app is fine on iPad and the Simulator.
  5. The engine survives interruptions. stoppedHandler and resetHandler are implemented and tested by backgrounding mid-pattern.
  6. Haptics are paired with something visible or audible. Never use a haptic as the only signal: some users turn System Haptics off, and some devices don't have them.
  7. Patterns are short and sparse. Constant buzzing is fatiguing and drains battery.
  8. iOS 27 readiness. Move to UIImpactFeedbackGenerator(style:view:) where you can (iOS 17.5+).
  9. Test on several iPhone models. The Taptic Engine differs, and so does the feel.

Wrapping up


Good haptics are a vocabulary, not decoration. Lean on the system generators and SwiftUI's sensoryFeedback for everyday UI. Reach for Core Haptics when you need rhythm, shape or texture, and manage its engine carefully. Use AHAP when patterns need to travel between people and tools. And always test with your fingers, not just your eyes.

Try the app, or explore my other work:

What's the best haptic you've felt in someone else's app? Tell me in the comments. I collect good examples.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/a-prac…


Animating Algorithms in SwiftUI: A Step-Recorder Pattern for Visualising Sorts, Searches and Graphs


Originally published at bestlatestapps.com/blog/animat…

Separate the algorithm from the animation, record every step as data, then play it back with play, pause, speed and scrubbing, accessibly

DSA Master app icon

When I was learning data structures, the textbook diagrams were always a frame or two short. You'd see the array before the merge and the array after, and the interesting part, the moment the two halves interleave, happened somewhere off the page.

That's the gap DSA Master – Data & Algorithms tries to fill. It's my learning app for data structures and algorithms. It covers arrays and linked lists through segment trees, Dijkstra and KMP, with code in several languages, practice challenges and, the part I care about most, step-by-step animated visualisations you can play, pause, replay and slow down.

The first visualiser I wrote was the obvious one: put Task.sleep inside the sorting loop and update some @State between comparisons. It worked for bubble sort and fell apart immediately after. Pause was a hack, rewind was impossible, the speed slider fought the animation, and every algorithm had UI code tangled through it.

The fix is a pattern I now use for every visualisation: record, then replay. The algorithm runs once, at full speed, and emits a list of steps as plain data. A separate player walks through those steps and the views render whatever the current step says. This article builds that pattern in SwiftUI, from the step model to the player, sorting bars, binary search, BFS on a graph and complexity charts, with notes on accessibility and on running user code. The approach is the one I'd use and recommend; I'm not publishing the app's exact source.


1. The problem with animating inside the algorithm


Here's the tempting version:

// Don't do this.
func bubbleSortAnimated() async {
    for i in 0..<values.count {
        for j in 0..<values.count - i - 1 {
            highlighted = [j, j + 1]
            try? await Task.sleep(for: .milliseconds(300))
            if values[j] > values[j + 1] { values.swapAt(j, j + 1) }
        }
    }
}

It looks fine, but:
  • You can't go backwards. The state is gone once the loop moves on.
  • Pause and speed changes leak into the algorithm. Every algorithm needs the same if paused { … } plumbing.
  • The algorithm isn't testable. You can't assert on "the sequence of comparisons" without running the UI.
  • Recursive algorithms get ugly. Merge sort and DFS turn into async recursion just to show a highlight.

Recording steps as values fixes all four.


2. The step model


A step is a snapshot of what the viewer should see, plus a sentence explaining it. For array algorithms:

struct Bar: Identifiable, Hashable {
    let id: Int        // stable identity, so SwiftUI can animate moves
    let value: Int
}

struct ArrayStep: Hashable {
    enum Action: Hashable { case compare, swap, overwrite, markSorted, done }

    var bars: [Bar]
    var action: Action
    var active: Set<Int> = []      // indices being looked at
    var sorted: Set<Int> = []      // indices known to be final
    var caption: String
}

Two design choices matter here.

Each bar has a stable id, separate from its value. Duplicate values are common in sample data; if identity were the value, SwiftUI couldn't tell two 5s apart and the swap animation would glitch.

A step holds the full array, not a diff. For the sizes you visualise (10–40 elements), copying an array per step costs almost nothing, and it makes scrubbing to any step O(1). Diffs are an optimisation you probably don't need.


3. Recording an algorithm


The algorithm becomes a pure function: input in, [ArrayStep] out. Here's insertion sort with a small recorder helper:

struct StepRecorder {
    private(set) var steps: [ArrayStep] = []
    var bars: [Bar]
    var sorted: Set<Int> = []

    init(_ values: [Int]) {
        bars = values.enumerated().map { Bar(id: $0.offset, value: $0.element) }
        record(.compare, [], "Start with \(values.count) unsorted values.")
    }

    mutating func record(_ action: ArrayStep.Action, _ active: Set<Int>, _ caption: String) {
        steps.append(ArrayStep(bars: bars, action: action,
                               active: active, sorted: sorted, caption: caption))
    }
}

func insertionSortSteps(_ input: [Int]) -> [ArrayStep] {
    var r = StepRecorder(input)
    guard r.bars.count > 1 else { return r.steps }

    for i in 1..<r.bars.count {
        var j = i
        r.record(.compare, [j], "Take \(r.bars[j].value) and insert it into the sorted left part.")
        while j > 0 {
            r.record(.compare, [j - 1, j], "Compare \(r.bars[j - 1].value) and \(r.bars[j].value).")
            guard r.bars[j - 1].value > r.bars[j].value else { break }
            r.bars.swapAt(j - 1, j)
            r.record(.swap, [j - 1, j], "\(r.bars[j].value) is larger, so shift it right.")
            j -= 1
        }
    }
    r.sorted = Set(r.bars.indices)
    r.record(.done, [], "Sorted.")
    return r.steps
}

That's the whole visualisation logic for insertion sort, with no SwiftUI and no timers, and it's unit-testable:
import XCTest

final class InsertionSortStepTests: XCTestCase {
    func testFinalStepIsSorted() {
        let steps = insertionSortSteps([5, 2, 4, 6, 1, 3])
        XCTAssertEqual(steps.last?.bars.map(\.value), [1, 2, 3, 4, 5, 6])
        XCTAssertEqual(steps.last?.action, .done)
    }
}

Recursive algorithms are just as easy, because the recorder is a value you pass inout. Merge sort records .overwrite steps as it copies values back from the temporary buffer. Quick sort records the pivot as active and the partition boundary in the caption. Each algorithm stays readable as an algorithm.

4. The player: play, pause, speed and scrub


The player owns an index into the step array, and nothing else about the algorithm:

import SwiftUI

@MainActor
final class StepPlayer<Step>: ObservableObject {
    @Published private(set) var index = 0
    @Published var isPlaying = false
    @Published var speed: Double = 1.0          // 0.5x, 1x, 2x …

    let steps: [Step]
    var baseInterval: Duration = .milliseconds(600)
    private var playTask: Task<Void, Never>?

    init(steps: [Step]) { self.steps = steps }

    var current: Step { steps[index] }
    var isAtEnd: Bool { index == steps.count - 1 }

    func play() {
        guard !isPlaying else { return }
        if isAtEnd { index = 0 }                  // "replay"
        isPlaying = true
        playTask = Task { [weak self] in
            while let self, self.isPlaying, !self.isAtEnd {
                try? await Task.sleep(for: self.baseInterval / self.speed)
                guard !Task.isCancelled, self.isPlaying else { return }
                self.index += 1
            }
            self?.isPlaying = false
        }
    }

    func pause() {
        isPlaying = false
        playTask?.cancel()
    }

    func step(by delta: Int) {
        pause()
        index = min(max(index + delta, 0), steps.count - 1)
    }

    func seek(to newIndex: Int) {
        pause()
        index = min(max(newIndex, 0), steps.count - 1)
    }
}

Because the speed is read on every tick, changing it mid-playback just works. Step back is step(by: -1), and a scrubber is a Slider bound to seek(to:). None of this knows what sorting is, so the same player drives arrays, trees and graphs.

5. Rendering bars that animate themselves


With stable identity, SwiftUI does the hard animation work for you. Render the current step and attach an animation to the step index:

struct ArrayStepView: View {
    @ObservedObject var player: StepPlayer<ArrayStep>

    var body: some View {
        let step = player.current
        let maxValue = max(step.bars.map(\.value).max() ?? 1, 1)

        VStack(spacing: 16) {
            HStack(alignment: .bottom, spacing: 6) {
                ForEach(Array(step.bars.enumerated()), id: \.element.id) { index, bar in
                    VStack(spacing: 4) {
                        RoundedRectangle(cornerRadius: 4)
                            .fill(color(for: index, in: step))
                            .frame(height: CGFloat(bar.value) / CGFloat(maxValue) * 180)
                        Text("\(bar.value)").font(.caption2.monospacedDigit())
                    }
                }
            }
            .frame(height: 210)
            .animation(.spring(duration: 0.35), value: player.index)

            Text(step.caption)
                .font(.callout)
                .multilineTextAlignment(.center)
                .frame(minHeight: 44)

            HStack(spacing: 24) {
                Button { player.step(by: -1) } label: { Image(systemName: "backward.frame.fill") }
                Button { player.isPlaying ? player.pause() : player.play() } label: {
                    Image(systemName: player.isPlaying ? "pause.fill" : "play.fill")
                }
                Button { player.step(by: 1) } label: { Image(systemName: "forward.frame.fill") }
                Picker("Speed", selection: $player.speed) {
                    Text("0.5×").tag(0.5); Text("1×").tag(1.0); Text("2×").tag(2.0)
                }
                .pickerStyle(.segmented).frame(width: 150)
            }
            .font(.title2)
        }
        .padding()
    }

    private func color(for index: Int, in step: ArrayStep) -> Color {
        if step.sorted.contains(index) { return .green }
        if step.active.contains(index) { return step.action == .swap ? .orange : .yellow }
        return .blue.opacity(0.6)
    }
}

ForEach uses \.element.id, not the index. When two bars swap places in the array, SwiftUI sees the same views in new positions and slides them, with no custom geometry code. The .animation(_:value:) modifier (iOS 13+) ties the animation to step changes only, so unrelated state changes don't animate.

6. Beyond arrays: binary search and BFS


The pattern generalises by changing only the step type.

Binary search needs a window, not swaps:

struct SearchStep: Hashable {
    var low: Int, high: Int, mid: Int?
    var found: Int?
    var caption: String
}

func binarySearchSteps(_ sorted: [Int], target: Int) -> [SearchStep] {
    var steps: [SearchStep] = []
    var low = 0, high = sorted.count - 1
    while low <= high {
        let mid = low + (high - low) / 2
        steps.append(.init(low: low, high: high, mid: mid, found: nil,
                           caption: "Check the middle: \(sorted[mid])."))
        if sorted[mid] == target {
            steps.append(.init(low: low, high: high, mid: mid, found: mid,
                               caption: "Found \(target) at index \(mid)."))
            return steps
        }
        if sorted[mid] < target {
            low = mid + 1
            steps.append(.init(low: low, high: high, mid: nil, found: nil,
                               caption: "\(sorted[mid]) < \(target): discard the left half."))
        } else {
            high = mid - 1
            steps.append(.init(low: low, high: high, mid: nil, found: nil,
                               caption: "\(sorted[mid]) > \(target): discard the right half."))
        }
    }
    steps.append(.init(low: low, high: high, mid: nil, found: nil,
                       caption: "The window is empty: \(target) isn't in the array."))
    return steps
}

Breadth-first search records the frontier and visited set at each step. The graph is laid out once with fixed node positions, and each step only changes colours:
struct GraphStep: Hashable {
    var visited: Set<Int>
    var frontier: [Int]       // the queue
    var current: Int?
    var caption: String
}

func bfsSteps(adjacency: [Int: [Int]], start: Int) -> [GraphStep] {
    var steps: [GraphStep] = []
    var visited: Set<Int> = [start]
    var queue = [start]
    steps.append(.init(visited: visited, frontier: queue, current: nil,
                       caption: "Start at \(start)."))

    while !queue.isEmpty {
        let node = queue.removeFirst()
        steps.append(.init(visited: visited, frontier: queue, current: node,
                           caption: "Visit \(node)."))
        for next in adjacency[node, default: []] where !visited.contains(next) {
            visited.insert(next)
            queue.append(next)
            steps.append(.init(visited: visited, frontier: queue, current: node,
                               caption: "Discover \(next); add it to the queue."))
        }
    }
    steps.append(.init(visited: visited, frontier: [], current: nil, caption: "Done."))
    return steps
}

Draw nodes with Circle() at fixed positions and edges with a Path inside a Canvas or ZStack. Show the queue underneath as a row of chips, because watching the queue is how BFS finally "clicks". DFS is the same with a stack. Dijkstra adds a distance label per node and a priority queue in the caption.

7. Complexity charts with Swift Charts


Complexity is easier to feel than to read. Swift Charts (iOS 16+) turns "O(n log n) vs O(n²)" into a picture in a few lines:

import Charts

struct ComplexityPoint: Identifiable {
    let id = UUID()
    let n: Int
    let ops: Double
    let label: String
}

struct ComplexityChart: View {
    private var points: [ComplexityPoint] {
        stride(from: 1, through: 64, by: 3).flatMap { n -> [ComplexityPoint] in
            let x = Double(n)
            return [
                .init(n: n, ops: log2(x), label: "O(log n)"),
                .init(n: n, ops: x, label: "O(n)"),
                .init(n: n, ops: x * log2(max(x, 2)), label: "O(n log n)"),
                .init(n: n, ops: x * x, label: "O(n²)")
            ]
        }
    }

    var body: some View {
        Chart(points) { p in
            LineMark(x: .value("n", p.n), y: .value("Operations", p.ops))
                .foregroundStyle(by: .value("Complexity", p.label))
        }
        .chartYScale(domain: 0...600)       // clip n², or it flattens everything else
        .frame(height: 240)
    }
}

The chartYScale clip is the teaching trick: let O(n²) shoot off the top of the chart, and learners see immediately why it matters.

8. Accessibility and running user code


VoiceOver. An animation is invisible to a VoiceOver user, but the captions aren't. Because every step already has a sentence, accessibility is nearly free: give the visualisation an accessibilityLabel of the current caption, and announce it when the step changes while playing:

.onChange(of: player.index) { _ in
    UIAccessibility.post(notification: .announcement, argument: player.current.caption)
}

(onChange(of:perform:) with one parameter is the iOS 16-compatible form; iOS 17 adds the two-parameter version.) Also slow the default speed when VoiceOver is running (UIAccessibility.isVoiceOverRunning) so announcements aren't cut off, and honour Reduce Motion by swapping the spring for a cross-fade.

A code playground. The listing describes a live playground that runs Python, JavaScript, Java, C++, Kotlin, Dart and Go. I won't claim how the app does it. Here are the two routes I know of:

  • On-device JavaScript with JSContext from JavaScriptCore (iOS 7+). It's sandboxed and fast, and it needs no network.
  • A remote, sandboxed runner for compiled languages: send the source, get back output, errors and timing. You need strict timeouts and resource limits on the server.

Either way, read App Store Review Guideline 2.5.2. It forbids downloading or executing code that changes the app's functionality, with a narrow exception for educational apps that teach or let students test code, provided the code is fully viewable and editable by the user.


9. Real-world example: DSA Master – Data & Algorithms


Everything above is the approach I'd use, and I'd recommend it for any teaching visualiser. You'll find it in spirit in DSA Master – Data & Algorithms.

DSA Master App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Education and Developer Tools
  • First released in February 2023
  • 36 ratings in the US store, averaging about 4.1 stars
  • Supports iOS 16.2 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Step-by-Step Animations… Play, pause, replay, and control animation speed"Recorded steps and StepPlayer (sections 2–4)
"Visual Data Flow – See how data structures transform during algorithm execution"Stable-identity bars that animate themselves (section 5)
"Multiple Visualization Types – Sorting, searching, tree traversal, graph algorithms"The same player with different step types (section 6)
"Time Complexity Charts – Visual comparisons of algorithm performance"Swift Charts LineMark curves (section 7)
"Accessibility – Full VoiceOver support"Captions as labels and announcements (section 8)
"Live Code Playground… Python, JavaScript, Java, C++, Kotlin, Dart, and Go"Options and the Review Guideline note (section 8)


DSA Master App Store screenshot 2

The listing's content library is broad: dynamic arrays, deques, AVL, red-black, segment and Fenwick trees, tries, suffix trees, topological sort, Bellman-Ford, Floyd-Warshall, Kruskal, Prim, KMP and Rabin-Karp. The record-then-replay pattern is what makes that breadth manageable. Each new topic is a pure function that returns steps, plus, at most, a new step view.


10. Before you ship an algorithm visualiser: a checklist


  1. Algorithms are pure. Each one takes input and returns [Step]; no SwiftUI, no timers, no async.
  2. There are unit tests per algorithm: the final step is correct, and the step count is sane for small inputs.
  3. Identity is stable. Elements have IDs independent of their values, so duplicates animate correctly.
  4. The player is generic. Play, pause, step, scrub, replay and speed work for every step type.
  5. Every step has a caption. It's your explanation, your VoiceOver label and your test oracle, all at once.
  6. Reduce Motion and VoiceOver are respected: a slower default, announcements and cross-fades instead of springs.
  7. Inputs are capped. Arrays of 10–40 elements; bigger inputs don't teach better, they just get slower.
  8. Code execution passes review. If you run user code, make sure you meet Guideline 2.5.2, and that remote runners are sandboxed with timeouts.

Wrapping up


The trick to visualising algorithms is to stop animating them. Record what happens as plain data, then let a small, generic player and SwiftUI's identity-based animations do the presenting. You get pause, rewind, speed control, tests and accessibility almost for free, and every new algorithm becomes a short, readable function.

Try the app, or explore my other work:

Which algorithm only made sense to you once you saw it? For me it was the partition step in quick sort. Tell me yours in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/animat…


Building a Chromatic Guitar Tuner on iOS: Real-Time Pitch Detection with AVAudioEngine and Accelerate


Originally published at bestlatestapps.com/blog/buildi…

From microphone samples to "A2, +3 cents": session setup, an input tap, autocorrelation with vDSP, note maths, smoothing and tuning presets

Guitar Tuner: Chromatic app icon

A tuner is the most honest app you can build. There's no design trick that hides a wrong answer: the guitarist plucks the low E, looks at the needle and then checks it with their ear. If the needle jitters, lands on the wrong octave or reads 4 cents sharp when the string is in tune, they delete the app.

My app Guitar Tuner: Chromatic has been on the App Store since June 2020. It's a chromatic tuner for guitar and bass with a live Hz readout, colour-coded feedback, an Auto Mode that detects which string you're playing and 15 tuning presets, from Standard and Drop-D to Open-G and Drop-B.

This article builds the core of a tuner like that, end to end: configuring the audio session for measurement, pulling samples from the mic with an AVAudioEngine tap, detecting pitch with autocorrelation using Accelerate's vDSP, turning Hz into a note name and cents, smoothing the reading so the needle doesn't dance, and matching against presets. I describe the approach I'd use and recommend; I'm not claiming the app's exact algorithm. API names and availability are checked against Apple's docs, including an iOS 27 change to audio taps that's worth knowing about.


1. What a tuner actually has to do


Every frame, a tuner answers three questions:

  1. Is anyone playing? Silence and room noise shouldn't move the needle.
  2. What is the fundamental frequency? That means the fundamental, not the loudest harmonic. A plucked guitar string often has a stronger second or third harmonic than its fundamental, which is why naive FFT-peak tuners jump octaves.
  3. How far is that from the nearest target note? The answer is in cents: hundredths of a semitone.

The guitar range sets the constraints. A 4-string bass in standard tuning goes down to E1 (≈ 41.2 Hz), drop tunings go lower, and a guitar's high E string sits around 330 Hz, with harmonics far above. To see a full period of 41 Hz you need ~24 ms of audio, and in practice you want several periods: I work with windows of about 4,096 samples (~85 ms at 48 kHz).


2. Permission and a session set up for measurement


Microphone permission works exactly as in any audio app: an NSMicrophoneUsageDescription purpose string ("Guitar Tuner listens to your instrument to detect its pitch"), then AVAudioApplication.requestRecordPermission() on iOS 17+, with the AVAudioSession fallback for older systems. I covered that pattern in detail in my mic-to-speaker article, so here I'll focus on what's different for a tuner: the session mode.

import AVFoundation

enum TunerSession {
    static func activate() throws {
        let session = AVAudioSession.sharedInstance()
        // .record: we only listen. .measurement: minimise system signal processing
        // (no automatic gain or voice EQ colouring the signal we're analysing).
        try session.setCategory(.record, mode: .measurement, options: [])
        try session.setPreferredSampleRate(48_000)
        try session.setActive(true)
    }

    static func deactivate() {
        try? AVAudioSession.sharedInstance()
            .setActive(false, options: .notifyOthersOnDeactivation)
    }
}

Apple describes .measurement (iOS 5+) as the mode for apps that measure audio input or output. For a tuner, that's exactly the point: voice processing and automatic gain control are designed to make speech pleasant, and they distort the very signal you want to measure.

If your tuner also plays reference tones, use .playAndRecord instead, but keep the .measurement mode.


3. Getting samples: an input tap and a rolling window


AVAudioEngine's input node delivers microphone audio. A tap on it hands you buffers of samples without routing anything to the speaker:

import AVFoundation

final class MicSampler {
    private let engine = AVAudioEngine()
    private var window: [Float] = []
    private let windowSize = 4_096
    private let analysisQueue = DispatchQueue(label: "tuner.analysis")

    /// Called on the analysis queue with a full window and the sample rate.
    var onWindow: (([Float], Double) -> Void)?

    func start() throws {
        let input = engine.inputNode
        let format = input.outputFormat(forBus: 0)
        guard format.sampleRate > 0, format.channelCount > 0 else {
            throw NSError(domain: "Tuner", code: 1,
                          userInfo: [NSLocalizedDescriptionKey: "No microphone input available"])
        }
        let sampleRate = format.sampleRate

        input.installTap(onBus: 0, bufferSize: 4_096, format: format) { [weak self] buffer, _ in
            guard let self, let channel = buffer.floatChannelData?[0] else { return }
            // Copy out of the buffer immediately; it's reused after this block returns.
            let samples = Array(UnsafeBufferPointer(start: channel, count: Int(buffer.frameLength)))
            self.analysisQueue.async {
                self.window.append(contentsOf: samples)
                if self.window.count >= self.windowSize {
                    let frame = Array(self.window.suffix(self.windowSize))
                    // Keep 50% overlap so readings update twice as often.
                    self.window = Array(frame.suffix(self.windowSize / 2))
                    self.onWindow?(frame, sampleRate)
                }
            }
        }
        engine.prepare()
        try engine.start()
    }

    func stop() {
        engine.inputNode.removeTap(onBus: 0)
        engine.stop()
    }
}

A few details that matter:
  • The requested buffer size is a request. Apple's documentation gives a supported range of roughly 100–400 ms for tap buffers, so don't assume you'll get exactly 4,096 frames. The rolling window above copes with any size.
  • Copy, then hand off. The buffer is only valid during the block, so copy the samples and do the analysis on your own queue.
  • Use only channel 0. A tuner needs mono.

What's new in iOS 27: Apple's documentation now marks installTap(onBus:bufferSize:format:block:) as deprecated in iOS 27 and adds installAudioTap(onBus:bufferSize:format:tapProvider:). The new method takes a @Sendable closure that receives an AVReadOnlyAudioPCMBuffer (iOS 27+), "safe for concurrent use". If you build with the iOS 27 SDK, it's the Swift-concurrency-friendly choice. Because it throws, call it with try. On earlier systems, the classic tap above is still the way.


4. Pitch detection with autocorrelation and vDSP


Autocorrelation asks: "if I shift this signal by lag samples, how similar is it to itself?" A periodic signal lines up with itself at its period, so the first strong peak (after lag zero) gives you the period, and frequency = sampleRate / lag. Unlike picking the biggest FFT bin, this naturally favours the fundamental, because every harmonic also repeats at the fundamental's period.

Accelerate's vDSP_conv (iOS 4+) computes a sliding dot product. Apple's docs note that a positive filter stride gives correlation, which is exactly what we want, and it's fast.

import Accelerate

struct PitchDetector {
    var minFrequency: Double = 30      // below low B on a 5-string bass
    var maxFrequency: Double = 1_200   // well above high E's fundamental
    var silenceRMS: Float = 0.01       // noise gate; tune on real devices

    /// Returns the fundamental frequency in Hz, or nil for silence or no clear pitch.
    func detect(_ x: [Float], sampleRate: Double) -> Double? {
        // 1. Noise gate.
        var rms: Float = 0
        vDSP_rmsqv(x, 1, &rms, vDSP_Length(x.count))
        guard rms > silenceRMS else { return nil }

        // 2. Autocorrelation for lags 0...maxLag.
        let minLag = Int(sampleRate / maxFrequency)
        let maxLag = min(Int(sampleRate / minFrequency), x.count / 2)
        let lagCount = maxLag + 1
        let filterLength = x.count - maxLag
        var r = [Float](repeating: 0, count: lagCount)
        x.withUnsafeBufferPointer { xp in
            // r[lag] = Σ x[lag + p] * x[p], for p in 0..<filterLength
            vDSP_conv(xp.baseAddress!, 1, xp.baseAddress!, 1,
                      &r, 1, vDSP_Length(lagCount), vDSP_Length(filterLength))
        }
        guard r[0] > 0 else { return nil }

        // 3. Normalise so r[0] == 1, then find the first strong peak.
        r = vDSP.multiply(1 / r[0], r)

        // Skip the initial lobe around lag 0 by waiting for the curve to dip below zero.
        var lag = minLag
        while lag < maxLag, r[lag] > 0 { lag += 1 }

        var bestLag = 0
        var bestValue: Float = 0
        let threshold: Float = 0.5          // how periodic it must be to count
        while lag < maxLag {
            if r[lag] > r[lag - 1], r[lag] >= r[lag + 1], r[lag] > threshold {
                bestLag = lag; bestValue = r[lag]
                break                          // first good peak = fundamental
            }
            lag += 1
        }
        guard bestLag > 0, bestValue > threshold else { return nil }

        // 4. Parabolic interpolation for sub-sample accuracy (cents need it).
        let a = r[bestLag - 1], b = r[bestLag], c = r[bestLag + 1]
        let denom = a - 2 * b + c
        let shift = denom == 0 ? 0 : 0.5 * (a - c) / denom
        return sampleRate / (Double(bestLag) + Double(shift))
    }
}

Why each step matters:
  • The noise gate stops room noise from producing random notes. Expose the threshold in a hidden debug setting while you test; phones differ.
  • Taking the first good peak, not the highest, avoids "sub-octave" errors where the peak at twice the period is marginally taller.
  • Parabolic interpolation matters more than anything else for accuracy. At 48 kHz, a lag of 109 vs 110 samples is the difference between 440.4 Hz and 436.4 Hz, roughly 16 cents. Without interpolation, your needle can only move in big jumps at high notes.

Autocorrelation over a 4,096-sample window at ~23 readings per second (with 50% overlap) is light work for any modern iPhone. If you ever need more, the same maths can be done through vDSP.FFT (iOS 13+) via the Wiener–Khinchin theorem, but measure first; you probably don't need it.


5. From Hz to note name and cents


Equal temperament makes the maths pleasant. With A4 = 440 Hz, MIDI note 69:

import Foundation

struct NoteReading: Equatable {
    let name: String       // "A"
    let octave: Int        // 2
    let midi: Int          // 45
    let cents: Double      // -50 ... +50
    let frequency: Double
}

enum NoteMath {
    static let names = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]

    static func midi(for frequency: Double, a4: Double = 440) -> Double {
        69 + 12 * log2(frequency / a4)
    }

    static func frequency(forMidi midi: Int, a4: Double = 440) -> Double {
        a4 * pow(2, Double(midi - 69) / 12)
    }

    static func reading(for frequency: Double, a4: Double = 440) -> NoteReading {
        let exact = midi(for: frequency, a4: a4)
        let nearest = Int(exact.rounded())
        let cents = (exact - Double(nearest)) * 100
        let name = names[((nearest % 12) + 12) % 12]
        let octave = nearest / 12 - 1            // MIDI 60 = C4
        return NoteReading(name: name, octave: octave, midi: nearest,
                           cents: cents, frequency: frequency)
    }

    /// Signed distance in cents from `frequency` to a specific target.
    static func cents(from frequency: Double, to target: Double) -> Double {
        1200 * log2(frequency / target)
    }
}

// 110 Hz → A2, 0 cents. 82.41 Hz → E2, ≈ 0 cents.

Let users change the reference pitch (a4). Some orchestras and recordings use 442 Hz or 432 Hz, and it's one parameter.

6. Smoothing: a calm needle that's still responsive


Raw readings jitter by a few cents from frame to frame, and the pluck's attack is noisy. Two layers of smoothing fix most of it:

struct ReadingSmoother {
    private var recent: [Double] = []
    private var displayed: Double?
    var historySize = 5
    var alpha = 0.35                     // 0 = frozen, 1 = no smoothing

    mutating func push(_ frequency: Double?) -> Double? {
        guard let f = frequency else {
            recent.removeAll()           // silence: reset so the next note starts clean
            displayed = nil
            return nil
        }
        // Jumped more than ~a semitone? Treat it as a new note, not noise.
        if let d = displayed, abs(NoteMath.cents(from: f, to: d)) > 80 {
            recent.removeAll(); displayed = nil
        }
        recent.append(f)
        if recent.count > historySize { recent.removeFirst() }

        // 1. Median rejects single-frame outliers (octave slips, transients).
        let median = recent.sorted()[recent.count / 2]
        // 2. Exponential smoothing for a calm needle.
        let next = displayed.map { $0 + alpha * (median - $0) } ?? median
        displayed = next
        return next
    }
}

The median handles outliers; the exponential average handles jitter. Resetting on a big jump keeps the tuner responsive when you move to the next string.

Then drive the UI with an animation that's slightly slower than your reading rate, for example .animation(.easeOut(duration: 0.12), value: cents), and the needle glides instead of stepping.


7. Tuning presets, Auto Mode and colour feedback


A preset is just a list of target frequencies. Using MIDI numbers keeps them exact:

struct TuningPreset: Identifiable {
    let id: String
    let name: String
    let strings: [Int]          // MIDI notes, low to high
    var frequencies: [Double] { strings.map { NoteMath.frequency(forMidi: $0) } }
}

extension TuningPreset {
    // E2 A2 D3 G3 B3 E4
    static let guitarStandard = TuningPreset(id: "g-std", name: "E Standard",
                                             strings: [40, 45, 50, 55, 59, 64])
    // D2 A2 D3 G3 B3 E4
    static let guitarDropD = TuningPreset(id: "g-dropd", name: "Drop D",
                                          strings: [38, 45, 50, 55, 59, 64])
    // E1 A1 D2 G2
    static let bassStandard = TuningPreset(id: "b-std", name: "Bass E Standard",
                                           strings: [28, 33, 38, 43])
}

enum TuneState { case inTune, close, adjust }

struct StringMatch {
    let index: Int
    let target: Double
    let cents: Double
    var state: TuneState {
        switch abs(cents) {
        case ..<5:  return .inTune
        case ..<15: return .close
        default:    return .adjust
        }
    }
}

/// Auto Mode: pick the string whose target is nearest, in cents.
func autoMatch(_ frequency: Double, in preset: TuningPreset) -> StringMatch? {
    preset.frequencies.enumerated()
        .map { StringMatch(index: $0.offset, target: $0.element,
                           cents: NoteMath.cents(from: frequency, to: $0.element)) }
        .min { abs($0.cents) < abs($1.cents) }
}

Measuring "nearest" in cents, not Hz, is what makes Auto Mode work: 5 Hz is a huge interval on the low E and a tiny one on the high E.

The ±5 / ±15 cent bands are my choice, not a standard. Around five cents is often cited as the point where most listeners stop noticing a difference in a single sustained note. Map .inTune, .close and .adjust to your colour scheme, and add an accessibilityValue like "A2, 3 cents sharp" so the tuner also works with VoiceOver.


8. Real-world example: Guitar Tuner: Chromatic


This is the approach I'd use, and recommend, for a tuner like Guitar Tuner: Chromatic.

Guitar Tuner App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Music and Utilities, with an optional Premium tier
  • First released in June 2020
  • 28 ratings in the US store, averaging about 4.1 stars
  • Supports iOS 16.0 and later

How the listing maps to the sections above:

What the listing saysWhere it's covered
"Real-time pitch detection… accurate to the cent, with no cables or extra hardware"Measurement-mode session, input tap, autocorrelation with interpolation (sections 2–4)
"Chromatic tuner with live Hz display"Hz → note and cents (section 5)
"Color-coded feedback (blue = in tune, yellow = close, red = adjust)"Three-state cents bands (section 7)
"Auto Mode detects the string you're playing automatically"Nearest target in cents (section 7)
"15 tuning presets: Standard, Drop-D, Drop-C, Drop-B, Open-G, Open-D, Open-C, and more" / "Guitar (6-string) and Bass (4-string) modes"Presets as MIDI note lists (section 7)
"Pluck a string near your iPhone microphone"The noise gate and windowing realities (sections 3–4)


Guitar Tuner App Store screenshot 2

The listing is also clear about the business model: E-Standard, D-Standard and D♯-Standard tunings are free for guitar and bass, while Premium unlocks all 15 presets, Auto Mode and an ad-free experience. Because a preset is just data, the free/premium split costs almost no code: filter the preset list by entitlement.


9. Before you ship a tuner: a checklist


  1. Test with real instruments, not just a tone generator. Sine waves are easy; a plucked low E with a strong second harmonic is the real test.
  2. Check the extremes. The bass low E (≈ 41 Hz), any drop tunings you offer, and the guitar high E's upper frets.
  3. Use the measurement mode. .measurement avoids system processing colouring the signal.
  4. Tune the noise gate on several devices, in a quiet room and a noisy one.
  5. Interpolate the peak. Without sub-sample accuracy, readings jump in multi-cent steps on higher notes.
  6. Smooth, but reset. Use a median plus an exponential average, and reset on silence and on a big jump.
  7. Measure "nearest string" in cents, never in Hz.
  8. Release the mic when you're not tuning. Stop the engine, remove the tap and deactivate the session when the screen goes away or the app backgrounds.
  9. Plan for iOS 27's tap API. Wrap the tap in one place, so moving to installAudioTap is a one-file change.

Wrapping up


A good tuner is a small pipeline done carefully: a measurement-mode session, a tap feeding a rolling window, autocorrelation with interpolation, clean note maths, and smoothing that knows when to let go. Accelerate does the heavy lifting in a handful of vDSP calls, and SwiftUI does the rest.

Try the app, or see the rest of my work:

If you've built pitch detection and preferred YIN, the McLeod method or a neural approach, I'd love to hear how it compared in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Building a Hashtag Copier for iOS: Clean Tags, Editable Lists, Clipboard Privacy and Instagram's New Five-Hashtag Limit


Originally published at bestlatestapps.com/blog/buildi…

How to normalise tags so they actually work, model bundled categories with user edits, copy to the pasteboard without tripping privacy prompts, and redesign a "copy 30 tags" app now that Instagram allows five

Hashtags Creator app icon

Hashtags Creator was one of my first App Store apps. The idea was simple: pick a category, and a list of popular hashtags is copied and ready to paste into Instagram. You can customise the lists, delete tags that don't fit, and copy them in one tap.

It's a small utility, but it's a good lens on three things every iOS developer runs into sooner or later. The first is text that has rules: a hashtag with a space or a $ in it silently stops being a hashtag. The second is the pasteboard, which has become one of iOS's most privacy-sensitive APIs. The third is platform rules that change under you: in December 2025, Instagram announced it would limit posts and reels to five hashtags, down from 30. Overnight, an app built around copying 30 tags needed a new design.

This article covers all three, with Swift that I've checked against Apple's documentation, and Instagram's rule taken from its own Help Center. Where I'm describing how I'd build something rather than how the shipped app works, I say so.


1. Normalise tags so they actually work


Instagram's Help Center is explicit: "Numbers are allowed in hashtags. However, spaces and special characters, like $ or %, won't work." Users type all sorts of things into a tag editor, like #Travel Photography, sunset!! or #café. So every tag should pass through one normaliser before it's stored or copied:

import Foundation

enum Hashtag {
    /// Turns user input into a usable tag, or nil if nothing usable remains.
    /// "Travel Photography!" -> "#travelphotography", "#café" -> "#café", "$$$" -> nil
    static func normalize(_ raw: String) -> String? {
        let body = raw
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .drop(while: { $0 == "#" })                        // tolerate a typed "#"
            .filter { $0.isLetter || $0.isNumber || $0 == "_" } // letters in any script, digits, underscore
            .lowercased()
        guard !body.isEmpty, body.contains(where: \.isLetter) else { return nil }  // "#2024" alone is rarely useful
        return "#" + body
    }

    /// Splits pasted text like "#a #b, c\n#d" into clean, de-duplicated tags, keeping order.
    static func parse(_ text: String) -> [String] {
        var seen = Set<String>()
        return text
            .components(separatedBy: CharacterSet.whitespacesAndNewlines.union(.init(charactersIn: ",;")))
            .compactMap(normalize)
            .filter { seen.insert($0).inserted }
    }
}

Three decisions worth explaining:
  • Character.isLetter instead of [a-z]. Hashtags work in Hindi, Japanese and Arabic, and an ASCII-only filter would quietly delete them. Swift's Character properties are Unicode-aware.
  • Lowercase for de-duplication. #Travel and #travel are the same tag to the platform, and keeping both wastes a slot. If you want to keep readable casing, like #TravelTips, store the display form and de-duplicate on the lowercased form.
  • Allowing underscores. These are widely used in tags. Instagram's help page only calls out spaces and symbols like $ and %, so test any other character you allow.

2. Bundled categories plus the user's edits


The listing promises "customize your lists, delete irrelevant tags". The clean way to model that is bundled defaults + a per-user overlay. You can then ship updated categories in an app update without wiping anyone's edits:

import Foundation

struct TagCategory: Codable, Identifiable, Hashable {
    let id: String              // stable: "travel", "food", "fitness"
    var name: String
    var tags: [String]
}

/// What the user changed, keyed by category id.
struct UserEdits: Codable {
    var added: [String: [String]] = [:]
    var removed: [String: Set<String>] = [:]
    var order: [String: [String]] = [:]     // optional manual ordering
}

final class TagLibrary: ObservableObject {
    @Published private(set) var categories: [TagCategory] = []
    @Published var edits = UserEdits() { didSet { save() } }

    private let editsURL = URL.applicationSupportDirectory.appending(path: "tag-edits.json")

    init() {
        let url = Bundle.main.url(forResource: "categories", withExtension: "json")!
        categories = (try? JSONDecoder().decode([TagCategory].self, from: Data(contentsOf: url))) ?? []
        if let data = try? Data(contentsOf: editsURL) {
            edits = (try? JSONDecoder().decode(UserEdits.self, from: data)) ?? UserEdits()
        }
    }

    /// Defaults minus removals, plus additions, in the user's order if they set one.
    func tags(for category: TagCategory) -> [String] {
        let removed = edits.removed[category.id] ?? []
        var merged = category.tags.filter { !removed.contains($0) } + (edits.added[category.id] ?? [])
        if let order = edits.order[category.id] {
            merged.sort { (order.firstIndex(of: $0) ?? .max) < (order.firstIndex(of: $1) ?? .max) }
        }
        return merged
    }

    func remove(_ tag: String, from category: TagCategory) {
        edits.added[category.id]?.removeAll { $0 == tag }
        edits.removed[category.id, default: []].insert(tag)
    }

    func add(_ raw: String, to category: TagCategory) {
        for tag in Hashtag.parse(raw) where !tags(for: category).contains(tag) {
            edits.removed[category.id]?.remove(tag)
            edits.added[category.id, default: []].append(tag)
        }
    }

    private func save() {
        try? FileManager.default.createDirectory(at: editsURL.deletingLastPathComponent(),
                                                 withIntermediateDirectories: true)
        try? JSONEncoder().encode(edits).write(to: editsURL, options: .atomic)
    }
}

The overlay is tiny, a few kilobytes of JSON. It also survives any content update, because the edits refer to stable category IDs and tag strings rather than to positions in a list.

3. An editable list in SwiftUI


With the library in place, the editing screen is mostly built-in SwiftUI behaviour: swipe to delete, an EditButton for reordering, and a field that accepts one tag or a pasted batch.

import SwiftUI

struct CategoryEditor: View {
    @ObservedObject var library: TagLibrary
    let category: TagCategory
    @State private var newTag = ""

    var body: some View {
        List {
            Section {
                HStack {
                    TextField("Add tags (space or comma separated)", text: $newTag)
                        .textInputAutocapitalization(.never)
                        .autocorrectionDisabled()
                        .onSubmit(addTags)
                    Button("Add", action: addTags).disabled(Hashtag.parse(newTag).isEmpty)
                }
            }
            Section("\(library.tags(for: category).count) tags") {
                ForEach(library.tags(for: category), id: \.self) { tag in
                    Text(tag).textSelection(.enabled)
                        .swipeActions {
                            Button("Delete", role: .destructive) { library.remove(tag, from: category) }
                        }
                }
                .onMove { from, to in
                    var current = library.tags(for: category)
                    current.move(fromOffsets: from, toOffset: to)
                    library.edits.order[category.id] = current
                }
            }
        }
        .navigationTitle(category.name)
        .toolbar { EditButton() }
    }

    private func addTags() { library.add(newTag, to: category); newTag = "" }
}

A note on versions. The shipped app supports iOS 12, which predates SwiftUI, so an app with that deployment target needs UIKit for its main screens. The SwiftUI here is the approach I'd use today; the model in section 2 works unchanged under either.

4. Copying to the pasteboard, carefully


Copying is one line, UIPasteboard.general.string = text, but setItems(_:options:) (iOS 10+) gives you two privacy options worth knowing about:

import UIKit
import UniformTypeIdentifiers

enum Clipboard {
    static func copy(_ tags: [String], expiresAfter seconds: TimeInterval? = nil, localOnly: Bool = false) {
        let text = tags.joined(separator: " ")
        var options: [UIPasteboard.OptionsKey: Any] = [:]
        if localOnly { options[.localOnly] = true }                 // don't sync via Universal Clipboard
        if let seconds { options[.expirationDate] = Date.now.addingTimeInterval(seconds) }

        UIPasteboard.general.setItems([[UTType.plainText.identifier: text]], options: options)
        UINotificationFeedbackGenerator().notificationOccurred(.success)
    }
}

  • .localOnly keeps the item off the user's other devices. For hashtags this hardly matters. For anything sensitive, like a one-time code, it should be the default.
  • .expirationDate makes the system clear the item later. It's useful when you copy something temporary and don't want it hanging around the clipboard all day.
  • Always confirm the copy. A haptic plus a brief "Copied 5 tags" banner. With no visual change, users tap "Copy" three times to be sure.

5. Reading the pasteboard: privacy prompts and how to avoid them


Writing to the pasteboard is silent. Reading it is not. Since iOS 14 the system shows a banner when an app reads another app's pasteboard content, and since iOS 16 a programmatic read without a clear user action triggers a permission alert. Apple's UIPasteboard documentation lists the properties you can use to avoid those prompts when the user hasn't shown intent, such as hasStrings, along with the pattern-detection methods.

So, for an "import tags from clipboard" feature:

import SwiftUI
import UniformTypeIdentifiers

struct ImportFromClipboard: View {
    @ObservedObject var library: TagLibrary
    let category: TagCategory

    var body: some View {
        // iOS 16+: the system paste button is user intent by definition, so there's no alert.
        PasteButton(payloadType: String.self) { strings in
            library.add(strings.joined(separator: " "), to: category)
        }
        .labelStyle(.titleAndIcon)
    }
}

// UIKit equivalent (iOS 16+): UIPasteControl, configured as the target of a paste action.
// To merely decide whether to *show* an import option, check
// UIPasteboard.general.hasStrings, which doesn't read the contents.

The design rule I follow: never read the pasteboard on launch or on appear. Plenty of apps once did that to "helpfully" detect a copied link, and the iOS 14 banner made it visible to everyone. Read only in response to a tap, and preferably through PasteButton or UIPasteControl, which carry the user's intent with them.

6. When the platform changes the rules: five hashtags


This is the part of the article I didn't expect to write. Instagram's Help Center now says: "You can use up to 5 tags on a post. If you include more than 5 tags on a single photo/video, your comment won't post." Instagram announced the change in December 2025, saying that "using fewer (up to 5), more targeted hashtags rather than many generic ones" helps performance. (Meta's developer documentation for publishing through its API still listed 30 when I checked. That's for scheduling tools, not for someone pasting into the app.)

For a hashtag app, that changes the core interaction from "copy the whole category" to "choose your five":

struct PickFive: View {
    let tags: [String]
    @State private var picked: [String] = []
    static let limit = 5

    var body: some View {
        List(tags, id: \.self) { tag in
            Button {
                if let i = picked.firstIndex(of: tag) { picked.remove(at: i) }
                else if picked.count < Self.limit { picked.append(tag) }
            } label: {
                HStack {
                    Text(tag)
                    Spacer()
                    if let i = picked.firstIndex(of: tag) { Text("\(i + 1)").bold() }   // show pick order
                }
            }
            .disabled(picked.count >= Self.limit && !picked.contains(tag))
        }
        .safeAreaInset(edge: .bottom) {
            Button("Copy \(picked.count) of \(Self.limit)") { Clipboard.copy(picked) }
                .buttonStyle(.borderedProminent)
                .disabled(picked.isEmpty)
                .padding()
        }
        .sensoryFeedback(.selection, trigger: picked)
    }
}

Lessons that apply well beyond hashtags:
  • Keep platform limits in one constant, ideally one you can update from a small remote config, not scattered as 30s through the code.
  • Make the limit visible. "Copy 3 of 5" teaches the rule without a tutorial.
  • Lean into the new guidance. "Fewer, more specific tags" is a better product anyway. A category list of the 50 most generic tags is now the wrong default; the approach I'd use puts specific, niche tags first.

7. Real-world example: Hashtags Creator


All of this is grounded in Hashtags Creator. The code above is the approach I'd use, written for this article, not the app's source.

Hashtags Creator App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Social Networking and Photo & Video
  • First released in July 2020
  • 2 ratings in the US store, both 5 stars
  • Supports iOS 12.0 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"most popular tags that could help you gain likes and followers"Bundled categories (section 2)
"You can customize your lists, delete irrelevant tags"User-edit overlay and an editable list (sections 2–3)
"copy and paste tags quickly"Pasteboard writes with options (section 4)
"Just select your category and tags are copied and ready to get posted in Instagram"The copy flow, and the five-tag redesign (sections 4 and 6)


Hashtags Creator App Store screenshot 2

Note that the listing's "copy a whole category" flow was written when Instagram allowed 30 tags. Section 6 is how I'd adapt it.


8. Before you ship a text-and-clipboard utility: a checklist


  1. Every tag passes through one normaliser, which handles non-Latin scripts, emoji input and pasted batches.
  2. De-duplication is case-insensitive.
  3. User edits live in an overlay, so content updates never wipe them.
  4. There's no pasteboard read on launch or on appear. Imports use PasteButton or UIPasteControl.
  5. Copies are confirmed with haptics and a visible message.
  6. Platform limits are a single, updatable constant, and the UI shows them.
  7. Test the real destination. Paste into Instagram (or wherever you target) on a current build, because platform rules move.
  8. Accessibility. VoiceOver reads tags sensibly; consider camel-casing display forms like #TravelTips for readability.

Wrapping up


Tiny utilities are where craft shows. Clean the input, respect the clipboard and design for the rules as they are today, not as they were when you shipped. Hashtags Creator started as a "copy 30 tags" app. Today, the most useful version of it is a "help me choose five good ones" app, and that's a better product.

Try the app, or explore my other work:

Has a platform rule change ever forced a redesign of your app? Tell me about it in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Building a Live Temple Darshan App for iOS: HLS Streaming, Picture-in-Picture, Time-Zone-Correct Aarti Reminders and Notification Actions


Originally published at bestlatestapps.com/blog/buildi…

Playing live and recorded video with AVPlayer, keeping darshan going in picture-in-picture, falling back gracefully when a stream is down, scheduling aarti reminders in the temple's time zone for devotees anywhere in the world, "Watch Now" and "Remind Later" actions, a next-aarti widget, and opening a temple in Maps the iOS 26 way

Mandir Darshan app icon

For many people, a daily visit to the temple is no longer a walk down the road. There are elderly parents who can't travel, and families abroad who miss the aarti they grew up with. That's the reason behind Mandir Darshan, my app for watching live darshan from India's temples, including Siddhivinayak, Kashi Vishwanath and Somnath. It sets aarti reminders, and it carries the Bhagavad Gita, chalisas, Panchang and more, with no sign-in.

I've written before about the offline audio side of devotional apps, in my Hanuman Chalisa article. This one is about the live side, which turns out to be a tour of some of the most practical media and notification APIs on iOS:

  • streaming HLS video with AVPlayer
  • picture-in-picture, so darshan keeps playing while you use other apps
  • falling back to recorded video when a live stream is unavailable
  • aarti reminders that fire at the right moment wherever the devotee lives
  • notification actions, a next-aarti widget, and opening a temple in Apple Maps with the new iOS 26 MapKit initialisers

The APIs are checked against Apple's documentation. Where I describe how I'd build something rather than the shipped code, I say so.


1. Model temples around their own time zone


The most important design decision isn't about video at all. Aarti times belong to the temple, not the phone. A 7:00 a.m. aarti in Varanasi is 7:00 a.m. India time, whether the person watching is in Pune, London or New Jersey. So the model stores times as wall-clock components plus the temple's time zone:

import Foundation
import CoreLocation

struct AartiTime: Codable, Hashable {
    let name: String              // "Mangala Aarti"
    let hour: Int, minute: Int    // wall-clock time at the temple
}

struct Temple: Codable, Identifiable, Hashable {
    let id: String
    let name: String
    let city: String
    let latitude: Double, longitude: Double
    let timeZoneID: String        // "Asia/Kolkata"
    let liveStreamURL: URL?       // HLS (.m3u8) when a live feed is available
    let recordedVideos: [URL]
    let aartis: [AartiTime]

    var timeZone: TimeZone { TimeZone(identifier: timeZoneID) ?? .current }
    var coordinate: CLLocationCoordinate2D { .init(latitude: latitude, longitude: longitude) }
}

extension Temple {
    /// The next occurrence of an aarti, as an absolute Date, computed in the temple's calendar.
    func nextOccurrence(of aarti: AartiTime, after now: Date = .now) -> Date? {
        var calendar = Calendar(identifier: .gregorian)
        calendar.timeZone = timeZone
        return calendar.nextDate(after: now,
                                 matching: DateComponents(hour: aarti.hour, minute: aarti.minute),
                                 matchingPolicy: .nextTime)
    }
}

With this in place, displaying "Mangala Aarti, 7:00 a.m. IST (9:30 p.m. your time)" is a formatting job, and every reminder in section 5 is correct by construction.

2. Playing live darshan with AVPlayer


Live temple streams are typically delivered as HLS, which AVPlayer plays natively. AVPlayerViewController gives you the system controls, AirPlay and picture-in-picture support for free:

import AVKit
import SwiftUI

struct DarshanPlayer: UIViewControllerRepresentable {
    let player: AVPlayer

    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let vc = AVPlayerViewController()
        vc.player = player
        vc.allowsPictureInPicturePlayback = true                    // iOS 9+
        vc.canStartPictureInPictureAutomaticallyFromInline = true   // iOS 14.2+: PiP when the app goes to background
        return vc
    }
    func updateUIViewController(_ vc: AVPlayerViewController, context: Context) {}
}

@MainActor
func makeLivePlayer(for url: URL) -> AVPlayer {
    // Playback category: audio continues with the silent switch on, and PiP is allowed.
    try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
    try? AVAudioSession.sharedInstance().setActive(true)

    let item = AVPlayerItem(url: url)
    item.preferredForwardBufferDuration = 6       // a little cushion against weak connections
    let player = AVPlayer(playerItem: item)
    player.automaticallyWaitsToMinimizeStalling = true
    return player
}

Three things are essential for picture-in-picture to work at all:
  1. The audio session category is .playback. With the default category, PiP won't start.
  2. The Background Modes capability includes "Audio, AirPlay, and Picture in Picture". Without it, the video stops when the app leaves the foreground.
  3. The player is shown in AVPlayerViewController (or an AVPlayerLayer managed by AVPictureInPictureController, iOS 9+, if you build custom controls).

canStartPictureInPictureAutomaticallyFromInline is the one that makes PiP feel effortless. Swipe home during darshan, and the video floats instead of stopping.


3. Bringing the app back from picture-in-picture


When the user taps the "return" button on the PiP window, iOS asks your app to restore its interface. If you don't, the video goes back into a screen that's no longer showing, and the experience breaks. The delegate method is where you navigate back:

final class PlayerCoordinator: NSObject, AVPlayerViewControllerDelegate {
    var restore: (() -> Void)?

    func playerViewController(_ playerViewController: AVPlayerViewController,
        restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
        restore?()                 // e.g. push the temple's darshan screen back onto the navigation stack
        completionHandler(true)
    }

    // Keep the player alive while PiP runs even if the user navigates away.
    func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ vc: AVPlayerViewController) -> Bool {
        false
    }
}

The approach I'd use is to keep the active AVPlayer in an app-level object, not in the darshan screen's state. Then PiP survives navigation, and "return to app" can rebuild the screen around the same player without a reload.

4. When the live stream is down: recorded darshan and playback speed


Live streams go offline: maintenance, network issues at the temple, or outside darshan hours. The listing promises "Recorded videos when live is unavailable", so failure has to be a planned state, not an error alert. I'd watch the item's status and fall back:

import AVFoundation
import Combine

@MainActor
final class DarshanSession: ObservableObject {
    @Published private(set) var isLive = false
    let player = AVPlayer()
    private var statusObservation: NSKeyValueObservation?

    func start(_ temple: Temple) {
        if let live = temple.liveStreamURL {
            play(live, isLive: true, fallback: temple.recordedVideos.first)
        } else if let recorded = temple.recordedVideos.first {
            play(recorded, isLive: false, fallback: nil)
        }
    }

    private func play(_ url: URL, isLive: Bool, fallback: URL?) {
        let item = AVPlayerItem(url: url)
        statusObservation = item.observe(\.status) { [weak self] item, _ in
            Task { @MainActor in
                guard let self else { return }
                switch item.status {
                case .readyToPlay: self.isLive = isLive; self.player.play()
                case .failed: if let fallback { self.play(fallback, isLive: false, fallback: nil) }
                default: break
                }
            }
        }
        player.replaceCurrentItem(with: item)
    }
}

Playback speed only makes sense for recorded video. On iOS 16+, AVPlayerViewController.speeds (an array of AVPlaybackSpeed) controls the speed menu, and AVPlayer.defaultRate sets the rate that play() uses. For a live stream, I'd set speeds = [] so the menu disappears. Fast-forwarding past "now" isn't possible, and showing the control would only confuse.

5. Aarti reminders that are right everywhere


"Aarti schedules with reminders 5, 10, 15, or 30 minutes before." This is where the temple-time-zone model pays off. UNCalendarNotificationTrigger (iOS 10+) matches date components, and those components can carry their own time zone:

import UserNotifications

enum AartiReminders {
    static let category = "AARTI_REMINDER"

    static func schedule(_ aarti: AartiTime, at temple: Temple, minutesBefore lead: Int) async throws {
        // Subtract the lead time in the temple's own calendar (handles 6:05 - 10 min = 5:55).
        var calendar = Calendar(identifier: .gregorian)
        calendar.timeZone = temple.timeZone
        let aartiToday = calendar.date(from: DateComponents(hour: aarti.hour, minute: aarti.minute))!
        let reminder = calendar.date(byAdding: .minute, value: -lead, to: aartiToday)!

        var components = calendar.dateComponents([.hour, .minute], from: reminder)
        components.timeZone = temple.timeZone            // fire at temple time, wherever the phone is

        let content = UNMutableNotificationContent()
        content.title = "\(aarti.name) at \(temple.name)"
        content.body = "Begins in \(lead) minutes. Tap to join the darshan."
        content.categoryIdentifier = category
        content.userInfo = ["templeID": temple.id]
        content.sound = .default

        let request = UNNotificationRequest(
            identifier: "aarti.\(temple.id).\(aarti.name)",          // stable: rescheduling replaces it
            content: content,
            trigger: UNCalendarNotificationTrigger(dateMatching: components, repeats: true))
        try await UNUserNotificationCenter.current().add(request)
    }
}

Why this works for a devotee in New Jersey: the trigger's components say "05:55 in Asia/Kolkata", so iOS fires it at the correct absolute moment, which locally is the previous evening. No manual offset maths, and no breakage when daylight saving starts or ends in the user's country (India doesn't observe it, but the US does).

Practical limits:

  • Use stable identifiers. Changing the lead time from 10 to 15 minutes re-adds a request with the same ID, which replaces the old one instead of duplicating it.
  • Watch the pending-notification cap. iOS keeps a limited number of scheduled local notifications per app (commonly cited as 64). Repeating daily triggers use one slot per aarti, which is why I'd prefer them over scheduling individual dates.
  • Time-sensitive interruption (interruptionLevel = .timeSensitive, iOS 15+) can break through Focus modes, but it requires the Time Sensitive Notifications capability and should be the user's choice.

6. "Watch Now" and "Remind Later"


The listing mentions two notification actions, "Watch Now" and "Remind Later". Both are a UNNotificationCategory registered at launch, plus a delegate that handles the response:

import UserNotifications

final class NotificationRouter: NSObject, UNUserNotificationCenterDelegate {
    var openTemple: ((String) -> Void)?

    func register() {
        let watch = UNNotificationAction(identifier: "WATCH_NOW", title: "Watch Now", options: [.foreground])
        let later = UNNotificationAction(identifier: "REMIND_LATER", title: "Remind Later", options: [])
        let category = UNNotificationCategory(identifier: AartiReminders.category,
                                              actions: [watch, later], intentIdentifiers: [])
        let center = UNUserNotificationCenter.current()
        center.setNotificationCategories([category])
        center.delegate = self
    }

    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse) async {
        let content = response.notification.request.content
        guard let templeID = content.userInfo["templeID"] as? String else { return }

        switch response.actionIdentifier {
        case "WATCH_NOW", UNNotificationDefaultActionIdentifier:
            await MainActor.run { openTemple?(templeID) }        // deep-link straight into the player
        case "REMIND_LATER":
            let again = UNNotificationRequest(
                identifier: "later.\(templeID).\(UUID().uuidString)",
                content: content,
                trigger: UNTimeIntervalNotificationTrigger(timeInterval: 5 * 60, repeats: false))
            try? await center.add(again)
        default: break
        }
    }

    // Show reminders even while the app is open (e.g. while reading the Gita).
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
        [.banner, .sound]
    }
}

.foreground on "Watch Now" opens the app; "Remind Later" works without opening it. Set the delegate before the app finishes launching (in the app delegate), or the response to a notification that launched the app can be missed.

7. A next-aarti widget and opening Maps


The same nextOccurrence(of:) function from section 1 powers a "next aarti" widget. A WidgetKit TimelineProvider (iOS 14+) can return one entry per upcoming aarti, so the widget updates itself at each one without the app running:

import WidgetKit

struct NextAartiEntry: TimelineEntry { let date: Date; let temple: String; let aarti: String; let at: Date }

func timeline(for temples: [Temple], now: Date = .now) -> Timeline<NextAartiEntry> {
    let upcoming = temples.flatMap { temple in
        temple.aartis.compactMap { a in temple.nextOccurrence(of: a, after: now).map { (temple.name, a.name, $0) } }
    }.sorted { $0.2 < $1.2 }.prefix(6)

    var entries: [NextAartiEntry] = []
    var shownFrom = now
    for (temple, aarti, at) in upcoming {
        entries.append(NextAartiEntry(date: shownFrom, temple: temple, aarti: aarti, at: at))
        shownFrom = at                                    // switch to the next one once this begins
    }
    return Timeline(entries: entries, policy: .after(shownFrom))
}

"Open in Maps" changed in iOS 26. Apple's documentation marks MKMapItem.init(placemark:) as deprecated in iOS 26, with new initialisers built on CLLocation and the new MKAddress type. Since the app supports iOS 17.6, I'd branch:
import MapKit

func openInMaps(_ temple: Temple) {
    let item: MKMapItem
    if #available(iOS 26, *) {
        let location = CLLocation(latitude: temple.latitude, longitude: temple.longitude)
        item = MKMapItem(location: location, address: MKAddress(fullAddress: "\(temple.name), \(temple.city)", shortAddress: temple.city))
    } else {
        item = MKMapItem(placemark: MKPlacemark(coordinate: temple.coordinate))
    }
    item.name = temple.name
    item.openInMaps(launchOptions: nil)                   // iOS 6+
}

8. Real-world example: Mandir Darshan


This article comes from building Mandir Darshan. The code above is the approach I'd use, written for this article, not a copy of the app's source.

Mandir Darshan App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Lifestyle and Utilities
  • First released in February 2026
  • 1 rating in the US store, 5 stars
  • Supports iOS 17.6 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Watch live streaming from famous temples across India"HLS with AVPlayer (section 2)
"picture-in-picture so you can keep darshan while using other apps"PiP setup and UI restore (sections 2–3)
"Recorded videos when live is unavailable; playback speed control"Status-based fallback and speeds (section 4)
"Aarti schedules with reminders 5, 10, 15, or 30 minutes before"Time-zone-aware calendar triggers (section 5)
"Notification actions: Watch Now or Remind Later"Categories and the delegate (section 6)
"Home Screen widgets: Live Darshan (live count/next aarti)…"A next-aarti timeline (section 7)
"Share temple or open in Maps"MKMapItem with the iOS 26 branch (section 7)
"Data stored on your device; no sign-in required"Everything here is local: notifications, favourites, widget data


Mandir Darshan App Store screenshot 2

The listing covers much more than this article does: the full Bhagavad Gita with Sanskrit, transliteration and translation, chalisas, Sunderkand, a Japa Mala counter, Panchang and festivals, English and Hindi, and voice search. Each of those could be its own article.


9. Before you ship a live-streaming devotional app: a checklist


  1. Times belong to the temple. Every schedule stores its own time zone, and reminders are tested with the phone set to the US and UK.
  2. PiP works. The audio session is .playback, the background mode is on, automatic PiP from inline is set, and UI restore is implemented.
  3. Stream failure is a state, not an error. There's a recorded fallback and a clear "Live" or "Recorded" badge.
  4. There's no speed control on live.
  5. Reminders use stable IDs and repeating triggers, and stay under the pending limit.
  6. The notification delegate is set at launch, and "Watch Now" deep-links into the right temple.
  7. The widget timeline advances at each aarti and has a sensible refresh policy.
  8. MapKit is branched for iOS 26. No deprecated initialisers on new systems.
  9. Respect and accuracy. Temple names, deity information and aarti times are checked, and the app never shows a stream it doesn't have the right to show.
  10. Be mindful of data use. Live video on cellular can be expensive; consider a Wi-Fi-only option.

Wrapping up


The technology in a darshan app is ordinary: AVPlayer, local notifications, WidgetKit and MapKit. What makes it work for real people is getting the human details right. An aarti happens at temple time, a stream can be down, and a devotee might be watching from the other side of the world in a floating window while they do something else. Model those details first, and the APIs fall into place.

Try the app, or explore my other work:

Which temple's darshan would you most like to see in the app? Let me know in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Building a Live iOS Component Catalogue: UIKit and SwiftUI Side by Side, Real Demos, and Code Snippets That Don't Rot


Originally published at bestlatestapps.com/blog/buildi…

One registry for every component, hosting UIKit inside SwiftUI with UIViewRepresentable, wrapping the system pickers, comparing UIKit and SwiftUI versions on one screen, tag filters, search, favourites and recently viewed, and keeping copyable snippets honest

Catalog

Every iOS developer has done this: you need a segmented control, or a date picker in compact style, or a blurred toolbar, and you can't remember exactly how it looks or what the defaults are. So you open a scratch project, type the code, run the simulator and squint. Then you do it again a month later for a different control.

Catalog - UI is my answer to that loop: a reference app where you can browse and try iOS UI components in one place. It includes controls, pickers and views in UIKit, parallel demos for key SwiftUI components, live demos, copyable code snippets, tag filters, search, favourites and recently viewed, and no account.

A catalogue app is also a surprisingly good architecture exercise. You need a data model that scales to dozens of components, a way to host UIKit views inside SwiftUI (or the other way round), wrappers for the system pickers that are really view controllers, and a way to make sure the code you let people copy actually matches what the demo does. This article covers how I'd build each of those, with APIs checked against Apple's documentation.


1. One registry to rule them all


The catalogue is driven by data, not by a giant switch statement. Each component is one value that knows its name, its tags, how to build its demos and what code to show:

import SwiftUI
import UIKit

enum CatalogTag: String, CaseIterable, Codable {
    case uiKit = "UIKit", controls = "Controls", views = "Views", pickers = "Pickers", swiftUI = "SwiftUI"
}

struct CatalogItem: Identifiable {
    let id: String                              // stable: "slider", "date-picker"
    let title: String
    let summary: String
    let tags: Set<CatalogTag>
    let uiKitDemo: (() -> UIView)?              // nil if SwiftUI-only
    let swiftUIDemo: (() -> AnyView)?           // nil if UIKit-only
    let snippetFiles: [String]                  // resource names, e.g. ["Slider.UIKit", "Slider.SwiftUI"]
}

enum Catalog {
    static let all: [CatalogItem] = [
        CatalogItem(
            id: "slider", title: "Slider",
            summary: "Choose a value from a continuous range.",
            tags: [.uiKit, .controls, .swiftUI],
            uiKitDemo: {
                let slider = UISlider()
                slider.minimumValue = 0; slider.maximumValue = 100; slider.value = 40
                slider.minimumTrackTintColor = .systemIndigo
                return slider
            },
            swiftUIDemo: { AnyView(SliderDemo()) },
            snippetFiles: ["Slider.UIKit", "Slider.SwiftUI"]
        ),
        // … buttons, menus, segmented controls, switches, steppers, text fields, search bars …
    ]
}

private struct SliderDemo: View {
    @State private var value = 40.0
    var body: some View {
        Slider(value: $value, in: 0...100) { Text("Value") }.tint(.indigo)
    }
}

Adding a component is now one entry, and every screen (list, search, favourites, detail) works from this one array. The uiKitDemo and swiftUIDemo closures keep construction lazy, so nothing is built until you open it.

2. Hosting UIKit demos in a SwiftUI shell


I'd write the shell (navigation, lists, search) in SwiftUI and host UIKit demos inside it. UIViewRepresentable (iOS 13+) does the bridging, and iOS 16's sizeThatFits(_:uiView:context:) finally lets a UIKit view tell SwiftUI how big it wants to be:

import SwiftUI

struct UIKitDemoHost: UIViewRepresentable {
    let make: () -> UIView

    func makeUIView(context: Context) -> UIView { make() }
    func updateUIView(_ uiView: UIView, context: Context) {}

    // iOS 16+: respect the width SwiftUI offers, and use the view's own fitting height.
    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? {
        let width = proposal.width ?? UIView.layoutFittingExpandedSize.width
        let fitted = uiView.systemLayoutSizeFitting(
            CGSize(width: width, height: UIView.layoutFittingCompressedSize.height),
            withHorizontalFittingPriority: .required,
            verticalFittingPriority: .fittingSizeLevel)
        return CGSize(width: width, height: max(fitted.height, uiView.intrinsicContentSize.height))
    }
}

Without sizeThatFits, many UIKit controls inside SwiftUI either collapse to zero height or stretch to fill everything. It's the most common "why does my bridged view look wrong" bug, and a catalogue shows it on every screen.

If your shell is UIKit instead, the mirror image is UIHostingController (iOS 13+) for embedding the SwiftUI demos. Both directions are first-class.


3. UIKit and SwiftUI, side by side


"Compare UIKit and SwiftUI side by side" is the feature I use most myself. With the registry and host in place, the detail screen is a simple two-up layout with labelled panes:

struct ComponentDetail: View {
    let item: CatalogItem
    @State private var showCode = false

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 24) {
                Text(item.summary).foregroundStyle(.secondary)

                if let uiKit = item.uiKitDemo {
                    DemoPane(title: "UIKit") { UIKitDemoHost(make: uiKit) }
                }
                if let swiftUI = item.swiftUIDemo {
                    DemoPane(title: "SwiftUI") { swiftUI() }
                }
                Button("View code") { showCode = true }.buttonStyle(.bordered)
            }
            .padding()
        }
        .navigationTitle(item.title)
        .sheet(isPresented: $showCode) { SnippetSheet(files: item.snippetFiles) }
        .onAppear { RecentlyViewed.shared.record(item.id) }
    }
}

struct DemoPane<Content: View>: View {
    let title: String
    @ViewBuilder var content: Content
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title).font(.caption.weight(.semibold)).foregroundStyle(.secondary)
            content
                .padding()
                .frame(maxWidth: .infinity)
                .background(.background.secondary, in: .rect(cornerRadius: 12))
        }
    }
}

Seeing them together teaches things documentation doesn't. A SwiftUI Slider picks up the accent colour, while UISlider has its own tint properties. And SwiftUI's Toggle and UIKit's UISwitch look the same, but they differ in how labels and accessibility are attached.

4. The pickers are view controllers, so wrap them properly


Colour, font and image pickers in UIKit aren't views; they're view controllers with delegates. UIColorPickerViewController (iOS 14), UIFontPickerViewController (iOS 13) and PHPickerViewController (iOS 14) all follow the same wrapping pattern, UIViewControllerRepresentable plus a coordinator:

import SwiftUI
import UIKit

struct UIKitColorPicker: UIViewControllerRepresentable {
    @Binding var color: Color

    func makeUIViewController(context: Context) -> UIColorPickerViewController {
        let picker = UIColorPickerViewController()
        picker.supportsAlpha = true
        picker.delegate = context.coordinator
        return picker
    }
    func updateUIViewController(_ vc: UIColorPickerViewController, context: Context) {
        vc.selectedColor = UIColor(color)
    }
    func makeCoordinator() -> Coordinator { Coordinator(self) }

    final class Coordinator: NSObject, UIColorPickerViewControllerDelegate {
        let parent: UIKitColorPicker
        init(_ parent: UIKitColorPicker) { self.parent = parent }

        // Called repeatedly while dragging (continuously == true) and once at the end.
        func colorPickerViewController(_ vc: UIColorPickerViewController,
                                       didSelect color: UIColor, continuously: Bool) {
            parent.color = Color(color)
        }
    }
}

The SwiftUI parallel is one line, ColorPicker("Tint", selection: $color) (iOS 14), which makes the comparison instructive. The same goes for images: PhotosPicker (iOS 16, PhotosUI) on the SwiftUI side versus PHPickerViewController on the UIKit side. Both run out of process and need no photo-library permission prompt, which is a detail worth surfacing in a reference app, because developers still add NSPhotoLibraryUsageDescription "just in case".

For the date picker demo, preferredDatePickerStyle (iOS 13.4) is the property to show off. .compact, .inline and .wheels are almost three different controls.


5. Code snippets that don't rot


A catalogue's worst failure is a snippet that doesn't match the demo, or doesn't compile. The approach I'd use is to make snippets real Swift files in the project, compiled as part of a build target, and load their text from the bundle at runtime:

Snippets/
  Slider.UIKit.swift        // compiled in a "Snippets" target; also copied as a resource
  Slider.SwiftUI.swift
struct SnippetSheet: View {
    let files: [String]
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            List(files, id: \.self) { name in
                Section(name.components(separatedBy: ".").last ?? name) {
                    let code = Self.load(name)
                    Text(code)
                        .font(.system(.footnote, design: .monospaced))
                        .textSelection(.enabled)
                    Button("Copy") { UIPasteboard.general.string = code }
                }
            }
            .navigationTitle("Code")
            .toolbar { Button("Done") { dismiss() } }
        }
    }

    static func load(_ name: String) -> String {
        guard let url = Bundle.main.url(forResource: name, withExtension: "swift.txt"),
              let text = try? String(contentsOf: url, encoding: .utf8) else { return "// Snippet missing" }
        return text
    }
}

The build setup: the .swift files compile in a small framework target, so a broken snippet breaks the build. A Run Script phase copies them into the app bundle as .swift.txt resources. Then:
  • Every snippet compiles, because the build proves it on every change.
  • The demo can call the same code. The uiKitDemo closure can call a function defined in the snippet file, so what you see is literally what you copy.
  • A unit test can assert that every CatalogItem has its snippet files in the bundle.

It's a little build plumbing for a lot of trust.


6. Tags, search, favourites and recently viewed


With everything in one registry, discovery features are a few lines each. Tags are a filter on a Set, search is a case-insensitive match, and favourites and recents are small ID lists in @AppStorage:

struct CatalogList: View {
    @State private var query = ""
    @State private var selectedTag: CatalogTag?
    @AppStorage("favorites") private var favoritesRaw = ""

    private var favorites: Set<String> { Set(favoritesRaw.split(separator: ",").map(String.init)) }

    private var results: [CatalogItem] {
        Catalog.all.filter { item in
            (selectedTag.map { item.tags.contains($0) } ?? true) &&
            (query.isEmpty || item.title.localizedStandardContains(query))
        }
    }

    var body: some View {
        NavigationStack {
            List(results) { item in
                NavigationLink(item.title) { ComponentDetail(item: item) }
                    .swipeActions { Button(favorites.contains(item.id) ? "Unfavorite" : "Favorite") { toggle(item.id) } }
            }
            .searchable(text: $query, prompt: "Search components")
            .toolbar {
                Menu("Filter") {
                    Button("All") { selectedTag = nil }
                    ForEach(CatalogTag.allCases, id: \.self) { tag in Button(tag.rawValue) { selectedTag = tag } }
                }
            }
            .navigationTitle("Catalog")
        }
    }

    private func toggle(_ id: String) {
        var set = favorites
        if !set.insert(id).inserted { set.remove(id) }
        favoritesRaw = set.sorted().joined(separator: ",")
    }
}

@MainActor
final class RecentlyViewed {
    static let shared = RecentlyViewed()
    private let key = "recentlyViewed"
    func record(_ id: String) {
        var ids = UserDefaults.standard.stringArray(forKey: key) ?? []
        ids.removeAll { $0 == id }
        ids.insert(id, at: 0)
        UserDefaults.standard.set(Array(ids.prefix(15)), forKey: key)
    }
}

Storing IDs, not titles or indices, means renaming a component or reordering the catalogue never breaks anyone's favourites. And "no account required" is a natural result: everything here is local.

7. The interesting new entry: WebView


The listing includes WebView among its views, and that entry now has a genuinely new SwiftUI side. For years, web content in SwiftUI meant wrapping WKWebView (iOS 8) in a UIViewRepresentable. Apple's current documentation lists a native SwiftUI WebView and a WebPage model in WebKit, both iOS 26:

import SwiftUI
import WebKit

struct WebDemo: View {
    var body: some View {
        if #available(iOS 26, *) {
            WebView(url: URL(string: "https://developer.apple.com")!)   // native SwiftUI (WebKit, iOS 26)
        } else {
            LegacyWebView(url: URL(string: "https://developer.apple.com")!)
        }
    }
}

struct LegacyWebView: UIViewRepresentable {
    let url: URL
    func makeUIView(context: Context) -> WKWebView {
        let web = WKWebView()
        web.load(URLRequest(url: url))
        return web
    }
    func updateUIView(_ uiView: WKWebView, context: Context) {}
}

This is exactly what a catalogue is for. The same component now has three entries worth comparing: UIKit WKWebView, the wrapped version, and the native SwiftUI view, each behind the right availability check.

8. Real-world example: Catalog - UI


This architecture comes from building Catalog - UI. The code above is the approach I'd use, written for this article, not a copy of the shipped source.

Catalog

At the time of writing, its App Store listing shows:

  • Free, in Reference and Education
  • First released in March 2023
  • 2 ratings in the US store, averaging 3 stars
  • Supports iOS 17.6 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Controls — Buttons, menus, segmented controls, sliders, switches…"The registry (section 1)
"Tap any component to see a live demo"UIKit hosting with sizeThatFits (section 2)
"Compare UIKit and SwiftUI side by side" / "Parallel demos for key SwiftUI components"Two-pane detail (section 3)
"Pickers — Date picker, color picker, font picker, image picker, custom picker view"View-controller pickers wrapped with coordinators (section 4)
"View code snippets and copy them into your project"Compiled snippet files (section 5)
"Filter by tag…", "Search the full catalogue by name", "Favorites and… Recently Viewed"Discovery features (section 6)
"Views — … visual effects, WebView"WKWebView vs SwiftUI WebView (section 7)


Catalog

The rating average comes from only two ratings, so it doesn't say much. For a reference app, the feedback that matters most is "this snippet didn't work", and that's exactly what section 5 is designed to prevent.


9. Before you ship a reference or catalogue app: a checklist


  1. One registry drives every screen. There's no per-component switch statements.
  2. Bridged UIKit views size correctly. sizeThatFits is implemented and checked in Dynamic Type sizes.
  3. Pickers use coordinators and report continuous changes where the API offers them.
  4. Snippets compile in CI, and a test checks every item has its snippet.
  5. Availability is explicit. iOS 26-only demos (like SwiftUI WebView) are gated and labelled.
  6. Favourites and recents store IDs, capped in size.
  7. Every demo works in Dark Mode, at large text sizes and with VoiceOver. A reference app should model good behaviour.
  8. No account and no tracking are needed for a reference tool.

Wrapping up


A component catalogue is a mirror held up to the platform. Building one forces you to understand how UIKit and SwiftUI really fit together: sizing across the bridge, view-controller pickers, availability and the small default differences that trip up real apps. Make the registry the single source of truth, make snippets compile and make every demo honest.

Try the app, or explore my other work:

Which component should every iOS catalogue include that most leave out? Tell me in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Building a Low-Latency Virtual Piano and Drum Kit on iOS with AVAudioEngine


Originally published at bestlatestapps.com/blog/buildi…

Samplers for pitched instruments, a player-node pool for drums, a multi-touch keyboard that handles chords and glissandos, and note names for beginners

Drums n Piano app icon

The test for a virtual instrument is brutal and immediate: tap a key, and the sound has to arrive with your finger, not after it. Play a chord and you expect three notes, not one. Drag your finger along the keyboard and you expect a glissando, not silence. Hit the snare four times quickly and you expect four hits, not one hit cut off three times.

My app Drums n Piano puts a piano, a drum kit and a xylophone on your phone. The listing promises an instrument that "sounds real, just like the original instrument!", and it's also meant for learning: it teaches beginners "the names & positions of notes on the piano".

This article covers how I'd build the audio and touch parts of an app like that on iOS. That means configuring the session for low latency, using AVAudioUnitSampler for pitched instruments, a pool of AVAudioPlayerNodes for one-shot drum samples, a custom multi-touch keyboard view, and labelling notes for learners. The app supports iOS 12 and later, so the touch layer is UIKit, and I'll explain why that's the right call even today. API names and availability are checked against Apple's documentation, and this is the approach I'd use rather than a tour of the app's source.


1. The three things a virtual instrument must get right


  1. Latency. The gap between touch and sound. Part of it is the audio buffer, and part is your own code: loading a file on tap, or allocating while the user is playing.
  2. Polyphony. Several sounds at once: chords on the piano, overlapping cymbal and kick, a fast roll on the snare.
  3. Touch handling. Many fingers at once, sliding between keys, and no gesture delays.

None of these are hard individually. They get hard when the first prototype plays a sound file with AVAudioPlayer on each tap. That loads, decodes and allocates on the main thread, can't overlap itself, and has noticeably more delay than the engine-based approaches below.


2. Session setup for an instrument

import AVFoundation

enum InstrumentSession {
    static func activate() throws {
        let session = AVAudioSession.sharedInstance()
        // .playback: the instrument IS the app, so play even with the Silent switch on.
        // .mixWithOthers: let people play along with music from another app.
        try session.setCategory(.playback, mode: .default, options: [.mixWithOthers])
        try session.setPreferredIOBufferDuration(0.005)   // ask for ~5 ms; the system decides
        try session.setActive(true)
        print("IO buffer: \(session.ioBufferDuration * 1000) ms, rate: \(session.sampleRate) Hz")
    }
}

Two decisions here deserve a sentence each:
  • .playback vs .ambient. .ambient respects the Silent switch, which suits a game's background music. For an instrument, silence when the switch is on looks like a broken app, so I use .playback.
  • A small preferred buffer reduces output latency. It's a preference: read back ioBufferDuration to see what you actually got, and expect Bluetooth headphones to add delay you can't remove. Wired headphones or the built-in speaker are best for playing in time.

3. Pitched instruments: AVAudioUnitSampler


For piano and xylophone, you don't want one audio file per tap. You want a sampler: an instrument that receives MIDI-style "note on/off" messages and plays the right sample, pitch-shifting between recorded notes if needed. AVAudioUnitSampler (iOS 8+) is Apple's sampler, wrapped as an engine node.

import AVFoundation

final class PitchedInstrument {
    private let engine: AVAudioEngine
    private let sampler = AVAudioUnitSampler()

    init(engine: AVAudioEngine) {
        self.engine = engine
        engine.attach(sampler)
        engine.connect(sampler, to: engine.mainMixerNode, format: nil)
    }

    /// Load a SoundFont (.sf2) or DLS bank shipped in the bundle.
    func loadSoundBank(named name: String, program: UInt8 = 0) throws {
        guard let url = Bundle.main.url(forResource: name, withExtension: "sf2") else {
            throw CocoaError(.fileNoSuchFile)
        }
        try sampler.loadSoundBankInstrument(
            at: url, program: program,
            bankMSB: UInt8(kAUSampler_DefaultMelodicBankMSB),
            bankLSB: UInt8(kAUSampler_DefaultBankLSB))
    }

    /// Or: load individual samples (e.g. one recording every few semitones).
    func loadSamples(_ urls: [URL]) throws {
        try sampler.loadAudioFiles(at: urls)
    }

    func noteOn(_ midi: UInt8, velocity: UInt8 = 100) {
        sampler.startNote(midi, withVelocity: velocity, onChannel: 0)
    }

    func noteOff(_ midi: UInt8) {
        sampler.stopNote(midi, onChannel: 0)
    }
}

Why this is the right tool:
  • Polyphony is built in. Ten fingers, ten notes. The sampler manages voices for you.
  • Note-off is real. Releasing a key triggers the sample's release, as on a real piano, rather than a hard cut.
  • Loading happens once, at launch or when the user switches instrument, never on the tap.

Where do the sounds come from? Either a SoundFont you have the rights to ship, or your own recordings, with one sample every few semitones loaded via loadAudioFiles(at:). Sample quality is the single biggest factor in "sounds real", and it's a licensing question as much as a technical one: check the licence of any sound bank before you bundle it.


4. Drums: preloaded buffers and a player-node pool


Drums are different. There's no pitch, every hit is a one-shot, and the same pad is hit repeatedly and fast. You could map drums onto a sampler (General MIDI percussion is exactly that), but a simple, very controllable approach is:

  1. Decode each drum sample into an AVAudioPCMBuffer once, at launch.
  2. Keep a small pool of AVAudioPlayerNodes per pad.
  3. On each hit, schedule the buffer on the next node in the pool, round-robin.


import AVFoundation

final class DrumKit {
    private let engine: AVAudioEngine
    private var buffers: [String: AVAudioPCMBuffer] = [:]
    private var pools: [String: [AVAudioPlayerNode]] = [:]
    private var nextIndex: [String: Int] = [:]
    private let voicesPerPad = 4     // how many overlapping hits per pad

    init(engine: AVAudioEngine) { self.engine = engine }

    /// Call once, before starting the engine.
    func load(pads: [String]) throws {
        for pad in pads {
            guard let url = Bundle.main.url(forResource: pad, withExtension: "caf") else {
                throw CocoaError(.fileNoSuchFile)
            }
            let file = try AVAudioFile(forReading: url)
            guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat,
                                                frameCapacity: AVAudioFrameCount(file.length)) else {
                throw CocoaError(.fileReadCorruptFile)
            }
            try file.read(into: buffer)
            buffers[pad] = buffer

            pools[pad] = (0..<voicesPerPad).map { _ in
                let node = AVAudioPlayerNode()
                engine.attach(node)
                engine.connect(node, to: engine.mainMixerNode, format: buffer.format)
                return node
            }
            nextIndex[pad] = 0
        }
    }

    /// Call after engine.start().
    func startNodes() {
        pools.values.flatMap { $0 }.forEach { $0.play() }
    }

    func hit(_ pad: String, velocity: Float = 1.0) {
        guard let buffer = buffers[pad], let pool = pools[pad], let i = nextIndex[pad] else { return }
        let node = pool[i]
        nextIndex[pad] = (i + 1) % pool.count
        node.volume = velocity
        // .interrupts: if this voice is still ringing from 4 hits ago, restart it cleanly.
        node.scheduleBuffer(buffer, at: nil, options: .interrupts, completionHandler: nil)
    }
}

The whole setup:
let engine = AVAudioEngine()
let piano = PitchedInstrument(engine: engine)
let drums = DrumKit(engine: engine)

try InstrumentSession.activate()
try piano.loadSoundBank(named: "Piano")
try drums.load(pads: ["kick", "snare", "hihat-closed", "hihat-open", "crash", "tom-high", "tom-low"])
engine.prepare()
try engine.start()
drums.startNodes()

Why a pool? A single player node plays one schedule queue. Scheduling a new hit with .interrupts on a single node cuts the previous hit off, and that's audible on cymbals. With four voices per pad, four overlapping hits all ring naturally, and only the fifth recycles the oldest voice. Ship samples as uncompressed .caf or .wav so decoding at launch is quick and exact. They're short, so the size cost is small.

5. A multi-touch keyboard that feels like a keyboard


This is where many SwiftUI prototypes fall short. A Button per key fires on touch-up, which is far too late for an instrument. A DragGesture per key doesn't hand off to the next key when the finger slides. And coordinating several simultaneous fingers through SwiftUI gestures is awkward.

The direct approach is one UIKit view for the whole keyboard that tracks every UITouch itself:

import UIKit

final class KeyboardView: UIView {
    var firstMidi: UInt8 = 60           // C4
    var octaves = 2
    var onNoteOn: ((UInt8) -> Void)?
    var onNoteOff: ((UInt8) -> Void)?

    private var touchNotes: [ObjectIdentifier: UInt8] = [:]   // which finger holds which note

    override init(frame: CGRect) {
        super.init(frame: frame)
        isMultipleTouchEnabled = true      // essential: off by default
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    // MARK: Touch tracking

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        for t in touches { press(t) }
    }

    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        for t in touches {
            let id = ObjectIdentifier(t)
            let newNote = note(at: t.location(in: self))
            guard newNote != touchNotes[id] else { continue }    // still on the same key
            if let old = touchNotes[id] { onNoteOff?(old) }       // glissando: release old…
            touchNotes[id] = newNote
            if let n = newNote { onNoteOn?(n) }                   // …and press new
            setNeedsDisplay()
        }
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        for t in touches { release(t) }
    }

    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        for t in touches { release(t) }     // never leave a note stuck on
    }

    private func press(_ t: UITouch) {
        guard let n = note(at: t.location(in: self)) else { return }
        touchNotes[ObjectIdentifier(t)] = n
        onNoteOn?(n)
        setNeedsDisplay()
    }

    private func release(_ t: UITouch) {
        if let n = touchNotes.removeValue(forKey: ObjectIdentifier(t)) { onNoteOff?(n) }
        setNeedsDisplay()
    }

    // MARK: Geometry

    private static let whiteOffsets: [UInt8] = [0, 2, 4, 5, 7, 9, 11]          // C D E F G A B
    private static let blackOffsets: [(after: Int, semitone: UInt8)] =
        [(0, 1), (1, 3), (3, 6), (4, 8), (5, 10)]                               // C# D# F# G# A#

    private var whiteKeyWidth: CGFloat { bounds.width / CGFloat(7 * octaves) }

    /// Black keys sit on top, so test them first.
    func note(at p: CGPoint) -> UInt8? {
        guard bounds.contains(p) else { return nil }
        let w = whiteKeyWidth
        if p.y < bounds.height * 0.6 {
            for octave in 0..<octaves {
                for (after, semitone) in Self.blackOffsets {
                    let x = CGFloat(octave * 7 + after + 1) * w - w * 0.3
                    if CGRect(x: x, y: 0, width: w * 0.6, height: bounds.height * 0.6).contains(p) {
                        return firstMidi + UInt8(octave * 12) + semitone
                    }
                }
            }
        }
        let index = Int(p.x / w)
        let octave = index / 7, step = index % 7
        return firstMidi + UInt8(octave * 12) + Self.whiteOffsets[step]
    }
}

The essential details:
  • isMultipleTouchEnabled = true. It's false by default, and without it you'll only ever get one finger.
  • Track notes per touch with ObjectIdentifier(touch). Each finger owns its note, so chords and glissandos work independently.
  • Handle touchesCancelled. A system gesture or an incoming call can cancel touches. If you don't release notes there, you get stuck notes.
  • Black keys are hit-tested first, because they're drawn on top.

On iOS 12 this is the only option, since SwiftUI needs iOS 13. Even on a SwiftUI app today, I'd wrap this view in a UIViewRepresentable. Touch handling for an instrument is one of the places where UIKit is still simply the better tool.

Drum pads follow the same idea in simpler form: a grid of rectangles, touchesBegan triggers hit(_:), and there's no note-off.


6. Teaching note names and positions


The listing describes the app as "teaching beginners the names & positions of notes on the piano". On the keyboard view, that's two small things: draw a label on each key, and let learners switch between naming systems.

enum NoteNaming: String, CaseIterable {
    case letters      // C D E F G A B
    case solfege      // Do Re Mi Fa Sol La Si

    static let letterNames = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]
    static let solfegeNames = ["Do", "Do♯", "Re", "Re♯", "Mi", "Fa", "Fa♯", "Sol", "Sol♯", "La", "La♯", "Si"]

    func name(for midi: UInt8, withOctave: Bool = true) -> String {
        let pitchClass = Int(midi) % 12
        let base = self == .letters ? Self.letterNames[pitchClass] : Self.solfegeNames[pitchClass]
        return withOctave ? "\(base)\(Int(midi) / 12 - 1)" : base      // MIDI 60 = C4
    }
}

// NoteNaming.letters.name(for: 60) == "C4"; NoteNaming.solfege.name(for: 64) == "Mi4"

In draw(_:), draw white-key labels near the bottom of each key and black-key labels only when a "show sharps" toggle is on; for beginners, too many labels is noise. Highlighting the pressed key matters more than any label. The immediate visual "this is the key you just played" is how position is learned.

7. Real-world example: Drums n Piano


This is the approach I'd use, and recommend, for Drums n Piano.

Drums n Piano App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Music and Entertainment
  • First released in September 2022
  • 9 ratings in the US store, averaging about 4.4 stars
  • Supports iOS 12.0 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Simply play and learn the piano, drums or xylophone wherever you are"A sampler per pitched instrument; a player-node pool for drums (sections 3–4)
"This virtual app sounds real, just like the original instrument!" / "Premium sound quality"Real samples loaded once, a low-latency session, natural note-off releases (sections 2–3)
"Teaching beginners the names & positions of notes on the piano"Note labels and pressed-key highlighting (section 6)
"Play or learn your music chords"Per-touch tracking with multi-touch enabled (section 5)


Drums n Piano App Store screenshot 2

The listing's audience is broad: "a music teacher, singer, songwriter, or beginner or joyful kid". That's a good reminder that kids mash several keys with their whole hand. A multi-touch view that never leaves a stuck note is worth more than any feature.


8. Before you ship a virtual instrument: a checklist


  1. Nothing loads on tap. Samples and sound banks load at launch or on instrument switch; taps only trigger notes.
  2. The session is .playback (or a deliberate .ambient), with a small preferred buffer, and you've logged what you actually got.
  3. Polyphony is tested. Ten-finger chords on the piano; rapid repeated hits on every drum pad.
  4. There are no stuck notes. Test cancelled touches, a phone call mid-chord, backgrounding mid-note and Control Center swipes.
  5. isMultipleTouchEnabled is on, and each touch owns its note.
  6. Glissando works: sliding across keys releases and presses notes as you go.
  7. Sample licences are cleared for everything you bundle.
  8. You've tested on real hardware with wired and Bluetooth audio, and the app tells users that wired is tighter.

Wrapping up


A virtual instrument is mostly preparation: decode everything up front, pick the right player for the job (a sampler for pitched instruments, a node pool for one-shots), keep the audio buffer small, and track every finger yourself. Get those right, and the sound arrives with the touch, which is all a player really asks for.

Play it, or explore my other apps:

If you've built an instrument app and found a better way to handle touches or voices, tell me about it in the comments. I'm especially curious about anyone doing velocity from touch force or radius.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Building a Real-Time Mic-to-Speaker App on iOS with AVAudioEngine


Originally published at bestlatestapps.com/blog/buildi…

Live audio passthrough in Swift: session setup, permissions, feedback, interruptions and route changes, from shipping an app that does exactly this

Virtual Mic app icon

Most audio tutorials on iOS cover one of two things: playing a file, or recording one. Very few cover the strange in-between case where the microphone and the speaker are both live at the same time, and whatever goes in comes straight back out.

That in-between case is exactly what my app Mic to Speaker – Virtual Mic does. You plug your iPhone into a speaker, or connect over Bluetooth or AirPlay, tap the screen, and your voice comes out of the speaker. I first shipped it in May 2020. Since then it has collected more than 3,400 ratings on the US App Store, and it is still the most popular of the 24 free apps I have published under my developer name.

It looks like a weekend project, and the first working version really is only a few dozen lines of Swift. But the gap between "it works on my desk" and "it works for thousands of people on random speakers, in cars, during phone calls" is where the real work lives.

This is the guide I wish I'd had: permissions, session setup, the engine graph, feedback, interruptions, route changes, background audio and App Store review. I've checked API names and availability against Apple's developer documentation and flag what's new or recently changed.


1. The problem: what "mic to speaker" really needs


On paper the requirement is one sentence: take audio from the microphone and play it through the speaker, right now. In practice that sentence hides three hard requirements.

Low latency. When you hear your own voice back, your brain notices delay quickly. A little sounds "roomy"; more sounds like an echo and makes it hard to keep talking. Every buffer between mic and speaker adds delay, so you want the smallest buffers the device can reliably handle.

The right output route. By default, when an iPhone records and plays at the same time, output goes to the receiver, the small earpiece you hold to your ear for calls. Users expect the loud bottom speaker, their Bluetooth speaker, the car stereo or an AirPlay device. Getting audio to the right place, and keeping it there, is half the job.

No howling feedback. If the mic can hear the speaker, the sound loops and grows into that painful squeal everyone knows from school assemblies. On a phone, with mic and speaker centimetres apart, that's the default outcome unless you plan for it.

I underestimated all three. My first build played through the earpiece. The second howled as soon as I turned the volume up. The third worked beautifully until a phone call came in, then stayed silent until I killed the app. Each section below is, roughly, one of those lessons.


2. Microphone permission, the modern way


Before anything else, you need the user's permission to use the microphone. Two things are required.

First, a purpose string. Add NSMicrophoneUsageDescription to your Info.plist. Apple's documentation is blunt about this: if your app tries to access the microphone without this key, the app exits. Make it specific, for example: "Virtual Mic uses the microphone to play your voice live through your speaker or headphones."

Second, the request itself. In iOS 17, Apple moved record permission from AVAudioSession to a new class, AVAudioApplication. The old AVAudioSession.requestRecordPermission(_:) is deprecated as of iOS 17. The new API has both a completion-handler version and an async version:

class func requestRecordPermission() async -> Bool

If your deployment target is below iOS 17, you need both paths. Mic to Speaker currently supports iOS 16.6 and later, so this is not theoretical for me. Here's a small helper that handles both, plus the "denied" case:
import AVFoundation
import UIKit

@MainActor
enum MicPermissionManager {

    /// Prompts if needed. Returns true if we may record.
    static func request() async -> Bool {
        if #available(iOS 17.0, *) {
            return await AVAudioApplication.requestRecordPermission()
        } else {
            return await withCheckedContinuation { continuation in
                AVAudioSession.sharedInstance().requestRecordPermission { granted in
                    continuation.resume(returning: granted)
                }
            }
        }
    }

    /// The system will not show the prompt again after a denial,
    /// so the only way forward is the Settings app.
    static func openSettings() {
        guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
        UIApplication.shared.open(url)
    }
}

Three practical notes:
  • Ask at the moment of need, when the user first taps "start", not at launch.
  • Denied is permanent from the app's side. The system won't prompt again, so show a short explanation and a button that calls openSettings().
  • Silence is not an error. Apple's docs note that without permission the app captures only silence. The engine starts "fine" and plays nothing, so always check permission first or you'll get confusing "no sound" bug reports.

3. Configuring AVAudioSession


AVAudioSession is how your app tells iOS what kind of audio it does. Get this wrong and nothing else matters.

Category and options


For simultaneous input and output you need the .playAndRecord category. On top of that, the options do most of the heavy lifting:

  • .defaultToSpeaker: uses the built-in loudspeaker instead of the earpiece when nothing else is connected. This one option fixes "why is it so quiet?"
  • .allowBluetoothA2DP: lets output go to high-quality Bluetooth speakers and headphones. A2DP is output-only, so the iPhone's mic stays the input, which is usually what a mic-to-speaker app wants.
  • .allowBluetoothHFP (formerly .allowBluetooth): lets Bluetooth hands-free devices act as input and output. Apple's current docs mark the old .allowBluetooth name deprecated in favour of .allowBluetoothHFP; on an older SDK, use .allowBluetooth. HFP is the "phone call" profile with noticeably lower quality than A2DP, so I enable it only when the user wants a headset's own microphone.
  • .allowAirPlay: lets output go to AirPlay devices; for .playAndRecord you must set it explicitly. AirPlay buffers over the network, so expect extra latency you can't control.


Mode


The mode tunes the session for a use case:

  • .default: no special processing; best for music and singing, and required for echo-cancelled input (below).
  • .voiceChat: tuned for two-way voice. Apple's docs say it optimises EQ for voice, limits routes to voice-suitable ones and automatically applies the Bluetooth HFP option. Great for speech, not for music.
  • .measurement: minimises system signal processing for the rawest input.

I default to .default and offer voice processing (section 5) as a user choice.

Buffer duration and sample rate


You can ask for a smaller I/O buffer and a particular sample rate:

try session.setPreferredIOBufferDuration(0.005)  // a request, not a guarantee
try session.setPreferredSampleRate(48_000)

These are preferences; the system decides based on hardware and route. After activating, read ioBufferDuration and sampleRate to see what you actually got, and log them. Bluetooth routes often differ from what you asked for.

Echo-cancelled input (iOS 18.2+)


Starting with iOS 18.2, AVAudioSession has three new members:

  • isEchoCancelledInputAvailable — whether the device and current setup support it
  • setPrefersEchoCancelledInput(_:) — ask for it
  • isEchoCancelledInputEnabled — whether the system actually turned it on

Per Apple's docs, it works only with .playAndRecord and .default mode, on certain 2024-or-later iPhones, on the built-in mic and speaker route. It's tuned for a wider range of sounds than voice processing, and can switch off when the route changes (say, a headset is plugged in). Treat it as a bonus on supported hardware, not your feedback strategy.

Temporary speaker override


For a "force loudspeaker" toggle, use overrideOutputAudioPort(_:). It temporarily changes the current route, and the override clears when the route changes again.

Putting it together

import AVFoundation

enum AudioSessionConfigurator {

    static func configure(allowBluetoothMic: Bool = false,
                          preferEchoCancellation: Bool = true) throws {
        let session = AVAudioSession.sharedInstance()

        var options: AVAudioSession.CategoryOptions = [
            .defaultToSpeaker,
            .allowBluetoothA2DP,
            .allowAirPlay
        ]
        if allowBluetoothMic {
            // Use .allowBluetooth if your SDK predates .allowBluetoothHFP.
            options.insert(.allowBluetoothHFP)
        }

        try session.setCategory(.playAndRecord, mode: .default, options: options)
        try session.setPreferredSampleRate(48_000)
        try session.setPreferredIOBufferDuration(0.005)

        if #available(iOS 18.2, *),
           preferEchoCancellation,
           session.isEchoCancelledInputAvailable {
            try session.setPrefersEchoCancelledInput(true)
        }

        try session.setActive(true)

        print("Sample rate: \(session.sampleRate) Hz, "
            + "IO buffer: \(session.ioBufferDuration * 1000) ms, "
            + "route: \(session.currentRoute.outputs.map(\.portType.rawValue))")
    }

    /// Force the built-in speaker (true) or go back to the normal route (false).
    static func setSpeakerOverride(_ on: Bool) throws {
        try AVAudioSession.sharedInstance()
            .overrideOutputAudioPort(on ? .speaker : .none)
    }

    static func deactivate() {
        try? AVAudioSession.sharedInstance()
            .setActive(false, options: .notifyOthersOnDeactivation)
    }
}

4. The AVAudioEngine graph


AVAudioEngine lets you connect audio nodes like guitar pedals. For mic-to-speaker the graph is short:

inputNode → [EQ] → [Reverb] → mainMixerNode → outputNode

  • inputNode is the current microphone input.
  • AVAudioUnitEQ gives bass/treble bands and a globalGain boost (documented range −96 to +24 dB).
  • AVAudioUnitReverb adds room character; wetDryMix runs 0–100%, where 0 is fully dry.
  • mainMixerNode is created and wired to outputNode the first time you access it. Its outputVolume (0.0–1.0) is a natural master volume.


Format matching


The most common live-audio crash is a format mismatch. My rule: use the input node's own format for every connection carrying the mic signal, and let the mixer convert for the output hardware. Also validate it first: with no input available, the format can come back with a 0 Hz sample rate or zero channels, and connecting with that raises an exception Swift can't catch.

The complete class


Here's a minimal, complete engine class: one object that owns the engine, exposes a few controls and starts and stops cleanly.

import AVFoundation

enum LiveAudioError: Error {
    case noInputAvailable
}

final class LiveAudioEngine {

    private let engine = AVAudioEngine()
    private let eq = AVAudioUnitEQ(numberOfBands: 2)
    private let reverb = AVAudioUnitReverb()

    private(set) var isRunning = false

    // MARK: Controls

    /// Master volume, 0.0 ... 1.0
    var volume: Float {
        get { engine.mainMixerNode.outputVolume }
        set { engine.mainMixerNode.outputVolume = min(max(newValue, 0), 1) }
    }

    /// Extra input gain in dB, clamped to a sane range.
    var boostDB: Float {
        get { eq.globalGain }
        set { eq.globalGain = min(max(newValue, -24), 24) }
    }

    /// Bass and treble in dB.
    var bassDB: Float {
        get { eq.bands[0].gain }
        set { eq.bands[0].gain = min(max(newValue, -12), 12) }
    }
    var trebleDB: Float {
        get { eq.bands[1].gain }
        set { eq.bands[1].gain = min(max(newValue, -12), 12) }
    }

    /// Reverb amount, 0 (off) ... 100
    var reverbMix: Float {
        get { reverb.wetDryMix }
        set { reverb.wetDryMix = min(max(newValue, 0), 100) }
    }

    // MARK: Setup

    init() {
        let bass = eq.bands[0]
        bass.filterType = .lowShelf
        bass.frequency = 150
        bass.gain = 0
        bass.bypass = false

        let treble = eq.bands[1]
        treble.filterType = .highShelf
        treble.frequency = 6_000
        treble.gain = 0
        treble.bypass = false

        reverb.loadFactoryPreset(.mediumRoom)
        reverb.wetDryMix = 0

        engine.attach(eq)
        engine.attach(reverb)
    }

    // MARK: Start / stop

    func start() throws {
        guard !isRunning else { return }

        let input = engine.inputNode
        let format = input.outputFormat(forBus: 0)
        guard format.sampleRate > 0, format.channelCount > 0 else {
            throw LiveAudioError.noInputAvailable
        }

        // Rebuild connections every time: the input format can change
        // between runs (new route, new sample rate).
        engine.disconnectNodeOutput(input)
        engine.disconnectNodeOutput(eq)
        engine.disconnectNodeOutput(reverb)

        engine.connect(input, to: eq, format: format)
        engine.connect(eq, to: reverb, format: format)
        engine.connect(reverb, to: engine.mainMixerNode, format: format)

        engine.prepare()
        try engine.start()
        isRunning = true
    }

    func stop() {
        guard isRunning else { return }
        engine.stop()
        isRunning = false
    }

    /// Voice processing (echo cancellation, AGC, noise suppression).
    /// Must be changed while the engine is stopped.
    func setVoiceProcessing(_ enabled: Bool) throws {
        let wasRunning = isRunning
        stop()
        try engine.inputNode.setVoiceProcessingEnabled(enabled)
        if wasRunning { try start() }
    }

    /// Exposed so the owner can observe engine configuration changes.
    var underlyingEngine: AVAudioEngine { engine }
}

And using it from a view model:
@MainActor
final class MicViewModel: ObservableObject {
    @Published var isLive = false
    @Published var errorMessage: String?

    let audio = LiveAudioEngine()

    func toggle() async {
        if audio.isRunning {
            audio.stop()
            AudioSessionConfigurator.deactivate()
            isLive = false
            return
        }

        guard await MicPermissionManager.request() else {
            errorMessage = "Microphone access is off. Turn it on in Settings."
            return
        }

        do {
            try AudioSessionConfigurator.configure()
            audio.volume = 0.3          // start low, always
            try audio.start()
            isLive = true
        } catch {
            errorMessage = "Couldn't start audio: \(error.localizedDescription)"
        }
    }
}

Note audio.volume = 0.3. Starting quiet is a safety feature, not a nicety.

5. Taming feedback and latency


No single switch removes feedback. There are trade-offs; pick sensible defaults and let the user adjust.

Headphones vs. speaker


The most reliable fix is physics: if the mic can't hear the speaker, there's no loop. With headphones, feedback basically disappears. With a Bluetooth speaker across the room, it's manageable. With the phone's own loudspeaker centimetres from the mic, you're fighting acoustics at any useful volume.

That's why my App Store listing has plain "Safety tips": start with low volume, connect speakers before turning on, increase volume gradually, keep the device away from speakers. They came straight from user reviews and support emails.

Voice processing


setVoiceProcessingEnabled(_:) (on AVAudioIONode, iOS 13+) turns on Apple's voice processing I/O: echo cancellation, automatic gain control and noise suppression, the same family used for calls. isVoiceProcessingAGCEnabled on the input node lets you keep echo cancellation but drop automatic gain.

  • Change it while the engine is stopped. It reconfigures the I/O unit and the input format can change, which is why LiveAudioEngine rebuilds connections on every start().
  • It changes the sound. Tuned for speech, it can make singing and instruments sound processed, so I make it a user choice.
  • It's not magic. It reduces the loop; at high enough volume on the built-in speaker you can still get feedback.


Buffer size trade-offs


Smaller buffers mean lower latency but less time for the audio thread before the next deadline; miss it and you get clicks and dropouts. Larger buffers are smoother, but you hear yourself later. My approach:

  • Request a small buffer, then read back what you got.
  • Keep the chain light. Apple's built-in EQ and reverb are fine; heavy custom DSP is where trouble starts.
  • Never allocate, lock or do file I/O on the real-time thread. In a tap (level meter, recording), copy what you need and hand it off.
  • Accept that Bluetooth and AirPlay add buffering you can't remove. I tell users plainly that wired gives the lowest delay.

I'm deliberately not quoting millisecond numbers. Latency depends on device, iOS version, route and granted buffer size; the only honest number is the one you measure on your own devices.


6. Interruptions and route changes


This separates a demo from a shipped app. Real phones get calls, alarms, Siri, other apps taking over audio, and headphones unplugged mid-sentence.

Interruptions


When another session (say, a phone call) takes over, the system posts AVAudioSession.interruptionNotification. On .began, the system has already stopped your audio; update state and UI. On .ended, the options may include .shouldResume, a hint that resuming without user input is appropriate.

What's new: Apple's latest docs mark .shouldResume deprecated as of iOS 27 in favour of a newer resumptionRecommendationNotification. It still works on the versions most apps support, so the sample uses it; on the newest SDK, see Apple's "Handling audio interruptions" article.

For a live mic I'm conservative: auto-resume only if the user was live before and the system says resuming is fine. A mic that silently turns itself back on after a call is a privacy surprise.

Route changes


AVAudioSession.routeChangeNotification carries a reason. The critical one is .oldDeviceUnavailable: headphones unplugged or Bluetooth disconnected. Apple's guidance for media apps is to pause; for a live mic it matters even more, because audio jumps to the loudspeaker and can instantly howl. Stop first, let the user decide. For .newDeviceAvailable, keep going but rebuild the graph, since the input format may have changed.

Media services reset


Rarely, the media server restarts and the system posts AVAudioSession.mediaServicesWereResetNotification. Every audio object is now invalid: recreate the engine and reconfigure the session. Separately, AVAudioEngine posts .AVAudioEngineConfigurationChange when the hardware I/O format changes; the engine stops, and you reconnect and restart.

Putting it together

import AVFoundation

@MainActor
final class AudioEventsHandler {

    private var engine: LiveAudioEngine
    private var wasLiveBeforeInterruption = false
    private var observers: [NSObjectProtocol] = []
    var onStateChange: ((Bool) -> Void)?

    init(engine: LiveAudioEngine) {
        self.engine = engine
        observe()
    }

    deinit {
        observers.forEach { NotificationCenter.default.removeObserver($0) }
    }

    private func observe() {
        let center = NotificationCenter.default
        let session = AVAudioSession.sharedInstance()

        observers.append(center.addObserver(
            forName: AVAudioSession.interruptionNotification,
            object: session, queue: .main) { [weak self] note in
                MainActor.assumeIsolated { self?.handleInterruption(note) }
            })

        observers.append(center.addObserver(
            forName: AVAudioSession.routeChangeNotification,
            object: session, queue: .main) { [weak self] note in
                MainActor.assumeIsolated { self?.handleRouteChange(note) }
            })

        observers.append(center.addObserver(
            forName: AVAudioSession.mediaServicesWereResetNotification,
            object: session, queue: .main) { [weak self] _ in
                MainActor.assumeIsolated { self?.handleMediaServicesReset() }
            })

        observers.append(center.addObserver(
            forName: .AVAudioEngineConfigurationChange,
            object: engine.underlyingEngine, queue: .main) { [weak self] _ in
                MainActor.assumeIsolated { self?.restartIfNeeded() }
            })
    }

    private func handleInterruption(_ note: Notification) {
        guard let info = note.userInfo,
              let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
              let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }

        switch type {
        case .began:
            wasLiveBeforeInterruption = engine.isRunning
            engine.stop()
            onStateChange?(false)

        case .ended:
            let optionsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
            let options = AVAudioSession.InterruptionOptions(rawValue: optionsRaw)
            if wasLiveBeforeInterruption && options.contains(.shouldResume) {
                do {
                    try AVAudioSession.sharedInstance().setActive(true)
                    try engine.start()
                    onStateChange?(true)
                } catch {
                    onStateChange?(false)
                }
            }
            wasLiveBeforeInterruption = false

        @unknown default:
            break
        }
    }

    private func handleRouteChange(_ note: Notification) {
        guard let info = note.userInfo,
              let raw = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
              let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }

        switch reason {
        case .oldDeviceUnavailable:
            // Headphones unplugged / Bluetooth gone: stop before we howl.
            engine.stop()
            onStateChange?(false)
        case .newDeviceAvailable, .categoryChange, .override:
            restartIfNeeded()
        default:
            break
        }
    }

    private func handleMediaServicesReset() {
        // Every audio object is invalid now. Rebuild from scratch.
        engine.stop()
        engine = LiveAudioEngine()
        try? AudioSessionConfigurator.configure()
        onStateChange?(false) // let the user start again
    }

    private func restartIfNeeded() {
        guard engine.isRunning else { return }
        engine.stop()
        do {
            try engine.start()   // reconnects with the new input format
        } catch {
            onStateChange?(false)
        }
    }
}

In production you'd also re-register the configuration observer for the new engine after a reset and hand it back to its owner; I've kept the shape minimal.

7. Background audio and App Store notes

Background audio


To keep the mic live with the screen locked, add audio to UIBackgroundModes (Xcode: Background Modes → "Audio, AirPlay, and Picture in Picture"). With an active .playAndRecord session and a running engine, iOS keeps the app alive and shows its microphone-in-use indicator, so users always know.

The flip side: when the user stops, really stop. Stop the engine and deactivate with .notifyOthersOnDeactivation so paused music and podcasts can resume. Holding the session forever drains battery and blocks other audio.

App Store review considerations


  • A specific purpose string, in the user's words.
  • Background audio must be real. Declaring the audio mode means actually producing audible content in the background; an idle app holding the session doesn't qualify.
  • An obvious "live" state: a big on/off control and a clear indicator.
  • Honest privacy. If you record sessions, say so and say where they're stored, and keep your App Privacy details consistent.
  • Don't over-promise. If the app can't act as a system-wide mic for a computer or other apps, don't imply it can.

8. Real-world example: Virtual Mic, a.k.a. Mic to Speaker


Everything above comes from building and maintaining my own apps. The main one is on the App Store as Mic to Speaker – Virtual Mic.

Virtual Mic App Store screenshot 1

At the time of writing, its public App Store listing shows:

  • Free, in the Music and Utilities categories
  • First released in May 2020
  • About 3,400 ratings in the US store, averaging around 4.5 stars
  • Supports iOS 16.6 and later, on iPhone and iPad

The listing describes the core idea simply: "Use your device as a live microphone! Connect to any speaker and your voice comes out loud and clear." Here's how the listing's features map to the techniques in this article:

What the listing saysWhat makes it work
"Open the app and tap the screen. That's it!" / "One tap to turn on or off"Permission request on first tap (section 2), then session + engine start (sections 3–4)
"Connect via Bluetooth, AirPlay, or cable".playAndRecord with .defaultToSpeaker, .allowBluetoothA2DP, .allowAirPlay (section 3)
"Works in the background" / "Runs even when screen is locked"UIBackgroundModes → audio, active session, running engine (section 7)
"Add echo and reverb… Adjust bass and treble. Make your voice louder or softer. Everything updates in real-time"AVAudioUnitReverb, AVAudioUnitEQ bands and gain, mixer volume, all live in the graph (section 4)
"Every session is saved automatically… MP3, WAV, M4A"A tap on the graph feeding a file writer, off the real-time thread (section 5)
"Safety tips: Start with low volume… Avoid feedback by keeping device away from speakers"The feedback realities in section 5, and starting at a low volume by default


Virtual Mic App Store screenshot 2

The listing's use cases range from live streaming, karaoke and podcasts to announcements and hearing assistance: amplifying voices in meetings or classes. That last one shapes my defaults most. For someone using your app to hear better, a feedback squeal or surprise route change isn't a glitch; it hurts. Hence "start quiet" and "stop on .oldDeviceUnavailable". It's also why the app still supports iOS 16.6 and carries the pre-iOS 17 permission path from section 2.

Virtual Mic App Store screenshot 3

Android sidebar: Virtual Mic on Google Play


The same idea exists on Android as Virtual Mic on Google Play (10K+ downloads at the time of writing). Its listing highlights real-time noise reduction, echo cancellation, reverb and echo effects, an equaliser, input gain and output volume controls, and support for Bluetooth speakers and wired headphones.

On Android the building blocks are AudioRecord (capture) and AudioTrack (playback), or Google's Oboe C++ library over AAudio/OpenSL ES for low-latency work. Same problems (latency, routing, feedback, audio focus instead of interruptions), very different APIs. That's my next article.


9. Before you ship live audio: a 7-point checklist


  1. Permission flow is complete. NSMicrophoneUsageDescription is specific; you ask at the moment of need; iOS 17+ uses AVAudioApplication, older versions fall back to AVAudioSession; denied users get an "Open Settings" path.
  2. The session is configured on purpose. .playAndRecord, .defaultToSpeaker, the Bluetooth options you actually want (A2DP for quality, HFP only when needed), and you log the real sample rate and buffer duration you got.
  3. Formats come from the hardware. You connect using the input node's format, check it's valid, and rebuild connections whenever the route or configuration changes.
  4. Feedback has defaults. Start at low volume, recommend headphones or distance, offer voice processing as a user choice, and use echo-cancelled input only where isEchoCancelledInputAvailable says you can.
  5. Interruptions and route changes are tested on a real device. Phone call, alarm, Siri, unplugging headphones, Bluetooth disconnecting, AirPlay switching: the app ends up in a sensible, visible state every time.
  6. Background behaviour is honest. The audio background mode is declared only because you really play audio in the background, and you deactivate the session with .notifyOthersOnDeactivation when the user stops.
  7. The real-time thread stays clean. No allocations, locks, logging or file I/O inside render callbacks or taps; heavy work is handed off.

Wrapping up


A mic-to-speaker app is a great way to learn iOS audio because there's nowhere to hide: latency is audible, routing mistakes are obvious, and a missed interruption leaves a silent app. AVAudioSession plus AVAudioEngine give you everything you need in a few hundred lines of Swift, as long as you respect the session, follow the hardware's formats and handle real-world events.

If you want to see the result in action, try my apps:

If you've shipped live audio on iOS and hit a problem I didn't cover, I'd love to hear about it in the comments. Follow me here on Medium for the Android version of this guide.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Building a Tap-to-Match Marble Game with SpriteKit and SwiftUI: Physics, Cluster Matching, Timers, Lives and Power-Ups


Originally published at bestlatestapps.com/blog/buildi…

A complete walkthrough of the game loop: marbles that settle under real physics, finding touching same-colour clusters, a pause-safe countdown, lives and levels, Lightning, Heart and Bomb power-ups, and particle effects, all hosted in SwiftUI with SpriteView

Marbles Popperz app icon

Every game developer has a "small" game that taught them how games actually work. Mine is Marbles Popperz, a puzzle game where you tap marbles of the same colour to match them and score points before time runs out. It has a lives system, levels that get harder, three power-ups (Lightning Flash, Heart and Bomb) and global leaderboards.

On paper that's a weekend project. In practice it touches nearly every part of a real game: a physics world, hit-testing, a frame-driven clock that has to survive pausing, difficulty tuning, juicy feedback, and a SwiftUI interface laid over a SpriteKit scene.

This article walks through how I'd build each piece with SpriteKit, Apple's 2D game framework. The code is mine, written for this article, and the design choices are labelled as the approach I'd use. Game Center leaderboards get their own article, my Speck Match piece, so here I'll stay on gameplay.


1. Why SpriteKit, and how it fits with SwiftUI


For a 2D casual game on Apple platforms, SpriteKit is the shortest path. It gives you a scene graph (SKScene, SKSpriteNode), actions for animation (SKAction), a built-in physics engine (SKPhysicsBody) and particles (SKEmitterNode), all rendered on the GPU, with no third-party engine to update.

Since iOS 14, SpriteView lets a SwiftUI view host a scene directly. So menus, the HUD and settings can be ordinary SwiftUI, and only the playfield is SpriteKit:

import SwiftUI
import SpriteKit

struct GameScreen: View {
    @StateObject private var state = GameState()
    @State private var scene: MarbleScene = {
        let s = MarbleScene(size: CGSize(width: 390, height: 700))
        s.scaleMode = .resizeFill
        return s
    }()

    var body: some View {
        ZStack(alignment: .top) {
            SpriteView(scene: scene, preferredFramesPerSecond: 60)
                .ignoresSafeArea()
                .onAppear { scene.state = state }

            HUD(score: state.score, lives: state.lives,
                timeLeft: state.timeLeft, level: state.level)
                .padding()
        }
    }
}

@MainActor
final class GameState: ObservableObject {
    @Published var score = 0
    @Published var lives = 3
    @Published var level = 1
    @Published var timeLeft: TimeInterval = 60
    @Published var isGameOver = false
}

The rule I follow: the scene owns the simulation; SwiftUI owns the chrome. The scene writes to a shared GameState, and SwiftUI just renders it. Keep SwiftUI state changes to a few per second, not 60, or you'll pay for view updates every frame.

2. A playfield of marbles that settle under physics


Marbles should behave like marbles. When some pop, the ones above should roll down and fill the gap. Giving each marble a circular physics body, and the scene an edge loop, gets you that for free:

import SpriteKit

enum MarbleColor: CaseIterable { case red, blue, green, yellow, purple }

final class Marble: SKSpriteNode {
    let color: MarbleColor
    static let radius: CGFloat = 22

    init(color: MarbleColor) {
        self.color = color
        let texture = SKTexture(imageNamed: "marble-\(color)")    // from a texture atlas
        super.init(texture: texture, color: .clear,
                   size: CGSize(width: Self.radius * 2, height: Self.radius * 2))
        name = "marble"
        let body = SKPhysicsBody(circleOfRadius: Self.radius)
        body.restitution = 0.2          // a little bounce
        body.friction = 0.3
        body.allowsRotation = true
        physicsBody = body
    }
    required init?(coder: NSCoder) { fatalError() }
}

final class MarbleScene: SKScene {
    weak var state: GameState?
    var config = LevelConfig.forLevel(1)
    var remaining: TimeInterval = 60
    private var lastUpdate: TimeInterval = 0

    override func didMove(to view: SKView) {
        backgroundColor = .black
        physicsBody = SKPhysicsBody(edgeLoopFrom: frame)         // the walls of the jar
        physicsWorld.gravity = CGVector(dx: 0, dy: -9.8)
        view.ignoresSiblingOrder = true                          // lets SpriteKit batch draws
        fill(count: config.marbleCount)
    }

    func fill(count: Int) {
        let palette = Array(MarbleColor.allCases.prefix(config.colorCount))
        for i in 0..<count {
            let marble = Marble(color: palette.randomElement()!)
            marble.position = CGPoint(x: .random(in: 40...(size.width - 40)),
                                      y: size.height + CGFloat(i) * 8)   // drop in from above
            addChild(marble)
        }
    }
}

Two cheap wins: put every marble texture in one texture atlas, so all marbles are drawn in a few batched calls; and set ignoresSiblingOrder so SpriteKit is free to reorder draws for batching.

3. Tap-to-match: finding the touching cluster


The listing describes the core loop simply: "Tap marbles of the same color to match them… The more marbles you match, the higher your score." The approach I'd use is cluster matching. Tapping a marble pops it together with every same-colour marble it is touching, directly or through a chain. A lone marble doesn't pop. Bigger clusters score more, which rewards looking before you tap.

Hit-testing uses nodes(at:). The cluster is a breadth-first search over "touching" neighbours:

extension MarbleScene {
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let point = touches.first?.location(in: self),
              let tapped = nodes(at: point).compactMap({ $0 as? Marble }).first else { return }

        let cluster = cluster(from: tapped)
        guard cluster.count >= 2 else {
            tapped.run(.sequence([.moveBy(x: 6, y: 0, duration: 0.04),
                                  .moveBy(x: -12, y: 0, duration: 0.08),
                                  .moveBy(x: 6, y: 0, duration: 0.04)]))   // "no match" wiggle
            return
        }
        pop(cluster)
        state?.score += score(forClusterOf: cluster.count)
    }

    func cluster(from start: Marble) -> [Marble] {
        let all = children.compactMap { $0 as? Marble }.filter { $0.color == start.color }
        let touchDistance = Marble.radius * 2 + 4                  // small tolerance for resting contact
        var visited: Set<ObjectIdentifier> = [ObjectIdentifier(start)]
        var queue = [start], result: [Marble] = []

        while let current = queue.popLast() {
            result.append(current)
            for other in all where !visited.contains(ObjectIdentifier(other)) {
                if hypot(other.position.x - current.position.x,
                         other.position.y - current.position.y) <= touchDistance {
                    visited.insert(ObjectIdentifier(other))
                    queue.append(other)
                }
            }
        }
        return result
    }

    /// Quadratic reward: 2 marbles = 20, 5 marbles = 125, 10 marbles = 500.
    func score(forClusterOf n: Int) -> Int { n * n * 5 }
}

With a few dozen marbles on screen, this brute-force neighbour check is trivially fast. If you ever have hundreds, bucket marbles into a grid by position first. Distance-based neighbours work better than physics contact callbacks here, because resting marbles don't keep reporting contacts.

4. Popping with juice


A match should feel good: a quick scale-up, a fade, a particle burst and a sound. The physics then lets the marbles above fall into the gap.

extension MarbleScene {
    func pop(_ marbles: [Marble]) {
        for (i, marble) in marbles.enumerated() {
            marble.physicsBody = nil                              // stop it interacting while it pops
            let delay = SKAction.wait(forDuration: Double(i) * 0.03)   // a ripple through the cluster
            let burst = SKAction.run { [weak self] in self?.spawnBurst(at: marble.position) }
            let vanish = SKAction.group([.scale(to: 1.4, duration: 0.12), .fadeOut(withDuration: 0.12)])
            marble.run(.sequence([delay, burst, vanish, .removeFromParent()]))
        }
        run(.playSoundFileNamed("pop.caf", waitForCompletion: false))
    }

    func spawnBurst(at point: CGPoint) {
        guard let emitter = SKEmitterNode(fileNamed: "Pop.sks") else { return }   // designed in Xcode
        emitter.position = point
        emitter.numParticlesToEmit = 24
        addChild(emitter)
        emitter.run(.sequence([.wait(forDuration: 0.6), .removeFromParent()]))
    }
}

The 30 ms stagger is the cheapest "juice" there is. A cluster of eight pops as a ripple instead of all at once, and players read that as a chain reaction. Always remove finished emitters, or they pile up invisibly and eat frame time.

5. A countdown that survives pausing


"Watch the timer – you need to match quickly!" The timer must stop when the game is paused, when a phone call comes in and when the app goes to the background. A Timer or Date arithmetic gets this wrong. The robust pattern is to accumulate frame deltas in update(_:), and only while the game is actually running:

extension MarbleScene {
    override func update(_ currentTime: TimeInterval) {
        defer { lastUpdate = currentTime }
        guard lastUpdate > 0, !isPaused, let state, !state.isGameOver else { return }

        // Clamp so a long hitch (or resuming from pause) can't eat seconds at once.
        let dt = min(currentTime - lastUpdate, 1.0 / 20.0)
        remaining -= dt

        // Push to SwiftUI only when the displayed whole second changes.
        let shown = max(0, remaining.rounded(.up))
        if shown != state.timeLeft { state.timeLeft = shown }

        if remaining <= 0 { timeUp() }
        if children.compactMap({ $0 as? Marble }).isEmpty { levelCleared() }
    }
}

(remaining and lastUpdate are the stored properties declared on the scene in section 2.) Three details carry the weight:
  • Clamp dt. After a pause, currentTime jumps forward. Without the clamp, the first frame back would subtract all of the paused time.
  • Guard on isPaused. Pausing the SKView stops the frame loop entirely. Pausing just the scene stops its actions and physics, and the explicit guard makes sure the clock stops too, whichever you use.
  • Throttle UI updates. Writing to @Published state 60 times a second makes SwiftUI re-render the HUD 60 times a second. Write only when the displayed value changes.

Pausing itself is one line, scene.isPaused = true. Wire it to the scene phase going to .inactive or .background in SwiftUI.


6. Lives and levels


The listing has a "Lives system – be careful not to run out!" and "Level progression – difficulty increases as you advance". The approach I'd use: running out of time on a level costs a life and restarts that level, and clearing the jar advances you. Difficulty lives in one small table, so tuning never means touching game logic:

struct LevelConfig {
    let marbleCount: Int
    let colorCount: Int
    let seconds: TimeInterval
    let powerUpChance: Double

    static func forLevel(_ level: Int) -> LevelConfig {
        LevelConfig(
            marbleCount: min(30 + level * 4, 80),       // more marbles
            colorCount: min(3 + level / 3, 5),          // more colours = smaller clusters
            seconds: max(60 - Double(level) * 2, 30),   // less time
            powerUpChance: max(0.08 - Double(level) * 0.004, 0.03)
        )
    }
}

extension MarbleScene {
    func timeUp() {
        guard let state else { return }
        state.lives -= 1
        if state.lives <= 0 { state.isGameOver = true; isPaused = true }
        else { restartLevel() }
    }

    func restartLevel() {
        enumerateChildNodes(withName: "marble") { node, _ in node.removeFromParent() }
        remaining = config.seconds
        fill(count: config.marbleCount)
    }

    func levelCleared() {
        state?.level += 1
        config = .forLevel(state?.level ?? 1)
        remaining = config.seconds
        fill(count: config.marbleCount)
    }
}

Adding a colour is the most powerful difficulty knob in a colour-match game. Each extra colour shrinks the average cluster, which lowers scores and raises pressure without touching the clock.

7. Power-ups: Lightning, Heart and Bomb


The listing names three power-ups: "Lightning Flash – Clear multiple marbles at once", "Heart – Gain an extra life" and "Bomb – Explode surrounding marbles". I'd model them as special marbles that spawn occasionally and trigger when tapped:

enum PowerUp { case lightning, heart, bomb }

extension MarbleScene {
    func trigger(_ powerUp: PowerUp, at point: CGPoint) {
        let marbles = children.compactMap { $0 as? Marble }
        switch powerUp {
        case .lightning:
            // Clear every marble of the most common colour.
            let counts = Dictionary(grouping: marbles, by: \.color)
            if let biggest = counts.max(by: { $0.value.count < $1.value.count })?.value {
                pop(biggest); state?.score += score(forClusterOf: biggest.count)
            }
        case .heart:
            state?.lives += 1
        case .bomb:
            let blastRadius: CGFloat = 110
            let hit = marbles.filter { hypot($0.position.x - point.x, $0.position.y - point.y) < blastRadius }
            pop(hit); state?.score += hit.count * 10
            // Push the survivors outwards for a satisfying shove.
            for m in marbles where !hit.contains(m) {
                let d = CGVector(dx: m.position.x - point.x, dy: m.position.y - point.y)
                let len = max(hypot(d.dx, d.dy), 1)
                m.physicsBody?.applyImpulse(CGVector(dx: d.dx / len * 30, dy: d.dy / len * 30))
            }
        }
    }
}

Which marbles "Lightning" clears is my design choice; the listing only says it clears multiple at once. The bomb's impulse on survivors is where the physics world pays off a second time. The jar visibly reacts, and the new arrangement creates fresh clusters.

8. Real-world example: Marbles Popperz


This walkthrough is based on building Marbles Popperz. The code here is the approach I'd use, not the shipped source.

Marbles Popperz App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Games (Puzzle, Board)
  • First released in December 2021
  • 3 ratings in the US store, averaging about 3.7 stars
  • Supports iOS 16.0 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Simple tap-to-match gameplay" / "Match marbles by color to remove them"Hit-testing and cluster matching (section 3)
"Beautiful visual effects and animations"Staggered pops and particle bursts (section 4)
"Watch the timer - you need to match quickly!"Pause-safe countdown in update(_:) (section 5)
"Lives system" / "Level progression - difficulty increases as you advance"Lives and a level table (section 6)
"Lightning Flash", "Heart", "Bomb"Power-ups (section 7)
"Global leaderboards - compete with players worldwide"Game Center, covered in my Speck Match article


Marbles Popperz App Store screenshot 2

The rating average is a useful honest signal for a small game: three ratings is too few to mean much. The way to learn what players struggle with is to watch someone play your first three levels without saying a word.


9. Before you ship a casual game: a checklist


  1. The timer pauses on pause, on interruptions and in the background, and dt is clamped on resume.
  2. The UI isn't updated every frame, only when displayed values change.
  3. Textures are in an atlas and ignoresSiblingOrder is on. Check the draw count with showsDrawCount during development.
  4. Finished emitters and actions are removed. No invisible nodes pile up.
  5. The difficulty table is data, and each level has been played by someone other than you.
  6. Taps feel fair. The hit area isn't smaller than the visible marble, and a mis-tap gets clear feedback.
  7. Colour isn't the only cue. Add patterns or symbols to marbles for colour-blind players.
  8. Sound respects the silent switch, and there's a mute option.
  9. Test on the oldest supported device at 60 fps with the maximum marble count.

Wrapping up


A tap-to-match game is a perfect classroom. Physics gives you settling for free. A simple search gives you clusters. A clamped delta gives you an honest clock. A table gives you difficulty. And a few milliseconds of stagger turn "it works" into "it feels good". SpriteKit and SwiftUI together cover all of it with nothing to install.

Play the game, or explore my other work:

What's your favourite bit of game "juice"? Share it in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Building an AdMob Earnings Dashboard for iOS: Google OAuth with PKCE, the AdMob Reporting API, Pacific-Time 'Today', and Offline Caching


Originally published at bestlatestapps.com/blog/buildi…

Signing in with ASWebAuthenticationSession and PKCE, storing tokens safely in the Keychain, calling networkReport:generate, decoding a streamed report, turning micros into money, why "today" is a Los Angeles day, live earning rates, and caching for offline use

AdEarnings Pro app icon

If you have apps with ads, you know the habit: open the AdMob website on your phone, pinch, zoom, wait for the dashboard, then do it again an hour later. I wanted a native, fast, glanceable view of my AdMob earnings, so I built AdEarnings Pro. It's a dashboard for today's earnings, yesterday's comparison, earning rates per second, minute and hour, weekly averages for earnings and clicks, six months of history and currency conversion. It uses Google Sign-In, and the listing states plainly: "Your AdMob data is not saved, shared, or accessible to us."

That promise shapes the whole architecture. There's no server of mine in the middle. The app talks directly to Google's AdMob API using the user's own OAuth token, and everything is cached on the device.

This article walks through how to build that: OAuth for a native app done properly, the AdMob Reporting API's actual request and response format, and a few surprises from Google's documentation. The biggest is that the report time zone currently supports only America/Los_Angeles, which changes what "today" means for everyone outside California. Apple APIs are checked against Apple's documentation and Google APIs against Google's. Where I describe my approach rather than the shipped code, I say so.


1. The shape of the app: no middleman


The data flow is deliberately short:

  1. The user signs in with Google and grants a read-only reporting scope.
  2. The app stores the refresh token in the Keychain, on the device.
  3. The app calls admob.googleapis.com directly with a short-lived access token.
  4. Results are cached locally for offline viewing.

Google's AdMob API getting-started guide lists two scopes. admob.readonly lets an app "See all AdMob data", and Google notes that this "doesn't include sensitive data, such as payments or campaign details". admob.report lets it "See ad performance and earnings reports. See publisher ID, timezone, and default currency code." For an earnings dashboard, admob.report is the narrowest scope that works, and asking for the minimum is both good privacy and good for consent rates. Google's OAuth guide makes the same point: the fewer scopes you request, the more likely users are to grant them.

The listing also notes "This app requires access to your existing Google AdMob account". The API reads accounts; it can't create one.


2. OAuth for a native app: system browser, PKCE, no embedded web views


Google's OAuth documentation is blunt about embedded web views. Opening the authorization page in WKWebView produces a disallowed_useragent error, and Google recommends its Google Sign-In for iOS library or AppAuth instead. Both are built on the same standard flow: the system browser plus PKCE (Proof Key for Code Exchange). Here's that flow with Apple's own ASWebAuthenticationSession (iOS 12+), so you can see every step:

import AuthenticationServices
import UIKit
import CryptoKit
import Foundation

struct PKCE {
    let verifier: String
    let challenge: String

    /// Google: verifier is 43–128 chars of [A-Z a-z 0-9 - . _ ~]; S256 challenge = BASE64URL(SHA256(verifier)).
    static func make() -> PKCE {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
        let verifier = Data(bytes).base64URLEncoded()                     // 43 chars
        let challenge = Data(SHA256.hash(data: Data(verifier.utf8))).base64URLEncoded()
        return PKCE(verifier: verifier, challenge: challenge)
    }
}

extension Data {
    func base64URLEncoded() -> String {
        base64EncodedString().replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
    }
}

@MainActor
final class GoogleAuth: NSObject, ASWebAuthenticationPresentationContextProviding {
    // An iOS OAuth client in Google Cloud; its redirect uses the reversed client ID as the scheme.
    let clientID = "1234-abc.apps.googleusercontent.com"
    var redirectScheme: String { "com.googleusercontent.apps.1234-abc" }
    var redirectURI: String { "\(redirectScheme):/oauth2redirect" }

    func signIn() async throws -> (code: String, pkce: PKCE) {
        let pkce = PKCE.make()
        let state = UUID().uuidString
        var url = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
        url.queryItems = [
            .init(name: "client_id", value: clientID),
            .init(name: "redirect_uri", value: redirectURI),
            .init(name: "response_type", value: "code"),
            .init(name: "scope", value: "https://www.googleapis.com/auth/admob.report"),
            .init(name: "code_challenge", value: pkce.challenge),
            .init(name: "code_challenge_method", value: "S256"),
            .init(name: "state", value: state)
        ]

        let callback: URL = try await withCheckedThrowingContinuation { cont in
            let session = ASWebAuthenticationSession(url: url.url!, callbackURLScheme: redirectScheme) { url, error in
                if let url { cont.resume(returning: url) } else { cont.resume(throwing: error ?? URLError(.cancelled)) }
            }
            session.presentationContextProvider = self
            session.prefersEphemeralWebBrowserSession = false   // reuse an existing Google session if there is one
            session.start()
        }

        let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? []
        guard items.first(where: { $0.name == "state" })?.value == state,       // CSRF check
              let code = items.first(where: { $0.name == "code" })?.value else { throw URLError(.userAuthenticationRequired) }
        return (code, pkce)
    }

    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        UIApplication.shared.connectedScenes.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first ?? ASPresentationAnchor()
    }
}

Every parameter here comes from Google's OAuth documentation for installed apps:
  • The authorization endpoint is accounts.google.com/o/oauth2/v2/auth.
  • response_type=code is required.
  • The challenge is S256, which Google recommends.
  • The verifier is 43 to 128 characters. Thirty-two random bytes, base64url-encoded, gives exactly 43.
  • state protects against cross-site request forgery. Google calls it out as important, and the code checks it.

On iOS 17.4 and later, ASWebAuthenticationSession also accepts a Callback object (ASWebAuthenticationSession.Callback). SwiftUI has a webAuthenticationSession environment value from iOS 16.4. Either is a tidier wrapper around the same flow.


3. Exchanging the code and keeping tokens safe


Next, swap the code for tokens at oauth2.googleapis.com/token, sending the PKCE verifier (not the challenge). Google's docs note that client_secret doesn't apply to iOS clients:

struct TokenResponse: Decodable {
    let access_token: String
    let expires_in: Int
    let refresh_token: String?
    let scope: String
}

extension GoogleAuth {
    func exchange(code: String, pkce: PKCE) async throws -> TokenResponse {
        try await tokenRequest([
            "client_id": clientID, "code": code, "code_verifier": pkce.verifier,
            "grant_type": "authorization_code", "redirect_uri": redirectURI
        ])
    }

    func refresh(using refreshToken: String) async throws -> TokenResponse {
        try await tokenRequest(["client_id": clientID, "grant_type": "refresh_token", "refresh_token": refreshToken])
    }

    private func tokenRequest(_ form: [String: String]) async throws -> TokenResponse {
        var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
        request.httpMethod = "POST"
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        var body = URLComponents(); body.queryItems = form.map { URLQueryItem(name: $0.key, value: $0.value) }
        request.httpBody = body.percentEncodedQuery?.data(using: .utf8)
        let (data, _) = try await URLSession.shared.data(for: request)
        return try JSONDecoder().decode(TokenResponse.self, from: data)
    }
}

Then store the refresh token in the Keychain, never in UserDefaults, and mark it device-only:
import Security

enum TokenVault {
    static let account = "admob.refresh"

    static func save(_ token: String) {
        let base: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
                                   kSecAttrAccount as String: account]
        SecItemDelete(base as CFDictionary)
        var item = base
        item[kSecValueData as String] = Data(token.utf8)
        item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        SecItemAdd(item as CFDictionary, nil)
    }
}

AfterFirstUnlockThisDeviceOnly lets a widget or a background refresh read the token after the first unlock, while keeping it out of backups and off other devices. Two of Google's notes are worth building in. First, check the returned scope: users can grant less than you asked for, and the app should explain what's missing. Second, there are limits on how many refresh tokens are issued per client and user, so keep and reuse the refresh token rather than signing in again each time.

"Revoke consent anytime" maps to two things. The user can remove access from their Google Account settings, and the app can call https://oauth2.googleapis.com/revoke?token=… on sign-out and then delete the Keychain item.


4. Asking AdMob for a report


The AdMob API's core endpoint is accounts.networkReport.generate, a POST to https://admob.googleapis.com/v1/accounts/{publisherId}/networkReport:generate with a reportSpec. Here's a request for daily earnings and clicks over the last seven days:

struct ReportDate: Codable { let year: Int, month: Int, day: Int }

struct NetworkReportRequest: Encodable {
    struct Spec: Encodable {
        struct Range: Encodable { let startDate: ReportDate; let endDate: ReportDate }
        struct Localization: Encodable { let currencyCode: String; let languageCode: String }
        let dateRange: Range
        let dimensions: [String]
        let metrics: [String]
        let localizationSettings: Localization
        let timeZone: String
    }
    let reportSpec: Spec
}

func weeklyRequest(endingOn end: ReportDate, start: ReportDate, currency: String) -> NetworkReportRequest {
    .init(reportSpec: .init(
        dateRange: .init(startDate: start, endDate: end),
        dimensions: ["DATE"],
        metrics: ["ESTIMATED_EARNINGS", "CLICKS", "IMPRESSIONS"],
        localizationSettings: .init(currencyCode: currency, languageCode: "en-US"),
        timeZone: "America/Los_Angeles"
    ))
}

Three facts from Google's reference page shape this request:
  • ESTIMATED_EARNINGS is in micros. "$6.50 would be represented as 6500000." Never put that number in a Double and divide casually; use Decimal.
  • Currency comes from localizationSettings.currencyCode. Google says the currency unit of the earnings metrics is "determined by the localization setting for currency". The approach I'd use for "View earnings in your preferred currency" is to ask AdMob for that currency directly, so the figures are AdMob's own.
  • The time zone currently has only one supported value. Google: "Warning: The 'America/Los_Angeles' is the only supported value at the moment." Section 6 is about what that means.

5. Decoding a streamed report


The response isn't a simple object. It's "the result of a server-side streaming RPC": a JSON array whose first element has a header, then one row per result, then a footer. Each element has exactly one of those keys. So decode each element as an enum-like struct:

struct ReportChunk: Decodable {
    struct Row: Decodable {
        struct DimensionValue: Decodable { let value: String }
        struct MetricValue: Decodable { let microsValue: String?; let integerValue: String?; let doubleValue: Double? }
        let dimensionValues: [String: DimensionValue]
        let metricValues: [String: MetricValue]
    }
    let header: [String: AnyDecodable]?      // dateRange, localizationSettings
    let row: Row?
    let footer: [String: AnyDecodable]?      // matchingRowCount, warnings
}

struct AnyDecodable: Decodable { init(from decoder: Decoder) throws {} }   // we don't need header details here

struct DailyEarnings: Codable, Identifiable {
    var id: String { day }
    let day: String          // "20261022"
    let earnings: Decimal    // in report currency
    let clicks: Int
}

func parse(_ data: Data) throws -> [DailyEarnings] {
    try JSONDecoder().decode([ReportChunk].self, from: data).compactMap(\.row).map { row in
        let micros = Decimal(string: row.metricValues["ESTIMATED_EARNINGS"]?.microsValue ?? "0") ?? 0
        return DailyEarnings(day: row.dimensionValues["DATE"]?.value ?? "",
                             earnings: micros / 1_000_000,
                             clicks: Int(row.metricValues["CLICKS"]?.integerValue ?? "0") ?? 0)
    }
}

Google's example response shows "microsValue": 6500000 as a bare number. The API's JSON mapping for 64-bit integers often uses strings, so I'd decode both forms in production (a small custom init(from:)). The DATE dimension value is YYYYMMDD, as Google's reference describes.

Displaying money is then earnings.formatted(.currency(code: currency)). Don't hand-roll formats, because symbol placement and grouping vary by locale.


6. Why "today" is a Los Angeles day


Here's the subtle part. If reports can only be in America/Los_Angeles, then AdMob's "today" starts at midnight Pacific time. For me in India, that's 12:30 or 1:30 in the afternoon, depending on US daylight saving. A dashboard that labels the latest row "Today" without explanation will confuse anyone outside the Pacific time zone.

The approach I'd use is to compute "today" in the report's calendar and say so in the UI:

struct ReportCalendar {
    static let zone = TimeZone(identifier: "America/Los_Angeles")!
    static var calendar: Calendar { var c = Calendar(identifier: .gregorian); c.timeZone = zone; return c }

    static func reportDate(for date: Date = .now) -> ReportDate {
        let c = calendar.dateComponents([.year, .month, .day], from: date)
        return ReportDate(year: c.year!, month: c.month!, day: c.day!)
    }

    /// Seconds elapsed in the current *report* day; the denominator for live rates.
    static func secondsIntoReportDay(now: Date = .now) -> TimeInterval {
        now.timeIntervalSince(calendar.startOfDay(for: now))
    }
}

The live earning rates in the listing ("per second, minute, and hour") follow naturally. Today's earnings divided by seconds into the report day gives an average rate so far:
func rates(todayEarnings: Decimal, now: Date = .now) -> (perSecond: Decimal, perMinute: Decimal, perHour: Decimal) {
    let seconds = Decimal(max(ReportCalendar.secondsIntoReportDay(now: now), 60))   // avoid tiny denominators just after midnight
    let perSecond = todayEarnings / seconds
    return (perSecond, perSecond * 60, perSecond * 3600)
}

Label it honestly, as "average so far today". Ad revenue isn't evenly spread over a day, and AdMob's figures are estimated earnings that can be revised.

7. Pull to refresh, offline cache and the weekly view


"Pull-to-refresh for fresh data" is one modifier, refreshable (iOS 15+). "Offline-ready with cached data" means saving the last good response, with a timestamp, and showing it whenever the network fails:

import SwiftUI

@MainActor
final class Dashboard: ObservableObject {
    @Published private(set) var days: [DailyEarnings] = []
    @Published private(set) var updatedAt: Date?
    private let cacheURL = URL.cachesDirectory.appending(path: "report-week.json")

    init() {
        if let data = try? Data(contentsOf: cacheURL),
           let cached = try? JSONDecoder().decode(Cached.self, from: data) {
            days = cached.days; updatedAt = cached.at                   // instant first paint, even offline
        }
    }

    func refresh(using api: AdMobAPI) async {
        do {
            let fresh = try await api.lastSevenDays()
            days = fresh; updatedAt = .now
            try? JSONEncoder().encode(Cached(days: fresh, at: .now)).write(to: cacheURL, options: .atomic)
        } catch {
            // Keep showing the cache; the "Updated 2 h ago" label tells the user it's stale.
        }
    }

    var weeklyAverage: Decimal {
        days.isEmpty ? 0 : days.map(\.earnings).reduce(0, +) / Decimal(days.count)
    }

    private struct Cached: Codable { let days: [DailyEarnings]; let at: Date }
}

struct DashboardView: View {
    @StateObject private var model = Dashboard()
    let api: AdMobAPI
    var body: some View {
        List {
            if let updatedAt = model.updatedAt {
                Text("Updated \(updatedAt, format: .relative(presentation: .named))").font(.caption)
            }
            ForEach(model.days.reversed()) { day in
                LabeledContent(day.day, value: day.earnings, format: .currency(code: "USD"))
            }
        }
        .refreshable { await model.refresh(using: api) }
        .task { await model.refresh(using: api) }
    }
}

Putting the cache in Caches is deliberate. It's disposable, it's re-fetchable, and it holds only aggregated numbers from the user's own account on the user's own device. That fits the listing's privacy statement. (AdMobAPI here is the small wrapper around section 4's request, with a valid access token.)

8. Real-world example: AdEarnings Pro


This walkthrough comes from building AdEarnings Pro. The code above is the approach I'd use, written for this article, not a copy of the app's source.

AdEarnings Pro App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Developer Tools
  • First released in November 2025
  • No ratings yet in the US store
  • Supports iOS 17.6 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Secure Google Sign-In authentication" / "Google OAuth authentication is required to access AdMob API services"System-browser OAuth with PKCE, tokens in the Keychain (sections 2–3)
"Your AdMob data is not saved, shared, or accessible to us"Direct device-to-Google calls, no middleman (section 1)
"View today's earnings with live updates. Compare yesterday's performance."networkReport:generate with the DATE dimension (sections 4–5)
"Live earning rates per second, minute, and hour"Rates over the Pacific report day (section 6)
"Weekly average trends for earnings and clicks"ESTIMATED_EARNINGS and CLICKS (sections 4 and 7)
"Convert earnings to any currency…"localizationSettings.currencyCode (section 4)
"Pull-to-refresh for fresh data" / "Offline-ready with cached data"refreshable and a timestamped cache (section 7)
"Revoke consent anytime"Google Account settings and the revoke endpoint (section 3)


AdEarnings Pro App Store screenshot 2

The listing also mentions unpaid earnings, last payment history and GDPR consent management. Those are outside this article's scope.


9. Before you ship an OAuth-powered dashboard: a checklist


  1. Use the narrowest scope that works, and check the granted scope in the token response.
  2. Use the system browser, PKCE (S256) and a state check. No WKWebView for sign-in.
  3. Keep the refresh token in the Keychain, device-only, and reuse it.
  4. Sign-out revokes the token and clears the Keychain and caches.
  5. Handle money as Decimal from micros, formatted with the currency's own format.
  6. Make the report time zone explicit. "Today" is a Pacific day, and the UI says so.
  7. Label rates and estimates honestly: "average so far" and "estimated".
  8. Show cached data instantly, with a visible "Updated…" time.
  9. Complete Google's OAuth app verification if your scopes need it before public release. Google's docs describe an "unverified app" screen otherwise.
  10. No third-party analytics touches the earnings data.

Wrapping up


An earnings dashboard looks like a chart, but it's mostly about trust. Sign in the way Google and Apple both recommend, ask for the least access, keep tokens on the device, talk to the API directly, and be honest about what the numbers mean, including the surprising fact that AdMob's "today" is a Los Angeles day. Get those right, and a glance at your phone replaces a dozen trips to a desktop website.

Try the app, or explore my other work:

What's the one metric you check most in your ad dashboard? Tell me in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Building an Offline Quotes App in SwiftUI: a 50,000-Quote Library, a Daily Quote Without a Server, Honest Streaks and Share-as-Image


Originally published at bestlatestapps.com/blog/buildi…

Bundling and importing a large library into SwiftData, picking a "quote of the day" deterministically, search with recent queries, collections, time-zone-safe streaks, rendering shareable quote cards with ImageRenderer, and Light/Dark/Auto themes

Facts and Quotes app icon

A quotes app looks like the simplest thing in the App Store: a list of text and a share button. Then you build one and the real questions show up. Where do 50,000 quotes live, and how do they get onto the phone without a slow first launch? How does every user get the same "quote of the day" without a server deciding it? What counts as a reading streak when someone flies from Delhi to San Francisco? And how do you turn a quote into an image that looks good on Instagram?

I've worked through those questions building Facts and Quotes, my app of curated quotes, motivational facts and wisdom. It has categories, offline access, search with recent searches, collections, reading streaks and achievements, sharing quotes as images or text, and Light, Dark and Auto themes.

This article walks through how I'd build each of those pieces in SwiftUI and SwiftData. Availability was checked against Apple's documentation. Where I describe an approach rather than the app's exact implementation, I say so.


1. Ship the library in the bundle, import it once


"Offline access, read anywhere" means the quotes ship with the app. A plain JSON file in the bundle is easy to produce, diff and validate. Decoding 50,000 small records is quick, but querying them is better done from a real store, so I'd import into SwiftData on first launch:

import SwiftData
import Foundation

@Model
final class Quote {
    @Attribute(.unique) var id: String
    var text: String
    var author: String
    var category: String
    var isFavorite: Bool = false
    var collections: [QuoteCollection] = []

    init(id: String, text: String, author: String, category: String) {
        self.id = id; self.text = text; self.author = author; self.category = category
    }
}

@Model
final class QuoteCollection {
    var name: String
    var createdAt: Date = Date.now
    @Relationship(inverse: \Quote.collections) var quotes: [Quote] = []
    init(name: String) { self.name = name }
}

struct QuoteRecord: Decodable { let id, text, author, category: String }

The import runs once, in the background, in batches, so memory stays flat and the UI never blocks:
@ModelActor
actor LibraryImporter {
    func importIfNeeded(bundleVersion: Int) throws {
        let key = "library.importedVersion"
        guard UserDefaults.standard.integer(forKey: key) < bundleVersion,
              let url = Bundle.main.url(forResource: "quotes", withExtension: "json") else { return }

        let records = try JSONDecoder().decode([QuoteRecord].self, from: Data(contentsOf: url))
        let existing = Set(try modelContext.fetch(FetchDescriptor<Quote>()).map(\.id))

        for (index, record) in records.enumerated() where !existing.contains(record.id) {
            modelContext.insert(Quote(id: record.id, text: record.text,
                                      author: record.author, category: record.category))
            if index % 2_000 == 0 { try modelContext.save() }   // batch saves
        }
        try modelContext.save()
        UserDefaults.standard.set(bundleVersion, forKey: key)
    }
}

Two design points:
  • A library version number. When an app update adds quotes, bump the version and the importer adds only the new IDs. Favourites and collections are untouched because existing rows are never rewritten.
  • Stable IDs. Give every quote a permanent ID in the source file. If you ever key on the text, fixing a typo would orphan a user's favourite.

For a "daily new quotes automatically updated" feature, the same importer can accept a small JSON file downloaded from your own server. The bundled library guarantees it still works offline on day one.


2. A quote of the day with no server


A "daily quote" should be the same all day, change at midnight and need no network. The trick is a deterministic pick from the local date:

import Foundation

enum DailyPick {
    /// Same answer for everyone on the same local calendar day.
    static func index(for date: Date = .now, count: Int, calendar: Calendar = .current) -> Int {
        guard count > 0 else { return 0 }
        let c = calendar.dateComponents([.year, .month, .day], from: date)
        let key = "\(c.year!)-\(c.month!)-\(c.day!)"
        return Int(stableHash(key) % UInt64(count))
    }

    /// FNV-1a: stable across launches and devices (unlike Swift's `hashValue`).
    static func stableHash(_ string: String) -> UInt64 {
        var hash: UInt64 = 0xcbf2_9ce4_8422_2325
        for byte in string.utf8 {
            hash ^= UInt64(byte)
            hash &*= 0x0000_0100_0000_01B3
        }
        return hash
    }
}

The gotcha worth a paragraph: don't use String.hashValue. Swift randomises hashing per process launch, so your "quote of the day" would change every time the app opened. Use a fixed algorithm like FNV-1a.

Then fetch by a sorted offset rather than loading everything:

func quoteOfTheDay(in context: ModelContext) throws -> Quote? {
    let total = try context.fetchCount(FetchDescriptor<Quote>())          // iOS 17+
    var descriptor = FetchDescriptor<Quote>(sortBy: [SortDescriptor(\.id)])
    descriptor.fetchOffset = DailyPick.index(count: total)
    descriptor.fetchLimit = 1
    return try context.fetch(descriptor).first
}

Sorting by the stable ID matters too. Without a sort order, "the 1,234th quote" isn't guaranteed to be the same row tomorrow.

3. Search across content, author and category, with recent searches


SwiftUI's searchable (iOS 16+) and SwiftData's #Predicate (iOS 17+) do most of the work. localizedStandardContains gives the case- and diacritic-insensitive matching people expect from a search box:

import SwiftUI
import SwiftData

struct SearchView: View {
    @State private var text = ""
    @AppStorage("recentSearches") private var recentData = Data()

    var body: some View {
        NavigationStack {
            QuoteResults(query: text)
                .navigationTitle("Search")
                .searchable(text: $text, prompt: "Quotes, authors, categories")
                .searchSuggestions {
                    if text.isEmpty {
                        ForEach(recent, id: \.self) { term in
                            Text(term).searchCompletion(term)
                        }
                    }
                }
                .onSubmit(of: .search) { remember(text) }
        }
    }

    private var recent: [String] { (try? JSONDecoder().decode([String].self, from: recentData)) ?? [] }

    private func remember(_ term: String) {
        let trimmed = term.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else { return }
        let updated = ([trimmed] + recent.filter { $0.caseInsensitiveCompare(trimmed) != .orderedSame }).prefix(8)
        recentData = (try? JSONEncoder().encode(Array(updated))) ?? Data()
    }
}

struct QuoteResults: View {
    @Query private var quotes: [Quote]

    init(query: String) {
        let q = query
        _quotes = Query(filter: #Predicate<Quote> {
            q.isEmpty ||
            $0.text.localizedStandardContains(q) ||
            $0.author.localizedStandardContains(q) ||
            $0.category.localizedStandardContains(q)
        }, sort: \Quote.author)
    }

    var body: some View {
        List(quotes) { quote in
            VStack(alignment: .leading, spacing: 4) {
                Text("“\(quote.text)”")
                Text("— \(quote.author)").font(.caption).foregroundStyle(.secondary)
            }
        }
    }
}

Rebuilding the Query in the child view's initialiser is the standard way to drive a dynamic predicate from parent state. Recent searches are small and per-device, so @AppStorage is the right home for them.

4. Collections and favourites


With the many-to-many relationship from section 1, collections are a few lines. Favourites are just a flag. The listing says the Favorites collection "automatically syncs your liked quotes", so I'd treat it as a virtual collection, a query, rather than a stored one that can drift out of sync:

struct FavoritesView: View {
    @Query(filter: #Predicate<Quote> { $0.isFavorite }, sort: \Quote.author)
    private var favorites: [Quote]

    var body: some View {
        List(favorites) { Text($0.text) }
            .overlay { if favorites.isEmpty { ContentUnavailableView("No favourites yet", systemImage: "heart") } }
    }
}

func add(_ quote: Quote, to collection: QuoteCollection) {
    guard !collection.quotes.contains(where: { $0.id == quote.id }) else { return }
    collection.quotes.append(quote)
}

Deleting a collection shouldn't delete its quotes. The default delete rule for a relationship, nullify, does exactly that.

5. Streaks that survive time zones


A streak is "the number of consecutive days, ending today, on which you read". The bugs come from storing timestamps and doing arithmetic in seconds. Then 23:50 and 00:10 count as the same day, or a flight makes a day disappear.

The fix is to store calendar days as the user experienced them, as plain date strings, and count backwards:

import Foundation

struct StreakTracker {
    private(set) var days: Set<String>          // "2026-10-22", in the user's local calendar at the time
    var calendar = Calendar.current

    private func key(_ date: Date) -> String {
        let c = calendar.dateComponents([.year, .month, .day], from: date)
        return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
    }

    mutating func recordRead(at date: Date = .now) { days.insert(key(date)) }

    func currentStreak(asOf now: Date = .now) -> Int {
        var day = calendar.startOfDay(for: now)
        // Not read yet today? The streak can still be alive from yesterday.
        if !days.contains(key(day)) {
            day = calendar.date(byAdding: .day, value: -1, to: day)!
        }
        var count = 0
        while days.contains(key(day)) {
            count += 1
            day = calendar.date(byAdding: .day, value: -1, to: day)!
        }
        return count
    }
}

Why this is honest:
  • Each day is recorded in the local calendar at the moment it happens. Reading at 23:55 in Delhi is "the 22nd", whatever happens afterwards.
  • Today doesn't break the streak until it's over. Opening the app at 8 a.m. shouldn't show zero because you haven't read yet.
  • Calendar arithmetic, not 86,400-second arithmetic. date(byAdding: .day…) handles daylight-saving days of 23 or 25 hours.

Achievements, like "7-day streak" or "read 100 quotes", are then pure functions of this data. Compute them; don't store them separately.


6. Share as a beautiful image


"Share quotes as beautiful images or text" is two lines of SwiftUI once you have the pieces: ImageRenderer (iOS 16+) to rasterise a SwiftUI view, and ShareLink with a Transferable item (iOS 16+):

import SwiftUI

struct QuoteCard: View {
    let quote: Quote
    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            Text("“\(quote.text)”").font(.system(size: 44, weight: .semibold, design: .serif))
            Text("— \(quote.author)").font(.title2).foregroundStyle(.white.opacity(0.8))
        }
        .foregroundStyle(.white)
        .padding(80)
        .frame(width: 1080, height: 1350)                        // a 4:5 portrait post
        .background(LinearGradient(colors: [.indigo, .purple], startPoint: .topLeading, endPoint: .bottomTrailing))
    }
}

struct ShareableQuote: Transferable {
    let quote: Quote
    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(exportedContentType: .png) { item in
            await MainActor.run {
                let renderer = ImageRenderer(content: QuoteCard(quote: item.quote))
                renderer.scale = 1                                // the view is already 1080 px wide
                return renderer.uiImage?.pngData() ?? Data()
            }
        }
        ProxyRepresentation { "“\($0.quote.text)” — \($0.quote.author)" }   // text fallback
    }
}

struct ShareButton: View {
    let quote: Quote
    var body: some View {
        ShareLink(item: ShareableQuote(quote: quote),
                  preview: SharePreview("Quote by \(quote.author)")) {
            Label("Share", systemImage: "square.and.arrow.up")
        }
    }
}

Details that matter:
  • Design the card at output pixel size and render with scale = 1. Sizes stay predictable, and text is sharp at the size social apps actually use.
  • Offer text too. The ProxyRepresentation gives the share sheet a plain-text version, so Messages or Notes receive words, not a picture of words.
  • Rendering happens on the main actor. ImageRenderer is main-actor bound, hence the MainActor.run.

7. Light, Dark and Auto, with colour tokens


"Light, Dark, and Auto" is a three-way setting where Auto means "follow the system". In SwiftUI, preferredColorScheme(nil) is exactly that:

enum ThemeSetting: String, CaseIterable { case auto, light, dark
    var scheme: ColorScheme? { switch self { case .auto: nil; case .light: .light; case .dark: .dark } }
}

@main
struct QuotesApp: App {
    @AppStorage("theme") private var theme = ThemeSetting.auto
    var body: some Scene {
        WindowGroup {
            RootView().preferredColorScheme(theme.scheme)
        }
        .modelContainer(for: [Quote.self, QuoteCollection.self])
    }
}

The listing mentions a design system with 50+ colour tokens. The approach I'd use is to define each token once in the asset catalog with light and dark variants, and refer to it by meaning (Color("surfaceElevated"), Color("textSecondary")) rather than by hue. Then a theme change is free, and no screen hard-codes .black.

8. Real-world example: Facts and Quotes


All of this comes from building Facts and Quotes. The code above is the approach I'd use, written for this article, not a copy of the app's source.

Facts and Quotes App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Health & Fitness and Productivity
  • First released in August 2022
  • 3 ratings in the US store, all 5 stars
  • Supports iOS 17.6 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"50,000+ curated quotes", "Offline access - read anywhere, anytime"Bundled library imported into SwiftData (section 1)
"Daily new quotes automatically updated"Versioned importer plus a deterministic daily pick (sections 1–2)
"Search quotes by content, author, or category" / "Recent searches"searchable + #Predicate + searchSuggestions (section 3)
"Create custom collections…" / "Favorites collection automatically syncs your liked quotes"SwiftData relationships and a query-based Favorites (section 4)
"Track your reading streak and daily progress" / "Unlock achievements"Calendar-day streaks (section 5)
"Share quotes as beautiful images or text"ImageRenderer + Transferable + ShareLink (section 6)
"Light, Dark, and Auto theme support" / "50+ color tokens"preferredColorScheme and semantic colours (section 7)


Facts and Quotes App Store screenshot 2

The listing also mentions AI-powered recommendations and haptic feedback. Those are topics for their own articles. I've written about haptics separately, in my Haptics Explorer piece.


9. Before you ship a content app: a checklist


  1. The first launch is fast. The import runs in the background, in batches, and the UI shows today's quote immediately.
  2. Every item has a stable ID. Updates never break favourites or collections.
  3. The daily pick uses a stable hash. No hashValue, and the fetch has a fixed sort order.
  4. Search is case- and accent-insensitive, and recent searches are capped.
  5. Streaks are tested across midnight, daylight saving and a time-zone change.
  6. Shared images are rendered at their target pixel size, with a text fallback.
  7. Auto theme follows the system; no colour is hard-coded.
  8. Attribution is correct. Every quote has an author, or "Unknown". Misattributed quotes are the most common complaint in this category.
  9. Accessibility. Quote text scales with Dynamic Type, and VoiceOver reads the author after the quote.

Wrapping up


The app is simple to use because the hard parts are hidden. Ship content offline and import it once. Make "daily" deterministic. Store days, not seconds. Render share images at their real size. None of it needs a server, which is also why the app works on a plane.

Try the app, or explore my other work:

Got a favourite quote? Drop it in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Building an Offline eBook Reader for iOS: Downloads, Typography Settings, Reading Progress That Survives Font Changes, and Highlights in the iOS 26 Text APIs


Originally published at bestlatestapps.com/blog/buildi…

Downloading and storing public-domain books, previewing before you download, a UITextView-based reader with font, line height, margin and theme settings, saving your place as a character offset instead of a page number, bookmarks and highlights (including iOS 26's multi-range selection), reading time estimates and reading statistics

Novels Club app icon

There's something special about reading a classic on your phone at night, when the book is more than a century old and the screen is the newest thing in the room. Novels Club is my app for that: thousands of public-domain classics, literature, poetry and philosophy, with recommendations, offline downloads, customisable reading settings, progress tracking, bookmarks, highlights and reading statistics.

A reading app sounds like "show text in a scroll view". It isn't, and the interesting problems are subtle. Where does a downloaded book live, and should it go into the user's iCloud backup? How do you let someone preview a book without downloading all of it? And the one that catches everybody: how do you remember someone's place when they change the font size and every "page" moves?

This article walks through how I'd build each part. The APIs are checked against Apple's documentation, including two iOS 27 deprecations in UITextView that matter for highlighting.


1. The library model


A book has metadata you can browse before downloading, and an optional local file once it's downloaded. In SwiftData:

import SwiftData
import Foundation

@Model
final class Book {
    @Attribute(.unique) var id: String
    var title: String
    var author: String
    var categories: [String]
    var wordCount: Int?
    var remoteURL: URL
    var localFileName: String?          // set once downloaded
    var progressOffset: Int = 0         // UTF-16 offset of the first visible character
    var lastOpened: Date?
    @Relationship(deleteRule: .cascade) var marks: [Mark] = []

    init(id: String, title: String, author: String, categories: [String], remoteURL: URL) {
        self.id = id; self.title = title; self.author = author
        self.categories = categories; self.remoteURL = remoteURL
    }

    var isDownloaded: Bool { localFileName != nil }

    /// "About 6 h 40 min" for a 100,000-word novel at 250 words per minute.
    var readingTimeEstimate: Duration? {
        wordCount.map { .seconds(Double($0) / 250 * 60) }
    }
}

@Model
final class Mark {
    enum Kind: String, Codable { case bookmark, highlight }
    var kind: Kind
    var location: Int                   // UTF-16 offset
    var length: Int                     // 0 for bookmarks
    var note: String?
    var created: Date = Date.now
    init(kind: Kind, location: Int, length: Int) { self.kind = kind; self.location = location; self.length = length }
}

Two decisions shape everything later. Positions are character offsets, not page numbers (section 5). And marks cascade-delete with their book, so removing a book never leaves orphaned highlights behind.

The reading-time estimate uses 250 words per minute, a common rule of thumb for adult silent reading. It's an estimate, and the UI should say "about".


2. Downloading books, and keeping them out of iCloud backup


"Download books for offline reading" with URLSession's async API (iOS 15+) is short. The details that matter are where the file goes and whether it's backed up:

import Foundation

enum BookStore {
    static var directory: URL {
        let dir = URL.applicationSupportDirectory.appending(path: "Books", directoryHint: .isDirectory)
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        return dir
    }

    /// Downloads a book, moves it into place and excludes it from iCloud backup.
    static func download(_ book: Book) async throws {
        let (tempURL, response) = try await URLSession.shared.download(from: book.remoteURL)
        guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }

        var destination = directory.appending(path: "\(book.id).txt")
        try? FileManager.default.removeItem(at: destination)
        try FileManager.default.moveItem(at: tempURL, to: destination)   // the temp file is deleted on return

        var values = URLResourceValues()
        values.isExcludedFromBackup = true        // re-downloadable content doesn't belong in backups
        try destination.setResourceValues(values)

        book.localFileName = destination.lastPathComponent
    }
}

  • Application Support, not Caches. The system can purge Caches under storage pressure, and a book vanishing mid-read on a plane is exactly what "offline reading" promises won't happen.
  • isExcludedFromBackup. The books can be downloaded again, so they shouldn't eat the user's iCloud quota. Apple's storage guidance asks for exactly this.
  • Move the temp file immediately. The async download(from:) gives you a temporary file that's deleted once you're done with it, so move it before doing anything else.

For a progress bar, pass a URLSessionTaskDelegate to download(from:delegate:), or use a URLSessionDownloadTask with a URLSessionDownloadDelegate (iOS 7+) and observe the task's progress.


3. Preview before downloading


"Preview books before downloading" doesn't need a separate preview file. If the server supports range requests, ask for just the first few kilobytes:

extension BookStore {
    static func preview(_ book: Book, bytes: Int = 16_384) async throws -> String {
        var request = URLRequest(url: book.remoteURL)
        request.setValue("bytes=0-\(bytes - 1)", forHTTPHeaderField: "Range")
        let (data, response) = try await URLSession.shared.data(for: request)

        // 206 = Partial Content. A 200 means the server ignored Range and sent everything,
        // so keep only the opening part either way.
        guard let http = response as? HTTPURLResponse, [200, 206].contains(http.statusCode) else {
            throw URLError(.badServerResponse)
        }
        let slice = data.prefix(bytes)
        // A byte cut can split a multi-byte character; trim back to valid UTF-8.
        for trim in 0..<4 {
            if let text = String(data: slice.dropLast(trim), encoding: .utf8) { return text }
        }
        return ""
    }
}

That last loop is easy to forget. A byte range can end in the middle of a multi-byte UTF-8 character, such as an accented letter in a French novel, and then String(data:encoding:) returns nil for the whole preview.

4. A reader with real typography settings


The listing promises "font size, font family, line height, margins" and "light, dark, and custom color schemes". UITextView (read-only) gives you excellent text layout, selection and accessibility for free. Since iOS 16 it can be created explicitly with TextKit 2 via init(usingTextLayoutManager:):

import SwiftUI
import UIKit

struct ReaderSettings: Equatable {
    var fontName = "Georgia"
    var fontSize: CGFloat = 19
    var lineHeight: CGFloat = 1.45       // multiple of the font's natural line height
    var margin: CGFloat = 24
    var theme = ReaderTheme.sepia
}

struct ReaderTheme: Equatable {
    var background: UIColor, text: UIColor
    static let light = ReaderTheme(background: .white, text: .black)
    static let sepia = ReaderTheme(background: UIColor(red: 0.98, green: 0.95, blue: 0.88, alpha: 1),
                                   text: UIColor(red: 0.26, green: 0.20, blue: 0.14, alpha: 1))
    static let dark  = ReaderTheme(background: UIColor(white: 0.08, alpha: 1), text: UIColor(white: 0.85, alpha: 1))
}

struct ReaderView: UIViewRepresentable {
    let text: String
    let settings: ReaderSettings
    @Binding var offset: Int             // reading position, see section 5

    func makeUIView(context: Context) -> UITextView {
        let view = UITextView(usingTextLayoutManager: true)   // TextKit 2 (iOS 16+)
        view.isEditable = false
        view.isSelectable = true                              // needed for highlights
        view.delegate = context.coordinator
        return view
    }

    func updateUIView(_ view: UITextView, context: Context) {
        guard context.coordinator.applied != settings else { return }
        context.coordinator.applied = settings

        let font = UIFont(name: settings.fontName, size: settings.fontSize)
            ?? .systemFont(ofSize: settings.fontSize)
        let paragraph = NSMutableParagraphStyle()
        paragraph.lineHeightMultiple = settings.lineHeight
        paragraph.paragraphSpacing = settings.fontSize * 0.6

        view.attributedText = NSAttributedString(string: text, attributes: [
            .font: UIFontMetrics(forTextStyle: .body).scaledFont(for: font),   // respects Dynamic Type
            .foregroundColor: settings.theme.text,
            .paragraphStyle: paragraph
        ])
        view.backgroundColor = settings.theme.background
        view.textContainerInset = UIEdgeInsets(top: 32, left: settings.margin, bottom: 48, right: settings.margin)

        context.coordinator.restore(offset, in: view)          // keep the reader's place after re-layout
    }

    func makeCoordinator() -> Coordinator { Coordinator(offset: $offset) }
}

The applied != settings guard matters. Rebuilding the attributed string for a 150,000-word novel is expensive, so do it only when a setting actually changes, not on every SwiftUI update. For very long books, the approach I'd use is to split by chapter and load one chapter at a time.

5. Reading progress that survives font changes


Here's the trap. If you save "page 143", or a scroll offset of 18,400 points, then the moment someone bumps the font from 17 to 21 point, page 143 is a different passage. The fix is to save the character the reader is looking at, a UTF-16 offset into the text, which no layout change can move:

final class Coordinator: NSObject, UITextViewDelegate {
    var offset: Binding<Int>
    var applied: ReaderSettings?
    var onHighlight: (([NSRange]) -> Void)?      // set by the reader screen to save Marks
    init(offset: Binding<Int>) { self.offset = offset }

    /// The first character at the top-left of the visible area.
    func currentOffset(in view: UITextView) -> Int {
        let topLeft = CGPoint(x: view.textContainerInset.left + 1,
                              y: view.contentOffset.y + view.textContainerInset.top + 1)
        guard let position = view.closestPosition(to: topLeft) else { return 0 }
        return view.offset(from: view.beginningOfDocument, to: position)    // UTF-16 units
    }

    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { save(scrollView) }
    func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate: Bool) {
        if !willDecelerate { save(scrollView) }
    }
    private func save(_ scrollView: UIScrollView) {
        guard let view = scrollView as? UITextView else { return }
        offset.wrappedValue = currentOffset(in: view)
    }

    /// After re-layout, bring that character back to the top.
    func restore(_ offset: Int, in view: UITextView) {
        DispatchQueue.main.async {                                  // after layout has happened
            guard let start = view.position(from: view.beginningOfDocument, offset: offset),
                  let end = view.position(from: start, offset: 1),
                  let range = view.textRange(from: start, to: end) else { return }
            let rect = view.firstRect(for: range)
            view.setContentOffset(CGPoint(x: 0, y: max(0, rect.minY - view.textContainerInset.top)), animated: false)
        }
    }
}

closestPosition(to:) and offset(from:to:) come from UITextInput (iOS 3.2+), which UITextView adopts. They do exactly what we need: convert between points on screen and positions in the text. With this:
  • Changing the font, line height or margins keeps the same sentence at the top of the screen.
  • "Continue reading where you left off" is just opening the book and restoring progressOffset.
  • Percentage progress is offset / text.utf16.count, which is meaningful whatever the layout.

Save on scroll end, not on every scroll event. Writing to SwiftData 60 times a second while flicking through a chapter is wasted work.


6. Bookmarks and highlights, and the iOS 26 text-selection change


Since positions are character offsets, a highlight is just (location, length) and a bookmark is (location, 0). Both are stable because a public-domain text never changes. Rendering highlights is an attribute pass:

func applyHighlights(_ marks: [Mark], to storage: NSMutableAttributedString, color: UIColor) {
    for mark in marks where mark.kind == .highlight {
        let range = NSRange(location: mark.location, length: mark.length)
        guard NSMaxRange(range) <= storage.length else { continue }
        storage.addAttribute(.backgroundColor, value: color.withAlphaComponent(0.35), range: range)
    }
}

To create highlights, add an item to the text view's edit menu. This is where the APIs are changing:
  • iOS 16 introduced textView(_:editMenuForTextIn:suggestedActions:), which gives you a single NSRange. Apple's documentation now marks it deprecated in iOS 27.
  • iOS 26 added textView(_:editMenuForTextInRanges:suggestedActions:), which gives you an array of ranges. Apple's documentation explains that a selection can be several discontiguous ranges, for example when bidirectional text is selected. Correspondingly, UITextView.selectedRange is marked deprecated in iOS 27, alongside the newer selectedRanges.

Since the app supports iOS 16.6 and later, I'd implement both and funnel them into one function:

extension Coordinator {
    private func highlightAction(for ranges: [NSRange]) -> UIAction {
        UIAction(title: "Highlight", image: UIImage(systemName: "highlighter")) { [weak self] _ in
            self?.onHighlight?(ranges.filter { $0.length > 0 })
        }
    }

    // iOS 26+: one or more selected ranges.
    @available(iOS 26, *)
    func textView(_ textView: UITextView, editMenuForTextInRanges ranges: [NSValue],
                  suggestedActions: [UIMenuElement]) -> UIMenu? {
        UIMenu(children: [highlightAction(for: ranges.map(\.rangeValue))] + suggestedActions)
    }

    // iOS 16–25: a single range (deprecated in iOS 27).
    func textView(_ textView: UITextView, editMenuForTextIn range: NSRange,
                  suggestedActions: [UIMenuElement]) -> UIMenu? {
        UIMenu(children: [highlightAction(for: [range])] + suggestedActions)
    }
}

The iOS 26 method's [NSValue] parameter type is taken from Apple's published declaration. Each value wraps an NSRange.

7. Reading statistics without surveillance


"Track your reading statistics: books read, pages read, reading time" and "reading streaks". The honest way to measure reading time is to count only while the reader is on screen and the app is active:

struct ReadingClock {
    private var startedAt: Date?
    private(set) var total: TimeInterval = 0

    mutating func resume() { if startedAt == nil { startedAt = .now } }
    mutating func pause() {
        if let start = startedAt { total += min(Date.now.timeIntervalSince(start), 30 * 60) }  // cap idle sessions
        startedAt = nil
    }
}
// In the reader: resume() on appear and when scenePhase becomes .active;
// pause() on disappear and when it leaves .active.

The 30-minute cap per session stops a phone left open on the nightstand from claiming you read for eight hours. "Pages read" can be derived from the change in character offset divided by a nominal page size (say 1,800 characters), which gives consistent numbers whatever font you use. Streaks follow the calendar-day approach I described in my Facts and Quotes article.

8. Real-world example: Novels Club


Everything here comes from building Novels Club. The code is the approach I'd use, written for this article, not the shipped source.

Novels Club App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Magazines & Newspapers and Entertainment
  • First released in December 2025
  • No ratings yet in the US store
  • Supports iOS 16.6 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"All books are in the public domain, so you can read them completely"Why offsets and highlights can be stable (sections 1 and 6)
"Download books for offline reading" / "All your downloaded books are stored locally"Downloads in Application Support, excluded from backup (section 2)
"Preview books before downloading"Range-request previews (section 3)
"Customizable reading settings: font size, font family, line height, margins" / "light, dark, and custom color schemes"The TextKit 2 reader (section 4)
"Reading progress tracking" / "Continue reading where you left off"Character-offset progress (section 5)
"Bookmarks and highlights for your favorite passages"Marks and the edit menu (section 6)
"See book ratings, reading time estimates, and difficulty levels"Reading-time estimates (section 1)
"Track your reading statistics…" / "View reading streaks"Honest reading time (section 7)


Novels Club App Store screenshot 2

With no ratings yet, the app is at the stage where every piece of feedback shapes it. If you read classics on your phone, I'd love to hear what your current reader gets wrong.


9. Before you ship a reading app: a checklist


  1. Books live in Application Support, excluded from iCloud backup.
  2. Previews handle partial UTF-8 and servers that ignore Range.
  3. Re-layout happens only when settings change; long books load by chapter.
  4. Progress is stored as a character offset and restored after every settings change.
  5. Progress is saved on scroll end, not continuously.
  6. Highlights are offset ranges, with both edit-menu delegate methods (iOS 16 and iOS 26+).
  7. Dynamic Type works, and the custom font size is a multiplier on top of it.
  8. Reading time only counts while the reader is active, with an idle cap.
  9. Themes are tested in a dark room. Pure white on black is harsh; slightly grey text reads better.
  10. Copyright is respected. Check the public-domain status of every title for the regions you distribute in, because it varies by country.

Wrapping up


A good reader disappears. You open the book, you're exactly where you left off, the text looks the way you like, and nothing reminds you it's an app. Getting there means a few unglamorous decisions: store characters instead of pages, keep books out of backups, re-lay out only when you must, and adopt the new multi-range text APIs while keeping the old ones for older systems.

Try the app, or explore my other work:

Which classic should everyone read at least once? Tell me in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Building an Offline-First Bible Reader on iOS: Bundled SQLite, Full-Text Search and iCloud-Synced Notes


Originally published at bestlatestapps.com/blog/buildi…

How I'd structure a scripture app that works on a plane: a read-only text database, fast search, user data that follows you between devices, read-aloud and a verse-of-the-day widget

Bible Study AI app icon

The first app I ever published under my App Store developer name was a Bible reader. It went live on 20 April 2020, a day ahead of the next few apps I shipped that month, and today it's called Bible Study AI – Read Offline. It has grown a lot since then: reading plans, devotionals, a prayer journal, widgets and an AI study assistant. But the promise in the name has never changed. It works offline.

"Offline" sounds like a feature you tick off. It isn't. It's an architectural decision, and it shapes everything: where the text lives, how search works, where highlights and notes are saved, how sync behaves when the network comes back, and what you do on a device that has never been online since the app was installed.

This article walks through the architecture I'd recommend for this kind of app: a large, read-only text corpus plus a small amount of precious user data. Scripture is the example, but the same shape fits dictionaries, manuals, legal codes, recipe books and course material. All the code is Swift, and I've checked API names and availability against Apple's developer documentation. Where I'm describing the approach I'd use rather than the app's exact internals, I say so.


1. The problem: two kinds of data with opposite needs


A Bible app holds two very different kinds of data.

The text itself. All 66 books, in several translations; the app's listing mentions KJV, ASV, Webster and World English. That's tens of thousands of verses per translation. It's large, it never changes on the device, and it must be searchable in milliseconds. It belongs to you, the developer, not the user.

What the user creates. Highlights in different colours, notes on verses and chapters, bookmarks, reading-plan progress, prayer journal entries. It's small, it changes all the time, and it's the only data the user would be upset to lose. It belongs to them, and they expect it on their iPad as well as their iPhone.

The mistake I see most often is putting both in one store. Then every app update that fixes a typo in the text has to migrate a database full of user notes, and every sync pushes megabytes of scripture to iCloud for no reason.

So split them:

App bundle (read-only)                 User store (read-write, synced)
┌──────────────────────────┐           ┌──────────────────────────────┐
│ bible.sqlite             │           │ Core Data + CloudKit         │
│  translations, books,    │  verse    │  Highlight, Note, Bookmark,  │
│  chapters, verses        │◀─ id ────▶│  PlanProgress, Prayer        │
│  verses_fts (FTS5)       │           │                              │
└──────────────────────────┘           └──────────────────────────────┘

The only thing that links them is a stable verse identifier. Get that right first.

2. A verse ID that will never change


A verse needs an identifier that's identical across translations, app versions and devices. I use a simple integer: book * 1_000_000 + chapter * 1_000 + verse. John 3:16 is book 43, so its ID is 43_003_016.

struct VerseRef: Hashable, Codable, Comparable {
    let book: Int      // 1...66, canonical order
    let chapter: Int
    let verse: Int

    var id: Int { book * 1_000_000 + chapter * 1_000 + verse }

    init(book: Int, chapter: Int, verse: Int) {
        self.book = book; self.chapter = chapter; self.verse = verse
    }

    init(id: Int) {
        book = id / 1_000_000
        chapter = (id / 1_000) % 1_000
        verse = id % 1_000
    }

    static func < (lhs: VerseRef, rhs: VerseRef) -> Bool { lhs.id < rhs.id }
}

Why an integer and not a string like "JHN.3.16"? It sorts correctly, ranges are cheap (BETWEEN 43003001 AND 43003999 is "all of John 3"), and it's a compact key in both SQLite and Core Data. A highlight saved against 43003016 shows up in KJV and in World English alike, because the ID describes the position, not the wording.

One caveat from experience: versification isn't identical in every tradition. A few translations number some verses differently. If you add a translation that disagrees, map it onto your canonical scheme when you build the database, never at runtime.


3. Shipping the text as a bundled, read-only SQLite database


I build the text database offline with a script, add bible.sqlite to the app target and open it read-only straight from the bundle. No copying to Documents, no migrations, no risk of corruption.

The schema is tiny:

CREATE TABLE verses (
  translation TEXT NOT NULL,   -- 'KJV', 'ASV', 'WEB', ...
  id          INTEGER NOT NULL, -- VerseRef.id
  text        TEXT NOT NULL,
  PRIMARY KEY (translation, id)
) WITHOUT ROWID;

-- Full-text index (built by the script, shipped in the file)
CREATE VIRTUAL TABLE verses_fts USING fts5(
  text, translation UNINDEXED, id UNINDEXED,
  tokenize = 'unicode61 remove_diacritics 2'
);

Opening it from Swift with the system SQLite library:
import Foundation
import SQLite3

enum TextStoreError: Error { case openFailed(String), queryFailed(String) }

final class BibleTextStore {
    private var db: OpaquePointer?

    init() throws {
        guard let url = Bundle.main.url(forResource: "bible", withExtension: "sqlite") else {
            throw TextStoreError.openFailed("bible.sqlite missing from bundle")
        }
        // immutable=1 tells SQLite the file can never change, so it skips locking.
        let uri = "file:\(url.path)?immutable=1"
        let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_URI
        guard sqlite3_open_v2(uri, &db, flags, nil) == SQLITE_OK else {
            throw TextStoreError.openFailed(String(cString: sqlite3_errmsg(db)))
        }
    }

    deinit { sqlite3_close(db) }

    func chapter(_ book: Int, _ chapter: Int, translation: String) throws -> [(VerseRef, String)] {
        let sql = "SELECT id, text FROM verses WHERE translation = ? AND id BETWEEN ? AND ? ORDER BY id"
        var stmt: OpaquePointer?
        guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
            throw TextStoreError.queryFailed(String(cString: sqlite3_errmsg(db)))
        }
        defer { sqlite3_finalize(stmt) }

        let start = VerseRef(book: book, chapter: chapter, verse: 0).id
        sqlite3_bind_text(stmt, 1, translation, -1, SQLITE_TRANSIENT)
        sqlite3_bind_int64(stmt, 2, Int64(start))
        sqlite3_bind_int64(stmt, 3, Int64(start + 999))

        var rows: [(VerseRef, String)] = []
        while sqlite3_step(stmt) == SQLITE_ROW {
            let id = Int(sqlite3_column_int64(stmt, 0))
            let text = String(cString: sqlite3_column_text(stmt, 1))
            rows.append((VerseRef(id: id), text))
        }
        return rows
    }
}

// The C macro SQLITE_TRANSIENT doesn't import into Swift; this is the usual stand-in.
private let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)

Why raw SQLite and not Core Data for the text? Core Data is great for object graphs you edit. A read-only corpus with a full-text index is a different job, and SQLite's FTS5 does that job directly. You can wrap this in GRDB or another library if you prefer; the shape doesn't change.

4. Search that feels instant


The listing promises "Search across all books, chapters, and verses" and "Advanced search with history". With an FTS5 index shipped in the file, the query is one statement:

extension BibleTextStore {
    struct Hit { let ref: VerseRef; let snippet: String }

    func search(_ query: String, translation: String, limit: Int = 100) throws -> [Hit] {
        // Turn "love one another" into "love* one* another*": prefix match on every word.
        let terms = query
            .split(whereSeparator: { !$0.isLetter && !$0.isNumber })
            .map { "\($0)*" }
            .joined(separator: " ")
        guard !terms.isEmpty else { return [] }

        let sql = """
        SELECT id, snippet(verses_fts, 0, '[', ']', '…', 12)
        FROM verses_fts
        WHERE verses_fts MATCH ? AND translation = ?
        ORDER BY rank LIMIT ?
        """
        var stmt: OpaquePointer?
        guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
            throw TextStoreError.queryFailed(String(cString: sqlite3_errmsg(db)))
        }
        defer { sqlite3_finalize(stmt) }
        sqlite3_bind_text(stmt, 1, terms, -1, SQLITE_TRANSIENT)
        sqlite3_bind_text(stmt, 2, translation, -1, SQLITE_TRANSIENT)
        sqlite3_bind_int(stmt, 3, Int32(limit))

        var hits: [Hit] = []
        while sqlite3_step(stmt) == SQLITE_ROW {
            hits.append(Hit(ref: VerseRef(id: Int(sqlite3_column_int64(stmt, 0))),
                            snippet: String(cString: sqlite3_column_text(stmt, 1))))
        }
        return hits
    }
}

A few things that matter more than the SQL:
  • Sanitise the query. FTS5 has its own query syntax; a stray quote or hyphen from the user can cause a syntax error. Splitting on non-alphanumerics and rebuilding the string, as above, avoids that.
  • Debounce in the UI. Search on every keystroke, but cancel the previous task. A .task(id: query) modifier in SwiftUI with a short Task.sleep at the top gives you that for free.
  • Run it off the main thread. It's fast, but "fast" on an older iPhone in Low Power Mode isn't the same as on your desk.
  • Search history is user data, so it goes in the user store (next section), not the read-only file.

5. User data: Core Data with CloudKit mirroring


The listing says "iCloud sync across all your devices… Access your highlights, notes, and bookmarks anywhere". The app supports iOS 16.0 and later, which rules out SwiftData (iOS 17+). The natural fit is NSPersistentCloudKitContainer (iOS 13+), which keeps a normal local Core Data store and mirrors it to the user's private CloudKit database in the background.

That's exactly the offline-first behaviour you want: every write goes to the local store first and works with no network; sync catches up later.

import CoreData

final class UserStore {
    static let shared = UserStore()
    let container: NSPersistentCloudKitContainer

    private init() {
        container = NSPersistentCloudKitContainer(name: "UserData") // UserData.xcdatamodeld

        guard let description = container.persistentStoreDescriptions.first else {
            fatalError("No store description")
        }
        description.cloudKitContainerOptions =
            NSPersistentCloudKitContainerOptions(containerIdentifier: "iCloud.com.example.bible")
        // Needed so the app can react to changes that arrive from other devices.
        description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
        description.setOption(true as NSNumber,
                              forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

        container.loadPersistentStores { _, error in
            if let error { print("Store failed to load: \(error)") }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
}

The Highlight entity is deliberately boring: verseID (Integer 64), translation (String, optional), colorName (String), createdAt (Date) and a UUID id. A few CloudKit rules shape that model, and they catch everyone once:
  • Every attribute must be optional or have a default value. CloudKit can deliver records in any order and partially.
  • No unique constraints. CloudKit mirroring doesn't support them. If the user highlights the same verse on two offline devices, you'll get two rows; de-duplicate in code (keep the newest per verseID).
  • Relationships must be optional and need inverses.
  • Enable the capabilities: iCloud with CloudKit and your container, plus Background Modes → Remote notifications, so pushes from CloudKit can wake the app to sync.
  • Deploy the schema to production in the CloudKit Console before you ship. A development schema works on your device and silently fails for users. During development, initializeCloudKitSchema(options:) can create the schema from your model for you.

Saving a highlight is then plain Core Data:

extension UserStore {
    func toggleHighlight(_ ref: VerseRef, color: String) throws {
        let context = container.viewContext
        let request = NSFetchRequest<NSManagedObject>(entityName: "Highlight")
        request.predicate = NSPredicate(format: "verseID == %lld", Int64(ref.id))

        let existing = try context.fetch(request)
        if existing.isEmpty {
            let h = NSEntityDescription.insertNewObject(forEntityName: "Highlight", into: context)
            h.setValue(UUID(), forKey: "id")
            h.setValue(Int64(ref.id), forKey: "verseID")
            h.setValue(color, forKey: "colorName")
            h.setValue(Date(), forKey: "createdAt")
        } else {
            existing.forEach(context.delete) // also cleans up offline duplicates
        }
        try context.save()
    }
}

On the reading screen, fetch highlights for the chapter's ID range and merge them with the verse rows from SQLite in a view model. The two stores never need to know about each other.

6. Listen mode with AVSpeechSynthesizer


The listing's "Listen & Read" section mentions text-to-speech and auto-scroll. On iOS that's AVSpeechSynthesizer (iOS 7+), and its delegate tells you which range of text is about to be spoken, which is all you need to highlight and scroll along.

import AVFoundation

@MainActor
final class ReadAloud: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
    @Published var currentVerse: VerseRef?
    private let synth = AVSpeechSynthesizer()
    private var refs: [ObjectIdentifier: VerseRef] = [:]

    override init() {
        super.init()
        synth.delegate = self
    }

    func speak(chapter verses: [(VerseRef, String)], language: String = "en-US") {
        synth.stopSpeaking(at: .immediate)
        refs = [:]
        for (ref, text) in verses {
            let u = AVSpeechUtterance(string: text)
            u.voice = AVSpeechSynthesisVoice(language: language)
            u.rate = AVSpeechUtteranceDefaultSpeechRate
            u.postUtteranceDelay = 0.15        // a breath between verses
            refs[ObjectIdentifier(u)] = ref
            synth.speak(u)                     // utterances are queued in order
        }
    }

    func pause() { synth.pauseSpeaking(at: .word) }
    func resume() { synth.continueSpeaking() }

    nonisolated func speechSynthesizer(_ s: AVSpeechSynthesizer,
                                       didStart utterance: AVSpeechUtterance) {
        let key = ObjectIdentifier(utterance)
        Task { @MainActor in
            self.currentVerse = self.refs[key]
        }
    }
}

The view observes currentVerse and calls ScrollViewReader.scrollTo(_:anchor:) with the verse ID. For word-level highlighting, implement speechSynthesizer(_:willSpeakRangeOfSpeechString:utterance:) as well.

Two practical notes. If you want speech to continue with the screen locked ("perfect for listening while commuting"), set the audio session category to .playback and add the audio background mode, and only while speech is actually running. And the voices available vary by device and by what the user has downloaded in Settings, so let them pick, and fall back gracefully when AVSpeechSynthesisVoice(language:) returns nil.


7. Verse of the Day on the Home Screen with WidgetKit


The listing has "Home screen widgets: quick access to verse of the day". A widget runs in its own extension process, so it can't reach into the app's memory. Two things make this simple with the architecture above:

  1. The text database is read-only and in the bundle, so the widget extension can ship its own copy (or you can share it through an App Group container). No sync needed.
  2. The verse of the day is a deterministic function of the date. Every device shows the same verse without a server.


import WidgetKit
import SwiftUI

struct VerseEntry: TimelineEntry {
    let date: Date
    let reference: String
    let text: String
}

struct VerseProvider: TimelineProvider {
    func placeholder(in context: Context) -> VerseEntry {
        VerseEntry(date: .now, reference: "John 3:16", text: "For God so loved the world…")
    }

    func getSnapshot(in context: Context, completion: @escaping (VerseEntry) -> Void) {
        completion(entry(for: .now))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<VerseEntry>) -> Void) {
        let now = Date()
        let tomorrow = Calendar.current.startOfDay(for: now.addingTimeInterval(86_400))
        completion(Timeline(entries: [entry(for: now)], policy: .after(tomorrow)))
    }

    private func entry(for date: Date) -> VerseEntry {
        // Pick from a curated list by day-of-year: same verse for everyone, every device.
        let day = Calendar.current.ordinality(of: .day, in: .year, for: date) ?? 1
        let pick = DailyVerses.list[(day - 1) % DailyVerses.list.count]
        return VerseEntry(date: date, reference: pick.reference, text: pick.text)
    }
}

struct VerseOfTheDayWidget: Widget {
    var body: some WidgetConfiguration {
        StaticConfiguration(kind: "VerseOfTheDay", provider: VerseProvider()) { entry in
            VStack(alignment: .leading, spacing: 6) {
                Text(entry.text).font(.callout).lineLimit(5)
                Text(entry.reference).font(.caption).foregroundStyle(.secondary)
            }
            .containerBackground(.fill.tertiary, for: .widget) // iOS 17+
        }
        .configurationDisplayName("Verse of the Day")
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}

DailyVerses.list is a small curated array compiled into the extension. With an iOS 16 deployment target, wrap containerBackground in an availability check, since that modifier is iOS 17+. The .after(tomorrow) policy asks WidgetKit to refresh just after midnight. It's a request, not a guarantee, so the entry should still read correctly if WidgetKit refreshes a bit late.

8. Real-world example: Bible Study AI – Read Offline


All of this comes from building and maintaining my own app, Bible Study AI – Read Offline. The patterns above are the approach I'd use and recommend; I'm describing the architecture, not giving a line-by-line tour of the shipping code.

Bible Study AI App Store screenshot 1

At the time of writing, its public App Store listing shows:

  • Free, in the Books and Education categories
  • First released in April 2020: my first app on the store
  • 113 ratings in the US store, averaging about 4.6 stars
  • Supports iOS 16.0 and later, on iPhone and iPad

Here's how the listing's features map to the sections above:

What the listing saysWhere it's covered
"Read all 66 books of the Bible offline" / "Multiple translations: KJV, ASV, Webster, World English, and more"Bundled read-only SQLite, one table keyed by translation + verse ID (sections 2–3)
"Search across all books, chapters, and verses" / "Advanced search with history"FTS5 index shipped in the file, sanitised prefix queries (section 4)
"Highlight verses with multiple colors" / "Add personal notes" / "Bookmark favorite passages"Small Core Data entities keyed by verse ID (section 5)
"iCloud sync across all your devices"NSPersistentCloudKitContainer mirroring to the private database (section 5)
"Text-to-speech support" / "Auto-scroll for hands-free reading"AVSpeechSynthesizer and its delegate driving the scroll position (section 6)
"Home screen widgets: quick access to verse of the day"WidgetKit StaticConfiguration, date-derived entry (section 7)


Bible Study AI App Store screenshot 2

The listing also covers reading plans, daily devotionals, a prayer journal with reminders and a "Bible Study Assistant" for AI-powered answers. Those all sit on top of the same split: plan progress and prayers are user data in the synced store, reminders are local notifications, and the assistant is the one feature that needs a network. I've left the AI part out of this article on purpose. It's the one piece that isn't offline, and the most important design rule there is simply to make sure the rest of the app never waits on it.


9. Before you ship an offline-first reader: a checklist


  1. Airplane-mode test from a fresh install. Delete the app, turn on Airplane Mode, install from TestFlight… then read, search, highlight, take a note and listen. Everything except network features must work.
  2. Text and user data are separate. The corpus is read-only in the bundle; user data lives in its own store. An app update that changes the text never touches user data.
  3. Stable IDs everywhere. Every highlight, note and bookmark points at a canonical verse ID, never at a row number or a translation-specific string.
  4. The CloudKit model follows the rules. Optional attributes or defaults, no unique constraints, optional relationships with inverses, and the schema is deployed to production.
  5. Duplicates are handled. Two offline devices editing the same verse produce two records; your code merges them predictably.
  6. Search can't crash on user input. Quotes, hyphens, emoji and other scripts are all tested against your FTS query builder.
  7. Background audio is honest. The audio mode is used only while read-aloud is playing, and the session is deactivated when it stops.
  8. The widget works with no app launch. Install, add the widget, never open the app: it should still show a verse.

Wrapping up


Offline-first sounds like a constraint, but for a reading app it makes things simpler. A read-only database in the bundle can't be corrupted and never needs migrating. A small, separate user store syncs cheaply. And a verse ID that never changes ties the two together. Add AVSpeechSynthesizer and WidgetKit and you get most of what people want from a daily reading app, without a single required network call.

If you want to see where this ended up, try the app:

If you've built a large offline corpus on iOS and solved sync or search differently, tell me in the comments. I'm always curious how other people draw the line between "bundled" and "user" data.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/buildi…


Convert-As-You-Type Currency Conversion in Swift: Decimal Maths, Cached Rates and Locale-Aware Formatting


Originally published at bestlatestapps.com/blog/conver…

Why Double is the wrong type for money, how to derive any cross rate from one base, keeping rates fresh without a refresh button, and formatting 150+ currencies correctly

Currency Converter : Fast app icon

A currency converter is used at the worst possible moments: at a market stall abroad, at a checkout page in another currency, in a taxi with a meter running. Nobody wants to tap "Convert". Nobody wants a spinner. And nobody wants to see 0.30000000000000004 on the screen.

Currency Converter : Fast is one of my first apps, published in April 2020. Its listing describes the whole philosophy in a few lines: 150+ currencies, "converts as you type (no need for a 'Convert' button!)", exchange rates "updated automatically — you don't have to do anything!", and 3 decimal places "for greater accuracy".

Each of those promises maps onto a specific technical decision: the number type, the rate model, caching and refresh, and formatting. This article goes through how I'd build each one in Swift. API names and availability are checked against Apple's documentation. I describe the approach I'd use rather than the app's internals, and I deliberately don't name a rate provider: the listing doesn't, and the design is the same whichever one you use.


1. Money is not a Double


Binary floating point can't represent most decimal fractions exactly. 0.1 + 0.2 isn't 0.3, and after a few multiplications by exchange rates, the error shows up in exactly the decimal places you're displaying.

Foundation's Decimal is a base-10 number type, so 0.1 really is 0.1:

import Foundation

let a: Double = 0.1 + 0.2
print(a)                                  // 0.30000000000000004

let b = Decimal(string: "0.1")! + Decimal(string: "0.2")!
print(b)                                  // 0.3

The rule I follow: amounts and rates are Decimal from the moment they enter the app, whether that's the user's typing or the JSON from the rates API, until the moment they're formatted for display. Two gotchas:
  • Decimal(0.1) converts from a Double, so the binary error comes with it. Initialise from strings: Decimal(string:locale:).
  • JSONDecoder decodes Decimal fields via Double internally on some systems, which can reintroduce error in the last digits. If exactness matters, decode rates as Strings and convert them yourself, as below.

2. One base, every cross rate


You don't need a 150 × 150 table. Fetch every currency's rate against one base (say USD), and derive any pair:

amount_in_B = amount_in_A × (rate_B / rate_A)       where rate_X = units of X per 1 base
import Foundation

struct RateTable: Codable {
    let base: String                     // "USD"
    let rates: [String: Decimal]         // "EUR": 0.92 means 1 USD = 0.92 EUR
    let fetchedAt: Date

    func rate(from: String, to: String) -> Decimal? {
        guard let a = from == base ? 1 : rates[from],
              let b = to == base ? 1 : rates[to],
              a != 0 else { return nil }
        return b / a
    }

    func convert(_ amount: Decimal, from: String, to: String) -> Decimal? {
        rate(from: from, to: to).map { amount * $0 }
    }
}

// Decode rates exactly: the API sends numbers, but we read them as strings first.
struct RatesResponse: Decodable {
    let base: String
    let rates: [String: Decimal]

    private enum CodingKeys: String, CodingKey { case base, rates }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        base = try c.decode(String.self, forKey: .base)
        // Assumes the provider sends rates as JSON strings; if it sends numbers,
        // decode [String: Double] and convert via "\(value)" to limit binary error.
        let raw = try c.decode([String: String].self, forKey: .rates)
        rates = raw.compactMapValues { Decimal(string: $0, locale: Locale(identifier: "en_US_POSIX")) }
    }
}

RateTable.rate(from:to:) is also how you show "the current exchange rate of what you're converting", one of the listing's features: 1 EUR = 1.087 USD, computed from the same table.

Note the en_US_POSIX locale when parsing API data. API numbers always use . as the decimal separator, whatever the user's device language is.


3. Convert as you type


With Decimal maths and a local rate table, conversion is instant and synchronous. There's no reason for a button. In SwiftUI:

import SwiftUI

@MainActor
final class ConverterModel: ObservableObject {
    @Published var input = ""
    @Published var from = "USD"
    @Published var to = "EUR"
    @Published private(set) var table: RateTable?

    private let parser: NumberFormatter = {
        let f = NumberFormatter()
        f.numberStyle = .decimal
        f.generatesDecimalNumbers = true   // parse straight to NSDecimalNumber, never via Double
        f.isLenient = true
        return f
    }()

    var amount: Decimal? {
        (parser.number(from: input) as? NSDecimalNumber)?.decimalValue
    }

    var result: Decimal? {
        guard let amount, let table else { return nil }
        return table.convert(amount, from: from, to: to)
    }

    func swap() { (from, to) = (to, from) }
}

struct ConverterView: View {
    @StateObject private var model = ConverterModel()

    var body: some View {
        Form {
            Section {
                TextField("Amount", text: $model.input)
                    .keyboardType(.decimalPad)
                    .font(.largeTitle.monospacedDigit())
                CurrencyPicker(title: "From", code: $model.from)
            }
            Section {
                Text(model.result.map { CurrencyText.format($0, code: model.to) } ?? "—")
                    .font(.largeTitle.monospacedDigit())
                    .contentTransition(.numericText())          // iOS 17: digits roll smoothly
                    .animation(.snappy, value: model.result)
                CurrencyPicker(title: "To", code: $model.to)
            }
            Button("Swap", systemImage: "arrow.up.arrow.down", action: model.swap)
        }
    }
}

Because result is a computed property, every keystroke recomputes it: there's no debounce and no task. CurrencyPicker is a searchable list of codes (section 5).

The decimal-pad keyboard shows the user's locale separator, and the NumberFormatter uses Locale.current by default, so "12,50" in Germany and "12.50" in the US both parse.


4. Rates that update themselves, and work offline


"Exchange rates are updated automatically" has three parts: a cache that's always there, a refresh when the app comes to the foreground, and an occasional refresh in the background.

import Foundation

actor RateStore {
    static let shared = RateStore()
    private let fileURL: URL = FileManager.default
        .urls(for: .cachesDirectory, in: .userDomainMask)[0]
        .appendingPathComponent("rates.json")
    private let maxAge: TimeInterval = 60 * 60          // refresh if older than an hour

    /// Always returns something if we've ever succeeded, even offline.
    func cached() -> RateTable? {
        guard let data = try? Data(contentsOf: fileURL) else { return nil }
        return try? JSONDecoder().decode(RateTable.self, from: data)
    }

    func refreshIfStale(force: Bool = false) async -> RateTable? {
        if !force, let c = cached(), Date().timeIntervalSince(c.fetchedAt) < maxAge { return c }
        do {
            var request = URLRequest(url: URL(string: "https://rates.example.com/latest?base=USD")!)
            request.timeoutInterval = 10
            let (data, response) = try await URLSession.shared.data(for: request)
            guard (response as? HTTPURLResponse)?.statusCode == 200 else { return cached() }
            let decoded = try JSONDecoder().decode(RatesResponse.self, from: data)
            let table = RateTable(base: decoded.base, rates: decoded.rates, fetchedAt: Date())
            try JSONEncoder().encode(table).write(to: fileURL, options: .atomic)
            return table
        } catch {
            return cached()                                   // offline or failed: keep the last good table
        }
    }
}

Key behaviours:
  • Show the cache immediately, then refresh. The UI never waits on the network.
  • On failure, keep the last good table. A slightly old rate is far more useful than an error screen at a market stall.
  • Show the age. "Rates updated 3 hours ago" in small text is honest, and it's the one piece of UI that explains a surprising number.
  • Bundle a seed table in the app, so a first launch in Airplane Mode still converts. Label it with its date.

For background refresh, SwiftUI's backgroundTask(_:action:) scene modifier (iOS 16+) with .appRefresh pairs with BGAppRefreshTaskRequest (iOS 13+):

import SwiftUI
import BackgroundTasks

@main
struct CurrencyApp: App {
    @Environment(\.scenePhase) private var phase

    var body: some Scene {
        WindowGroup { ConverterView() }
            .onChange(of: phase) { _, newPhase in
                if newPhase == .active { Task { _ = await RateStore.shared.refreshIfStale() } }
                if newPhase == .background { scheduleRefresh() }
            }
            .backgroundTask(.appRefresh("rates.refresh")) {
                _ = await RateStore.shared.refreshIfStale(force: true)
                scheduleRefresh()
            }
    }
}

func scheduleRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "rates.refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 60 * 60)
    try? BGTaskScheduler.shared.submit(request)
}

Add rates.refresh to BGTaskSchedulerPermittedIdentifiers in Info.plist and enable Background Modes → Background fetch. Remember that earliestBeginDate is a floor, not a schedule: the system decides when, based on how the user actually uses the app. The foreground refresh is what really keeps rates fresh.

5. 150+ currencies: names, symbols and decimals


Foundation knows a great deal about currencies, so you don't have to maintain that list by hand:

import Foundation

struct CurrencyInfo: Identifiable, Hashable {
    let code: String
    var id: String { code }
    var name: String { Locale.current.localizedString(forCurrencyCode: code) ?? code }
}

enum CurrencyCatalog {
    /// Intersect what the rates API supports with what Foundation can name.
    static func available(in table: RateTable) -> [CurrencyInfo] {
        let common = Set(Locale.commonISOCurrencyCodes)          // iOS 16+
        return (Array(table.rates.keys) + [table.base])
            .filter(common.contains)
            .map(CurrencyInfo.init)
            .sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
    }
}

enum CurrencyText {
    /// Three decimals, as the listing promises, with the locale's grouping and symbol.
    static func format(_ value: Decimal, code: String) -> String {
        value.formatted(.currency(code: code).precision(.fractionLength(3)))
    }
}

// CurrencyText.format(1234.5678, code: "EUR") → "€1,234.568" in en_US, "1.234,568 €" in de_DE

localizedString(forCurrencyCode:) gives "Colombian Peso" in English and "Peso colombiano" in Spanish for free. Decimal.FormatStyle.Currency (iOS 15+) handles the symbol, its position, grouping and the decimal separator for the user's locale.

About three decimal places: most currencies have two minor units, some have zero (JPY, KRW) and a few have three (KWD, BHD). Showing three everywhere is a deliberate product choice. It gives more precision on small amounts and on the exchange rate itself. If you'd rather respect each currency's standard, use .precision(.fractionLength(0...3)) with rounding, or read the currency's default fraction digits from a NumberFormatter set to .currency style.

Make the picker searchable by code and by name (.searchable(text:)), and put recently used currencies at the top. Most people convert between the same two or three currencies over and over.


6. Real-world example: Currency Converter : Fast


This is the approach I'd use, and recommend, for Currency Converter : Fast.

Currency Converter App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Finance and Productivity
  • First released in April 2020
  • 7 ratings in the US store, averaging about 4.7 stars
  • Supports iOS 17.6 and later

How the listing's features map to this article:

What the listing saysWhere it's covered
"Over 150 currencies supported" / "from the US Dollar to the Colombian Peso"One-base rate table plus Foundation's currency names (sections 2, 5)
"Converts as you type (no need for a 'Convert' button!)"Synchronous Decimal conversion as a computed property (section 3)
"Exchange rates are updated automatically – you don't have to do anything!"Cache-first store, foreground refresh, background app refresh (section 4)
"Uses 3 decimal places for greater accuracy!"Decimal maths and .precision(.fractionLength(3)) (sections 1, 5)
"Find the current exchange rate of what you're converting"RateTable.rate(from:to:) (section 2)


Currency Converter App Store screenshot 2

Because the app targets iOS 17.6 and later, everything above, including contentTransition(.numericText()), the two-parameter onChange and the SwiftUI background task, is available without availability checks.


7. Before you ship a currency converter: a checklist


  1. Decimal end to end. No Double between parsing and formatting, and no Decimal(someDouble).
  2. API numbers are parsed with en_US_POSIX, and user input with the user's locale.
  3. Cross rates come from one base. Test that A → B → A returns the original amount, within rounding.
  4. The cache is shown first. Launch in Airplane Mode: you see the last rates and their age, not an error.
  5. A seed table ships in the bundle for first-launch offline use.
  6. Background refresh is configured (permitted identifiers, background mode), and foreground refresh does the real work.
  7. Formatting is tested in several locales: en_US, de_DE, fr_FR, ja_JP and ar, at least.
  8. Zero-decimal and three-decimal currencies look right, according to your product decision.
  9. Rates are labelled honestly. These are indicative mid-market-style rates, not what a bank or card will charge.

Wrapping up


The "fast" in a fast currency converter isn't about network speed. It's about never needing the network at the moment of use. Keep money in Decimal, derive every pair from one cached base table, refresh quietly in the foreground and background, and let Foundation handle names, symbols and locale formatting. The result converts as you type, anywhere, even in Airplane Mode.

Try it, or explore my other apps:

How do you handle money in Swift: Decimal, integer minor units, or a library? I'd love to hear the trade-offs you've found in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/conver…


Drag-to-Connect and Game Center Leaderboards in a SwiftUI Puzzle Game, Updated for iOS 26


Originally published at bestlatestapps.com/blog/drag-t…

A dot-matching board drawn with Canvas and TimelineView, drag gestures that chain same-colour dots, speed-based scoring and rising difficulty, pause and resume, and modern Game Center: authentication, submitting scores, the access point, and what iOS 26 deprecates

Speck Match app icon

Speck Match is a puzzle game about speed. You tap and drag to connect matching coloured dots and clear them from the board, and the faster you match, the higher your score. New dots appear faster as you play, until the board is crowded and you're racing to keep up. Then you see where you rank on Game Center leaderboards against players worldwide.

I wrote about tap-based gameplay in SpriteKit in my Marbles Popperz article. Speck Match lets me cover two different things. The first is a drag-to-connect mechanic built entirely in SwiftUI, with Canvas and TimelineView instead of a game engine. The second is Game Center leaderboards in depth, which have changed more in the last few iOS releases than most developers realise. The latest change: Apple's documentation now marks GKGameCenterViewController as deprecated in iOS 26.

As always, the code is written for this article and checked against Apple's documentation. Gameplay rules beyond what the App Store listing says are labelled as the approach I'd use.


1. The board as plain data


Keep the game state free of SwiftUI, so you can unit-test it:

import Foundation
import CoreGraphics

enum SpeckColor: CaseIterable { case coral, mint, sky, lemon, violet }

struct Speck: Identifiable, Equatable {
    let id = UUID()
    var color: SpeckColor
    var position: CGPoint
    let bornAt: TimeInterval            // game time when it appeared, used for speed scoring
    static let radius: CGFloat = 18
}

struct Board {
    var specks: [Speck] = []
    var score = 0
    var elapsed: TimeInterval = 0       // game time only; doesn't advance while paused
    var nextSpawn: TimeInterval = 0
    var difficulty: Difficulty

    /// Seconds between new specks: starts relaxed, tightens as you play.
    var spawnInterval: TimeInterval {
        max(difficulty.minInterval, difficulty.startInterval - elapsed * difficulty.ramp)
    }

    var isOverfull: Bool { specks.count >= difficulty.maxSpecks }
}

struct Difficulty {
    let startInterval: TimeInterval, minInterval: TimeInterval, ramp: Double
    let maxSpecks: Int, colors: Int
    static let easy   = Difficulty(startInterval: 1.4, minInterval: 0.55, ramp: 0.004, maxSpecks: 40, colors: 3)
    static let normal = Difficulty(startInterval: 1.1, minInterval: 0.40, ramp: 0.006, maxSpecks: 36, colors: 4)
    static let hard   = Difficulty(startInterval: 0.9, minInterval: 0.28, ramp: 0.008, maxSpecks: 32, colors: 5)
}

"The game gets faster and more challenging as you progress" becomes one computed property, spawnInterval, and "Multiple difficulty levels" becomes three rows of numbers. The values are illustrative; tune them by playing.

2. Drawing and ticking with Canvas and TimelineView


For a few dozen circles, SwiftUI's Canvas (iOS 15+) is fast and simple. It's immediate-mode drawing, so you're not creating one view per dot. TimelineView(.animation(minimumInterval:paused:)) (iOS 15+) gives you a frame clock that can be paused, which matters later:

import SwiftUI

struct BoardView: View {
    @State private var game = GameModel(difficulty: .normal)
    @Environment(\.scenePhase) private var scenePhase

    var body: some View {
        TimelineView(.animation(minimumInterval: 1.0 / 60, paused: game.isPaused)) { timeline in
            Canvas { context, size in
                for speck in game.board.specks {
                    let rect = CGRect(x: speck.position.x - Speck.radius, y: speck.position.y - Speck.radius,
                                      width: Speck.radius * 2, height: Speck.radius * 2)
                    context.fill(Path(ellipseIn: rect), with: .color(speck.color.swiftUIColor))
                }
                if game.chain.count > 1 {                       // the line you're drawing
                    var path = Path()
                    path.addLines(game.chain.compactMap { id in game.board.specks.first { $0.id == id }?.position })
                    context.stroke(path, with: .color(.white.opacity(0.8)), lineWidth: 6)
                }
            }
            .onChange(of: timeline.date) { _, now in game.tick(now: now) }
        }
        .gesture(dragGesture)
        .onChange(of: scenePhase) { _, phase in if phase != .active { game.pause() } }
    }
}

This code uses iOS 17 conveniences (@Observable and the two-parameter onChange). For the app's iOS 15.6 minimum, the same model works as an ObservableObject with the single-parameter onChange(of:perform:). Two more notes. Keep tick cheap: spawn when due, check the overfull condition, and nothing else. And auto-pause when the scene leaves .active. A phone call shouldn't cost a player their best run, and the listing promises "Pause and resume anytime".

3. Drag to connect


The listing's core loop is "Tap and drag dots to match them by color. Connect matching colored dots to clear them from the board." The approach I'd use: touching down on a dot starts a chain. Dragging over another dot of the same colour, close enough to the last one in the chain, adds it. Lifting your finger clears the chain if it has at least two dots.

@Observable
final class GameModel {
    var board: Board
    var chain: [UUID] = []
    var isPaused = false
    private var lastTick: Date?

    init(difficulty: Difficulty) { board = Board(difficulty: difficulty) }

    func speck(at point: CGPoint) -> Speck? {
        board.specks.first { hypot($0.position.x - point.x, $0.position.y - point.y) <= Speck.radius * 1.4 }
    }

    func dragChanged(to point: CGPoint) {
        guard !isPaused, let hit = speck(at: point) else { return }
        if chain.isEmpty { chain = [hit.id]; return }
        guard let first = board.specks.first(where: { $0.id == chain[0] }),
              hit.color == first.color, !chain.contains(hit.id) else {
            // Dragging back onto the previous dot undoes the last link, like most connect games.
            if chain.count >= 2, hit.id == chain[chain.count - 2] { chain.removeLast() }
            return
        }
        chain.append(hit.id)
    }

    func pause() { isPaused = true; lastTick = nil }
    func resume() { isPaused = false }

    func tick(now: Date) {
        defer { lastTick = now }
        guard !isPaused, let last = lastTick else { return }
        board.elapsed += min(now.timeIntervalSince(last), 0.05)     // clamp hitches and resumes
        if board.elapsed >= board.nextSpawn {
            spawn()
            board.nextSpawn = board.elapsed + board.spawnInterval
        }
        if board.isOverfull { pause() /* game over: submit score, show results */ }
    }

    private func spawn() {
        let palette = Array(SpeckColor.allCases.prefix(board.difficulty.colors))
        board.specks.append(Speck(color: palette.randomElement()!,
                                  position: CGPoint(x: .random(in: 30...360), y: .random(in: 120...700)),
                                  bornAt: board.elapsed))
    }

    func dragEnded() {
        defer { chain = [] }
        guard chain.count >= 2 else { return }
        let cleared = board.specks.filter { chain.contains($0.id) }
        board.specks.removeAll { chain.contains($0.id) }
        board.score += Scoring.points(for: cleared, now: board.elapsed)
    }
}

extension BoardView {
    var dragGesture: some Gesture {
        DragGesture(minimumDistance: 0)                 // 0 so a touch-down starts the chain immediately
            .onChanged { game.dragChanged(to: $0.location) }
            .onEnded { _ in game.dragEnded() }
    }
}

DragGesture(minimumDistance: 0) is the important detail. The default minimum distance means the first few points of a drag are swallowed, which feels laggy in a speed game. Make the hit radius a bit larger than the drawn dot (1.4× here). Fingers are bigger than dots, and fair hit areas are most of what "Smooth, responsive controls" means in practice.

4. Scoring speed


"Speed matters – faster matches = higher scores." There are many ways to express that. The approach I'd use rewards how fresh the dots were when you cleared them, plus a bonus for longer chains:

enum Scoring {
    /// Each dot: 10 points, plus up to 10 more if cleared within 3 seconds of appearing.
    /// Chains: +25% per dot beyond two.
    static func points(for specks: [Speck], now: TimeInterval) -> Int {
        let perDot = specks.map { speck -> Double in
            let age = now - speck.bornAt
            let freshness = max(0, 1 - age / 3)           // 1.0 when brand new, 0 after 3 s
            return 10 + 10 * freshness
        }
        let chainBonus = 1 + 0.25 * Double(max(0, specks.count - 2))
        return Int((perDot.reduce(0, +) * chainBonus).rounded())
    }
}

The bornAt timestamp uses game time (board.elapsed), not wall-clock time. Otherwise pausing would make every dot on screen "old", and the player would be punished for taking a break.

5. Signing in to Game Center


Leaderboards start with authentication. On iOS, you set GKLocalPlayer.local.authenticateHandler once, early. GameKit calls it, possibly more than once, with either a view controller to present or the result:

import GameKit
import UIKit

@MainActor
final class GameCenter: ObservableObject {
    @Published private(set) var isAuthenticated = false

    func authenticate() {
        GKLocalPlayer.local.authenticateHandler = { [weak self] viewController, error in
            if let viewController {
                // GameKit needs to show its sign-in UI.
                UIApplication.shared.connectedScenes
                    .compactMap { ($0 as? UIWindowScene)?.keyWindow?.rootViewController }
                    .first?.present(viewController, animated: true)
                return
            }
            self?.isAuthenticated = GKLocalPlayer.local.isAuthenticated
            if self?.isAuthenticated == true {
                GKAccessPoint.shared.location = .topLeading
                GKAccessPoint.shared.isActive = false        // show it on menus, not during play
            }
            if let error { print("Game Center unavailable: \(error.localizedDescription)") }
        }
    }
}

Rules I follow:
  • Never block the game on Game Center. If the player declines or is offline, the game plays normally and leaderboards just aren't offered.
  • Present the sign-in view controller from a real, visible controller. Presenting from a controller that isn't in the window is a classic "Game Center never shows" bug.
  • Show the access point only on menus. GKAccessPoint (iOS 14+) is a small Game Center badge that the system draws. Set isActive = false during play so it doesn't overlap the board.

6. Submitting scores the modern way


The old GKScore class is deprecated since iOS 14. The current API is a single class method on GKLeaderboard:

extension GameCenter {
    static let leaderboardID = "speck.highscores.normal"   // as configured in App Store Connect

    func submit(score: Int, difficulty: String) {
        guard GKLocalPlayer.local.isAuthenticated else { return }
        GKLeaderboard.submitScore(score, context: 0, player: GKLocalPlayer.local,
                                  leaderboardIDs: ["speck.highscores.\(difficulty)"]) { error in
            if let error { print("Score submit failed: \(error.localizedDescription)") }
        }
    }
}

Design choices:
  • One leaderboard per difficulty. An easy-mode score and a hard-mode score aren't comparable, and mixing them makes the board meaningless. Each needs its own ID in App Store Connect.
  • Submit at game over, not per match. One call per run is plenty. Game Center keeps each player's best score according to the leaderboard's settings.
  • Configure the sort order and format in App Store Connect (high to low, integer). The client just sends numbers.

7. Showing leaderboards in iOS 18 and 26


This is where things have moved. For years the answer was "present a GKGameCenterViewController". Apple's documentation now marks GKGameCenterViewController deprecated in iOS 26. Meanwhile, GKAccessPoint gained trigger(leaderboardID:playerScope:timeScope:handler:) in iOS 18, which opens the system Game Center UI straight to one leaderboard.

Since Speck Match supports iOS 15.6 and later, the approach I'd use branches:

import GameKit
import SwiftUI

extension GameCenter {
    func showLeaderboard(id: String, from presenter: UIViewController) {
        if #available(iOS 18, *) {
            GKAccessPoint.shared.trigger(leaderboardID: id, playerScope: .global, timeScope: .allTime) { }
        } else {
            // iOS 14–17: the dashboard view controller (deprecated in iOS 26, fine on older systems).
            let vc = GKGameCenterViewController(leaderboardID: id, playerScope: .global, timeScope: .allTime)
            vc.gameCenterDelegate = presenter as? GKGameCenterControllerDelegate
            presenter.present(vc, animated: true)
        }
    }

    /// In-game "Top 10" without leaving the app (iOS 14+).
    func topTen(id: String) async throws -> [(rank: Int, name: String, score: String)] {
        let boards = try await GKLeaderboard.loadLeaderboards(IDs: [id])
        guard let board = boards.first else { return [] }
        let (_, entries, _) = try await board.loadEntries(for: .global, timeScope: .allTime,
                                                         range: NSRange(location: 1, length: 10))
        return entries.map { ($0.rank, $0.player.displayName, $0.formattedScore) }
    }
}

loadEntries(for:timeScope:range:) returns the local player's entry, the requested range and the total count. Use formattedScore rather than formatting the raw value yourself, so the display matches what Game Center shows everywhere else.

The app's most recent release notes include "Fixed Game Center leaderboard display issue". That's a reminder that the leaderboard display path is the fragile part. Test it on every iOS version you support, and especially on both sides of the iOS 18 branch.


8. Real-world example: Speck Match


This walkthrough is grounded in building Speck Match. The code is the approach I'd use, written for this article, not the shipped source.

Speck Match App Store screenshot

At the time of writing, its App Store listing shows:

  • Free to play ("with optional ads that can be removed"), in Games (Board, Strategy)
  • First released in December 2021
  • 1 rating in the US store, 5 stars
  • Supports iOS 15.6 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Tap and drag to connect matching dots"DragGesture chains (section 3)
"Game gets faster and more challenging as you progress" / "Multiple difficulty levels"spawnInterval and difficulty presets (section 1)
"Speed matters - faster matches = higher scores"Freshness-based scoring (section 4)
"Pause and resume anytime"A pausable TimelineView and game time (sections 2 and 4)
"Game Center integration" / "Global leaderboards"Authentication, submitting and display (sections 5–7)
Release notes: "Fixed Game Center leaderboard display issue"Version-branching the display path (section 7)

9. Before you ship a Game Center game: a checklist


  1. The game plays fully without Game Center. Declined sign-in and offline are both tested.
  2. authenticateHandler is set once, early, and presents from a visible view controller.
  3. There's no GKScore. Use GKLeaderboard.submitScore, once per run.
  4. There's one leaderboard per difficulty, with IDs matching App Store Connect exactly.
  5. The display path is branched. GKAccessPoint.trigger(leaderboardID:…) on iOS 18+, the view controller on older systems, and both tested.
  6. The access point is hidden during play.
  7. Scores use game time. Pausing never changes the outcome.
  8. Hit areas are generous, and minimumDistance: 0 is set for instant drags.
  9. Colour isn't the only cue. Consider shapes for colour-blind players.

Wrapping up


A drag-to-connect game shows how far plain SwiftUI goes. Canvas draws the board, TimelineView ticks it, DragGesture drives it, and nothing needs a game engine. Game Center is the part that keeps evolving. Use the iOS 14 GKLeaderboard APIs, adopt the iOS 18 access-point trigger, and plan for the iOS 26 deprecation of the old dashboard controller.

Play the game, or explore my other work:

Think you can top the leaderboard? Let me know your score in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/drag-t…


From Barcode to PDF: Building a Private, Offline Medication Tracker with VisionKit, WidgetKit and PDFKit


Originally published at bestlatestapps.com/blog/from-b…

Scanning a box to add a medication, a SwiftData model shared with widgets, adherence statistics, a doctor-ready PDF report, and CSV/JSON export that keeps everything on the device

Track Your Pills app icon

I have two medication apps on the App Store. The first, Daily Pills | Reminder, does one thing: remind you on time and count your supply. I've written about its notification engineering separately. The second, Track Your Pills, came out of what people asked for next: "Can I just scan the box?" "Can I show my doctor what I've actually taken?" "Can I see today's doses without opening the app?"

Track Your Pills launched in February 2023. Its listing covers medications added "with photos, barcodes, or manual entry", tablets, capsules, syrups and injections, adherence calendars and statistics, PDF reports to "share with healthcare providers", home-screen widgets, CSV and JSON export with backup and restore, and a firm privacy line: "All data stored locally on your device… No account creation needed."

This article covers the parts that are different from a plain reminder app. Those are barcode capture with VisionKit, a SwiftData model the app and its widgets can share, adherence maths, a PDF report built with UIGraphicsPDFRenderer, and export and import. I'm leaving out the notification side on purpose, because the Daily Pills article covers it. API names and availability are checked against Apple's documentation, and I describe the approach I'd use rather than the app's exact source.


1. The model, shared by the app and its widgets


The app targets iOS 17.6 and later, so SwiftData is available. The model separates what the medication is from what happened:

import SwiftData
import Foundation

@Model
final class Medication {
    var name: String
    var form: String                 // "tablet", "capsule", "syrup", "injection"
    var strength: String?            // "500 mg", "5 ml"
    var barcode: String?
    @Attribute(.externalStorage) var photo: Data?
    var quantityRemaining: Double    // Double: syrups are measured in ml
    var dosePerIntake: Double
    var scheduleTimes: [DateComponents]
    var startDate: Date
    var endDate: Date?
    @Relationship(deleteRule: .cascade, inverse: \Intake.medication) var intakes: [Intake] = []

    init(name: String, form: String, quantityRemaining: Double, dosePerIntake: Double,
         scheduleTimes: [DateComponents], startDate: Date = .now) {
        self.name = name; self.form = form
        self.quantityRemaining = quantityRemaining; self.dosePerIntake = dosePerIntake
        self.scheduleTimes = scheduleTimes; self.startDate = startDate
    }
}

@Model
final class Intake {
    enum Status: String, Codable { case taken, missed, skipped }
    var scheduledAt: Date
    var loggedAt: Date?
    var statusRaw: String
    var medication: Medication?

    var status: Status { Status(rawValue: statusRaw) ?? .missed }

    init(scheduledAt: Date, status: Status, loggedAt: Date? = .now) {
        self.scheduledAt = scheduledAt; self.statusRaw = status.rawValue; self.loggedAt = loggedAt
    }
}

@Attribute(.externalStorage) keeps photos out of the main database file. Each intake records both the scheduled time and the logged time, which lets the report distinguish "taken on time" from "taken two hours late".

Widgets run in a separate process, so the store has to live somewhere both processes can reach. That's an App Group container:

enum SharedStore {
    static let appGroup = "group.com.example.trackyourpills"

    static func makeContainer() throws -> ModelContainer {
        let config = ModelConfiguration(
            "Medications",
            groupContainer: .identifier(appGroup),   // same file for app and widget extension
            cloudKitDatabase: .none                  // "No cloud sync required"
        )
        return try ModelContainer(for: Medication.self, Intake.self, configurations: config)
    }
}

Add the App Groups capability to both the app and widget targets with the same identifier. cloudKitDatabase: .none is the explicit, privacy-preserving choice. SwiftData otherwise looks for a CloudKit container if the iCloud capability is present.

2. Adding a medication by scanning its barcode


DataScannerViewController (VisionKit, iOS 16+) gives you a live camera view that recognises text and machine-readable codes, with highlighting and guidance built in. You don't need to build a capture session.

import SwiftUI
import VisionKit

struct BarcodeScannerView: UIViewControllerRepresentable {
    var onCode: (String) -> Void

    static var canScan: Bool {
        DataScannerViewController.isSupported && DataScannerViewController.isAvailable
    }

    func makeUIViewController(context: Context) -> DataScannerViewController {
        let scanner = DataScannerViewController(
            recognizedDataTypes: [.barcode(symbologies: [.ean13, .ean8, .upce, .code128, .dataMatrix])],
            qualityLevel: .balanced,
            recognizesMultipleItems: false,
            isHighFrameRateTrackingEnabled: false,
            isHighlightingEnabled: true)
        scanner.delegate = context.coordinator
        try? scanner.startScanning()
        return scanner
    }

    func updateUIViewController(_ controller: DataScannerViewController, context: Context) {}

    func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) }

    final class Coordinator: NSObject, DataScannerViewControllerDelegate {
        let onCode: (String) -> Void
        private var delivered = false
        init(onCode: @escaping (String) -> Void) { self.onCode = onCode }

        func dataScanner(_ scanner: DataScannerViewController,
                         didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
            guard !delivered else { return }
            for item in addedItems {
                if case .barcode(let code) = item, let value = code.payloadStringValue {
                    delivered = true
                    scanner.stopScanning()
                    onCode(value)
                    return
                }
            }
        }
    }
}

Practical notes:
  • Check both flags. Apple's docs say isSupported requires a device with the A12 Bionic chip or later. isAvailable additionally reflects camera permission and restrictions. When either is false, fall back to manual entry; never show a dead button.
  • Add NSCameraUsageDescription, for example: "Scan the barcode on your medication box to fill in its details."
  • Pick the symbologies you need. Retail packaging is mostly EAN/UPC, and many pharmaceutical packs carry a GS1 DataMatrix. A narrower list means faster, more reliable recognition.


From code to name: an on-device lookup


The listing mentions "Auto-fill from extensive medication name database" and "Barcode scanning for instant medication lookup", and also says "No internet connection required". The approach that satisfies both is a bundled, read-only lookup table: barcode → name, form and strength. Then fall back to the user's own history: if they've scanned this code before, reuse what they typed.

struct MedicationCatalog {
    struct Entry: Decodable { let name: String; let form: String; let strength: String? }
    private let byBarcode: [String: Entry]

    init() {
        let url = Bundle.main.url(forResource: "catalog", withExtension: "json")!
        byBarcode = (try? JSONDecoder().decode([String: Entry].self, from: Data(contentsOf: url))) ?? [:]
    }

    func lookup(_ code: String) -> Entry? { byBarcode[code] }
}

Always show the result as an editable form, never a silent auto-save. A wrong strength is a dangerous error.

3. Adherence: the numbers behind the calendar


"Visual adherence calendar", "weekly and overall adherence statistics" and "per-medication adherence metrics" all come from one calculation: of the doses that were due, how many were taken?

import Foundation

struct AdherenceStats {
    let due: Int
    let taken: Int
    let late: Int        // taken, but more than the grace window after schedule
    var rate: Double { due == 0 ? 1 : Double(taken) / Double(due) }
}

enum Adherence {
    static let lateThreshold: TimeInterval = 60 * 60     // 1 hour

    static func stats(for intakes: [Intake], in interval: DateInterval) -> AdherenceStats {
        let due = intakes.filter { interval.contains($0.scheduledAt) && $0.status != .skipped }
        let taken = due.filter { $0.status == .taken }
        let late = taken.filter { ($0.loggedAt ?? $0.scheduledAt)
            .timeIntervalSince($0.scheduledAt) > lateThreshold }
        return AdherenceStats(due: due.count, taken: taken.count, late: late.count)
    }

    /// One value per day, for the calendar heat map.
    static func daily(for intakes: [Intake], days: Int, calendar: Calendar = .current)
        -> [(day: Date, rate: Double?)] {
        let today = calendar.startOfDay(for: .now)
        return (0..<days).reversed().compactMap { offset in
            guard let day = calendar.date(byAdding: .day, value: -offset, to: today),
                  let next = calendar.date(byAdding: .day, value: 1, to: day) else { return nil }
            let s = stats(for: intakes, in: DateInterval(start: day, end: next))
            return (day, s.due == 0 ? nil : s.rate)          // nil = nothing scheduled: grey cell
        }
    }
}

Two definitions you have to choose deliberately, and should explain on screen:
  • Skipped is not missed. If a doctor said "skip today", that dose shouldn't hurt the adherence score. Exclude skipped doses from due.
  • No dose scheduled ≠ 100%. A day with nothing due should render as empty, not as a perfect green square.

Swift Charts' RectangleMark makes the calendar heat map straightforward, with foregroundStyle(by:) binned into three or four colour bands.


4. A doctor-ready PDF with UIGraphicsPDFRenderer


"Generate detailed medication reports… Share reports with healthcare providers… Printable format for medical records." UIGraphicsPDFRenderer (iOS 10+) turns drawing code into PDF data. A report is just text and table rows drawn page by page:

import UIKit

struct ReportRow { let name: String; let schedule: String; let adherence: String; let remaining: String }

enum PDFReport {
    static func make(title: String, period: String, rows: [ReportRow]) -> Data {
        let page = CGRect(x: 0, y: 0, width: 595, height: 842)          // A4 in points
        let margin: CGFloat = 40
        let format = UIGraphicsPDFRendererFormat()
        format.documentInfo = [kCGPDFContextTitle as String: title,
                               kCGPDFContextCreator as String: "Track Your Pills"]
        let renderer = UIGraphicsPDFRenderer(bounds: page, format: format)

        let titleAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 20)]
        let headAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.boldSystemFont(ofSize: 11)]
        let bodyAttrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 11)]
        let columns: [CGFloat] = [margin, 230, 360, 460]

        return renderer.pdfData { ctx in
            var y: CGFloat = 0

            func newPage() {
                ctx.beginPage()
                y = margin
                (title as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: titleAttrs)
                y += 30
                (period as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: bodyAttrs)
                y += 28
                for (i, h) in ["Medication", "Schedule", "Adherence", "Remaining"].enumerated() {
                    (h as NSString).draw(at: CGPoint(x: columns[i], y: y), withAttributes: headAttrs)
                }
                y += 20
            }

            newPage()
            for row in rows {
                if y > page.height - margin - 40 { newPage() }       // paginate
                for (i, text) in [row.name, row.schedule, row.adherence, row.remaining].enumerated() {
                    (text as NSString).draw(in: CGRect(x: columns[i], y: y,
                                                       width: (i < 3 ? columns[i + 1] : page.width - margin) - columns[i] - 8,
                                                       height: 32), withAttributes: bodyAttrs)
                }
                y += 34
            }

            let footer = "Generated on this device. Not medical advice; review with your clinician."
            (footer as NSString).draw(at: CGPoint(x: margin, y: page.height - margin),
                                      withAttributes: [.font: UIFont.italicSystemFont(ofSize: 9)])
        }
    }
}

To preview it, wrap PDFView (PDFKit, iOS 11+) in a UIViewRepresentable, set document = PDFDocument(data: data) and autoScales = true. To share or print, write the data to a temporary file named something human, like "Medication report – Oct 2026.pdf", and hand the URL to ShareLink. The system share sheet includes Print, Mail and Files, so you don't need any extra UI for "printable".

5. Widgets that read the shared store


"Home screen widgets for quick medication view… Upcoming medication reminders… Adherence at a glance." The widget's timeline provider opens the same App Group container and computes the next doses:

import WidgetKit
import SwiftData
import SwiftUI

struct NextDoseEntry: TimelineEntry {
    let date: Date
    let upcoming: [(name: String, at: Date)]
    let todayRate: Double?
}

struct NextDoseProvider: TimelineProvider {
    func placeholder(in context: Context) -> NextDoseEntry {
        NextDoseEntry(date: .now, upcoming: [("Vitamin D", .now)], todayRate: 1)
    }

    func getSnapshot(in context: Context, completion: @escaping (NextDoseEntry) -> Void) {
        completion(makeEntry(at: .now))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline<NextDoseEntry>) -> Void) {
        let entry = makeEntry(at: .now)
        // Refresh just after the next dose time, or in an hour if nothing is due.
        let next = entry.upcoming.first?.at.addingTimeInterval(60) ?? .now.addingTimeInterval(3600)
        completion(Timeline(entries: [entry], policy: .after(next)))
    }

    private func makeEntry(at now: Date) -> NextDoseEntry {
        guard let container = try? SharedStore.makeContainer() else {
            return NextDoseEntry(date: now, upcoming: [], todayRate: nil)
        }
        let context = ModelContext(container)
        let meds = (try? context.fetch(FetchDescriptor<Medication>())) ?? []
        let cal = Calendar.current
        let upcoming = meds.flatMap { med in
            med.scheduleTimes.compactMap { comps in
                cal.nextDate(after: now, matching: comps, matchingPolicy: .nextTime)
                    .map { (name: med.name, at: $0) }
            }
        }
        .sorted { $0.at < $1.at }
        .prefix(3)
        let today = DateInterval(start: cal.startOfDay(for: now), duration: 86_400)
        let stats = Adherence.stats(for: meds.flatMap(\.intakes), in: today)
        return NextDoseEntry(date: now, upcoming: Array(upcoming),
                             todayRate: stats.due == 0 ? nil : stats.rate)
    }
}

When the app logs a dose, call WidgetCenter.shared.reloadTimelines(ofKind:), so the widget updates now rather than at its next scheduled refresh.

Keep widget work small. Fetch, compute three items and return. Widgets have tight time and memory budgets.


6. Export, backup and restore without a server


"Export data in CSV or JSON formats… Complete backup and restore… Privacy-focused: all data stored locally." With no account and no cloud, export is the backup strategy, so treat it as a first-class feature.

import Foundation
import CoreTransferable
import UniformTypeIdentifiers

struct BackupFile: Codable {
    struct Med: Codable {
        let name, form: String; let strength, barcode: String?
        let quantityRemaining, dosePerIntake: Double
        let scheduleTimes: [DateComponents]; let startDate: Date; let endDate: Date?
        let intakes: [IntakeDTO]
    }
    struct IntakeDTO: Codable { let scheduledAt: Date; let loggedAt: Date?; let status: String }

    var version = 1                 // bump when the format changes; migrate on import
    let exportedAt: Date
    let medications: [Med]
}

extension BackupFile: Transferable {
    static var transferRepresentation: some TransferRepresentation {
        DataRepresentation(exportedContentType: .json) { backup in
            let encoder = JSONEncoder()
            encoder.dateEncodingStrategy = .iso8601
            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
            return try encoder.encode(backup)
        }
        .suggestedFileName("TrackYourPills-backup.json")
    }
}

enum CSVExport {
    static func intakes(_ meds: [Medication]) -> String {
        func esc(_ s: String) -> String { "\"\(s.replacingOccurrences(of: "\"", with: "\"\""))\"" }
        let iso = ISO8601DateFormatter()
        var lines = ["medication,scheduled_at,logged_at,status"]
        for med in meds {
            for i in med.intakes.sorted(by: { $0.scheduledAt < $1.scheduledAt }) {
                lines.append([esc(med.name), iso.string(from: i.scheduledAt),
                              i.loggedAt.map(iso.string(from:)) ?? "", i.statusRaw].joined(separator: ","))
            }
        }
        return lines.joined(separator: "\n")
    }
}

ShareLink(item: backup, preview: SharePreview("Backup")) (iOS 16+) then lets the user save to Files, AirDrop it to another device or email it to themselves, all under their control. For restore, .fileImporter(isPresented:allowedContentTypes:onCompletion:) with [.json] picks the file. Decode it, check version, and import inside a single ModelContext transaction, so a bad file can't leave half a database.

A design rule I'd hold to: JSON is the backup, CSV is the report. CSV is for spreadsheets and doctors and loses structure; only JSON round-trips. Label them that way in the UI.

If you use fileExporter instead of ShareLink, note that Apple's current docs mark the original fileExporter(isPresented:document:contentType:defaultFilename:onCompletion:) as deprecated in iOS 27.2, in favour of the overload that adds onCancellation:.


7. Interaction warnings: handle with great care


The listing includes a "Drug Interaction Checker" with "Safety alerts before adding new medications" and "Medical disclaimers and recommendations". The technical side is simple: a bundled table of known interacting pairs, checked when a medication is added. The responsibility side is not:

  • Offline data ages. Show the data's version date in the warning.
  • Absence of a warning isn't safety. Word it as "No interactions found in this app's list", never "safe to combine".
  • Never block, always refer. Warn, show the source and recommend checking with a pharmacist or doctor.

I won't publish a table here; the content is the hard part, and it must come from a reliable, licensed source.


8. Real-world example: Track Your Pills


All of this describes the approach I'd use, and recommend, for Track Your Pills.

Track Your Pills App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Medical and Health & Fitness
  • First released in February 2023
  • 7 ratings in the US store, averaging about 4.7 stars
  • Supports iOS 17.6 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Add medications with photos, barcodes, or manual entry" / "Barcode scanning for instant medication lookup"DataScannerViewController plus a bundled lookup, with manual fallback (section 2)
"Support for tablets, capsules, syrups, and injections"form and a Double quantity in the model (section 1)
"Visual adherence calendar… Weekly and overall adherence statistics"Due vs taken, skipped excluded (section 3)
"Generate detailed medication reports… Share reports with healthcare providers"UIGraphicsPDFRenderer, PDFView preview, ShareLink (section 4)
"Home screen widgets… Upcoming medication reminders… Adherence at a glance"App Group SwiftData store read by a TimelineProvider (sections 1, 5)
"Export data in CSV or JSON formats… Complete backup and restore"Transferable JSON backup, CSV report, versioned import (section 6)
"All data stored locally… No cloud sync required… No account creation needed"cloudKitDatabase: .none, no server, user-controlled export (sections 1, 6)


Track Your Pills App Store screenshot 2

The reminder side of the listing (pre-alarms, quiet hours, escalating reminders) is local-notification work. The patterns in my Daily Pills article, deterministic identifiers and the 64-pending-notification budget, apply directly, and escalation makes that budget matter even more.


9. Before you ship a medication tracker: a checklist


  1. Scanning degrades gracefully. Unsupported devices and denied camera access both lead to manual entry.
  2. Scanned data is always reviewed by the user before it's saved.
  3. The store lives in an App Group, and widgets read it without crashing on first launch, before any data exists.
  4. Adherence definitions are explicit: skipped vs missed, late threshold, and days with nothing due.
  5. The PDF paginates, has a title, a date range and a disclaimer, and opens cleanly in Files and Mail.
  6. The JSON backup round-trips. Export, delete the app, reinstall, import: every record comes back.
  7. Backups are versioned, and import is all-or-nothing.
  8. Privacy claims are literally true: no CloudKit, no analytics surprises, and accurate App Privacy details.
  9. Interaction warnings are worded carefully and dated, and always refer the user to a professional.

Wrapping up


A medication tracker becomes trustworthy through small, careful pieces: input that's fast but always confirmed, a clear model of what happened, statistics with honest definitions, and reports and exports that keep the user in control of their own data. VisionKit, SwiftData, WidgetKit and a PDF renderer cover nearly all of it, without a single server.

Try it, or explore my other apps:

If you've built health features with no backend, how did you handle backups? Tell me in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/from-b…


Medication Reminders That Actually Fire: Local Notifications, the 64-Notification Limit and Supply Math


Originally published at bestlatestapps.com/blog/medica…

Repeating calendar triggers, a notification budget, "Taken" and "Snooze" actions, run-out dates and on-device privacy, from building a pill reminder app

Daily Pills | Reminder app icon

Most apps can afford a missed notification. A medication reminder can't. If the 9 p.m. reminder silently doesn't arrive, someone misses a dose, and they stop trusting the app the same evening.

Daily Pills | Reminder is my take on the simplest version of this problem, live since May 2020: set the times you take your pills, get reminded, see how many you have left and know when you'll run out. The listing sums it up as "Never miss your medication again!" and promises that "your medication data stays private on your device."

That combination, reliable reminders with no server, rests entirely on local notifications, and they have more sharp edges than most developers expect: a hard cap on pending notifications, time-zone behaviour, actions that run while the phone is locked, and data that must be readable at that moment. This article covers how I'd build the core: the data model, repeating triggers, a notification budget, actionable notifications, supply maths and on-device storage. API names and availability are checked against Apple's documentation, and this is the approach I'd use rather than the app's exact source.


1. The model: keep it boring

import Foundation

struct ReminderTime: Codable, Hashable, Identifiable {
    var hour: Int          // 0...23, local wall-clock time
    var minute: Int
    var id: String { String(format: "%02d%02d", hour, minute) }
}

struct Medication: Codable, Identifiable {
    var id = UUID()
    var name: String
    var pillsPerDose: Int = 1
    var times: [ReminderTime]                 // multiple reminders per day
    var pillsRemaining: Int
    var lowSupplyWarningDays: Int = 5
    var log: [DoseEvent] = []
}

struct DoseEvent: Codable, Hashable {
    enum Kind: String, Codable { case taken, missed, skipped }
    var date: Date
    var kind: Kind
}

Two choices to notice:
  • Reminder times are an hour and a minute, not a Date. "8:00 every morning" is a wall-clock intention. Storing it as a Date bakes in a time zone, and the reminder drifts when the user travels or when daylight saving changes.
  • The log is append-only. Missed and taken doses are facts. Supply and adherence are computed from them, never stored separately where they could disagree.

2. Repeating reminders with UNCalendarNotificationTrigger


For "every day at 08:00", a single repeating calendar trigger is exactly right:

import UserNotifications

enum ReminderScheduler {
    static let doseCategory = "DOSE_REMINDER"

    static func identifier(for med: Medication, time: ReminderTime) -> String {
        "dose.\(med.id.uuidString).\(time.id)"
    }

    static func schedule(_ med: Medication) async throws {
        let center = UNUserNotificationCenter.current()
        // Start clean for this medication so edits never leave stale reminders behind.
        await cancel(med)

        for time in med.times {
            let content = UNMutableNotificationContent()
            content.title = "Time for \(med.name)"
            content.body = med.pillsPerDose == 1 ? "Take 1 pill." : "Take \(med.pillsPerDose) pills."
            content.sound = .default
            content.categoryIdentifier = doseCategory
            content.userInfo = ["medID": med.id.uuidString, "time": time.id]
            if #available(iOS 15.0, *) {
                content.interruptionLevel = .timeSensitive   // needs the Time Sensitive capability
            }

            // Only hour and minute: fires every day at that local wall-clock time.
            var components = DateComponents()
            components.hour = time.hour
            components.minute = time.minute
            let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: true)

            let request = UNNotificationRequest(identifier: identifier(for: med, time: time),
                                                content: content, trigger: trigger)
            try await center.add(request)
        }
    }

    static func cancel(_ med: Medication) async {
        let center = UNUserNotificationCenter.current()
        let ids = await center.pendingNotificationRequests()
            .map(\.identifier)
            .filter { $0.hasPrefix("dose.\(med.id.uuidString).") }
        center.removePendingNotificationRequests(withIdentifiers: ids)
    }
}

What makes this reliable:
  • Deterministic identifiers. dose.<medID>.<HHmm> means rescheduling replaces rather than duplicates, and cancelling one medication can't touch another's reminders.
  • Date components without a time zone fire at local wall-clock time. If the user flies from Delhi to London, "08:00" stays 08:00 local, which is usually what a daily pill means. (For medications that must be taken at strict intervals regardless of time zone, that's a different design: talk to the user, and consider fixed-interval triggers.)
  • .timeSensitive (iOS 15+) lets the reminder break through Focus modes, if the user allows it. It requires adding the Time Sensitive Notifications capability. Use it only for genuinely time-sensitive content, which a dose reminder is.

And of course, ask permission first, at the moment the user adds their first reminder, with requestAuthorization(options: [.alert, .sound, .badge]).


3. The 64-notification limit, and a budget


Here's the trap. iOS keeps only a limited number of pending local notifications per app. Apple's documentation for the older UILocalNotification API states it plainly: the system keeps the soonest-firing 64 notifications and discards the rest. Automatically repeating notifications count as a single notification. The same cap is widely observed with UserNotifications, and it fails silently: add(_:) succeeds and the 65th reminder just never fires.

With repeating triggers, one per medication per time, you're usually fine: 5 medications × 3 times a day is 15 requests. The trouble starts when you schedule one-off notifications: a different message per day ("Day 12 of 30"), weekly schedules built from individual dates, snoozes or low-supply warnings. Each of those is its own slot.

So treat the pending queue as a budget, and check it:

enum NotificationBudget {
    static let systemLimit = 64
    static let safetyMargin = 4          // room for snoozes and low-supply warnings

    /// How many requests we'd have after scheduling `meds`.
    static func projected(for meds: [Medication], extraOneOffs: Int = 0) -> Int {
        meds.reduce(0) { $0 + $1.times.count } + extraOneOffs
    }

    static func canSchedule(_ meds: [Medication], extraOneOffs: Int = 0) -> Bool {
        projected(for: meds, extraOneOffs: extraOneOffs) <= systemLimit - safetyMargin
    }

    /// Debug helper: compare what you *think* is scheduled with what the system has.
    static func audit() async {
        let pending = await UNUserNotificationCenter.current().pendingNotificationRequests()
        print("Pending: \(pending.count) / \(systemLimit)")
        pending.forEach { print(" •", $0.identifier, $0.trigger.map(String.init(describing:)) ?? "-") }
    }
}

If you genuinely need more than the budget allows, for example non-repeating schedules, schedule a rolling window (the next N days) and top it up whenever the app becomes active. Never assume "scheduled once" means "scheduled forever".

4. "Taken" and "Snooze" right from the notification


A reminder you have to unlock the phone and open the app to acknowledge is a reminder people ignore. Register a category with actions:

import UserNotifications

enum DoseActions {
    static let taken = "TAKEN"
    static let snooze = "SNOOZE_10"

    static func register() {
        let takenAction = UNNotificationAction(identifier: taken, title: "Taken", options: [])
        let snoozeAction = UNNotificationAction(identifier: snooze, title: "Snooze 10 min", options: [])
        let category = UNNotificationCategory(identifier: ReminderScheduler.doseCategory,
                                              actions: [takenAction, snoozeAction],
                                              intentIdentifiers: [], options: [])
        UNUserNotificationCenter.current().setNotificationCategories([category])
    }
}

final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
    let store: MedicationStore
    init(store: MedicationStore) { self.store = store }

    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse) async {
        let info = response.notification.request.content.userInfo
        guard let idString = info["medID"] as? String, let id = UUID(uuidString: idString) else { return }

        switch response.actionIdentifier {
        case DoseActions.taken, UNNotificationDefaultActionIdentifier:
            store.recordDose(for: id, kind: .taken, at: Date())

        case DoseActions.snooze:
            let content = response.notification.request.content.mutableCopy() as! UNMutableNotificationContent
            let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 10 * 60, repeats: false)
            let request = UNNotificationRequest(identifier: "snooze.\(idString)",   // one snooze per med
                                                content: content, trigger: trigger)
            try? await center.add(request)

        default:
            break
        }
    }
}

Register the categories and set the delegate early, in application(_:didFinishLaunchingWithOptions:) or your App initialiser, because the system may launch your app in the background specifically to handle an action.

Notice the action options are empty: no .foreground (which would open the app) and no .authenticationRequired (which forces an unlock). "Taken" works straight from the Lock Screen. That has a consequence for storage, which section 6 covers.

Using one fixed identifier per medication for snoozes means repeated snoozes replace each other instead of filling your 64-slot budget.


5. Supply maths: pills left, run-out date and a warning


The listing promises you'll "know when your supply will run out" and "get notified before you run out". That's arithmetic on the model, plus one more scheduled notification:

import Foundation

extension Medication {
    var pillsPerDay: Int { pillsPerDose * times.count }

    var daysOfSupplyLeft: Int {
        guard pillsPerDay > 0 else { return .max }
        return pillsRemaining / pillsPerDay
    }

    func runOutDate(from now: Date = Date(), calendar: Calendar = .current) -> Date? {
        guard pillsPerDay > 0 else { return nil }
        return calendar.date(byAdding: .day, value: daysOfSupplyLeft, to: calendar.startOfDay(for: now))
    }

    var missedCount: Int { log.filter { $0.kind == .missed }.count }
}

extension ReminderScheduler {
    /// One-off "running low" reminder at 09:00, `lowSupplyWarningDays` before run-out.
    static func scheduleLowSupplyWarning(for med: Medication, calendar: Calendar = .current) async throws {
        let id = "supply.\(med.id.uuidString)"
        let center = UNUserNotificationCenter.current()
        center.removePendingNotificationRequests(withIdentifiers: [id])

        guard let runOut = med.runOutDate(),
              let warnDay = calendar.date(byAdding: .day, value: -med.lowSupplyWarningDays, to: runOut),
              warnDay > Date() else { return }

        var components = calendar.dateComponents([.year, .month, .day], from: warnDay)
        components.hour = 9
        let content = UNMutableNotificationContent()
        content.title = "Running low on \(med.name)"
        content.body = "About \(med.lowSupplyWarningDays) days left. Time to refill."
        content.sound = .default

        try await center.add(UNNotificationRequest(
            identifier: id, content: content,
            trigger: UNCalendarNotificationTrigger(dateMatching: components, repeats: false)))
    }
}

Recalculate and reschedule the warning whenever supply changes: when a dose is taken, when pills are added, or when the schedule is edited. It's one slot per medication, kept accurate by a fixed identifier.

Missed doses. The system can't tell you a reminder was ignored. When the app becomes active, compare each scheduled time since the last check against the log, and record .missed for any slot without a .taken event within a grace window (say, 2 hours). Then show it honestly and let the user correct it ("I took it, I just didn't tap").


6. Private, on-device storage that works on a locked phone


The listing makes a clear promise: "Your medication data stays private on your device. We don't collect or share your personal health information." The simplest way to keep that promise is to have no server at all: a small Codable file in Application Support.

There's one subtlety. The "Taken" action from section 4 can run while the device is locked. If you save with the strongest file protection (.completeFileProtection), the file is unreadable while locked, and the write fails exactly when the user taps "Taken" on the Lock Screen.

import Foundation

final class MedicationStore {
    private(set) var medications: [Medication] = []
    private let url: URL

    init() {
        let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
        url = dir.appendingPathComponent("medications.json")
        load()
    }

    func load() {
        guard let data = try? Data(contentsOf: url) else { return }
        medications = (try? JSONDecoder().decode([Medication].self, from: data)) ?? []
    }

    func save() {
        do {
            let data = try JSONEncoder().encode(medications)
            // Encrypted at rest, but readable after the first unlock since boot,
            // so Lock Screen actions can update it.
            try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
        } catch {
            print("Save failed: \(error)")
        }
    }

    func recordDose(for id: UUID, kind: DoseEvent.Kind, at date: Date) {
        guard let i = medications.firstIndex(where: { $0.id == id }) else { return }
        medications[i].log.append(DoseEvent(date: date, kind: kind))
        if kind == .taken {
            medications[i].pillsRemaining = max(0, medications[i].pillsRemaining - medications[i].pillsPerDose)
        }
        save()
    }
}

.completeFileProtectionUntilFirstUserAuthentication (iOS 5+) is the trade-off I'd choose: encrypted when the phone has just rebooted and never been unlocked, and accessible afterwards, so background actions work. Also keep the App Privacy section of your App Store listing consistent with "data not collected", and don't add an analytics SDK that quietly breaks that promise.

7. Real-world example: Daily Pills | Reminder


These are the techniques I'd use, and recommend, for Daily Pills | Reminder.

Daily Pills App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Health & Fitness and Medical
  • First released in May 2020
  • 8 ratings in the US store, averaging about 4.4 stars
  • Supports iOS 16.0 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Set daily reminder times that work for you" / "Multiple reminders per day"One repeating calendar trigger per time, deterministic IDs (section 2)
"Never miss a dose"Time-sensitive delivery, a notification budget, a rolling window if needed (sections 2–3)
"Add pills with one tap" / "Mark missed pills instantly"An append-only dose log; Lock Screen actions (sections 1, 4)
"See how many pills you have left" / "Know when your supply will run out" / "Get notified before you run out"Supply maths and a one-off low-supply warning (section 5)
"Your medication data stays private on your device"Local Codable storage with the right file protection, no server (section 6)


Daily Pills App Store screenshot 2

I also have a second, more feature-rich medication app, Track Your Pills, with barcode scanning, PDF reports for doctors and widgets. I'm writing that one up separately, because its technical story is quite different.

A necessary note for anyone building in this space: a reminder app is a convenience tool, not medical advice. Say so in the app, and never let a bug in your scheduling be the only thing between someone and their medication. Clear, honest UI about what's scheduled helps a lot.


8. Before you ship a reminder app: a checklist


  1. Permission is requested at the moment of need, and a denied state shows how to enable notifications in Settings.
  2. Identifiers are deterministic, and edits cancel and reschedule only what they own.
  3. The pending count is audited. You know how many of the 64 slots you use, with a safety margin.
  4. Times are wall-clock components, tested across a time-zone change and a daylight-saving change.
  5. Actions work from the Lock Screen, including right after a reboot, once the phone has been unlocked once.
  6. Supply and run-out dates are recomputed on every change, and the warning is rescheduled.
  7. Missed doses are detected on launch and are easy to correct.
  8. Privacy claims match reality. No hidden analytics, and the App Privacy details are accurate.
  9. You've tested on a real device over several days. The Simulator won't show you what happens after a week of real use.

Wrapping up


Reliable reminders on iOS come down to a few disciplined habits: repeating calendar triggers with deterministic identifiers, a budget for the 64-notification cap, actions that work from the Lock Screen, and storage that can be written while the phone is locked. Add simple, honest supply maths and you have an app people can rely on.

Try it, or explore my other apps:

Have you hit the 64-notification limit in production? I'd love to hear how you discovered it. Tell me in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/medica…


Shipping a 4K Wallpaper App Without Running Out of Memory: ImageIO Downsampling, Core Image Blur and Add-Only Photos Access


Originally published at bestlatestapps.com/blog/shippi…

Decoded-image maths, thumbnails that don't decode the full file, a blur editor that keeps edges clean, saving with the minimum permission, and why your app can't set the wallpaper itself

Wallpapers HD: Exotic 4K app icon

A wallpaper app looks like the simplest kind of app there is: a grid of pictures, tap one, save it. Then you load forty 4K images into a scrolling grid on an older iPhone, and the system terminates your app for using too much memory.

My app Wallpapers HD: Exotic 4K lists "fresh 4K HD wallpapers", "parallax-ready backgrounds that look sharp on every iPhone display", a Create mode to "edit photos and add blur effects", categories from Nature and Anime to Space and Minimalism, search, and saving so you can set an image as your Lock Screen or Home Screen. It's been on the App Store since December 2022.

Every one of those features runs into the same physical fact: a decoded image costs width × height × 4 bytes, no matter how small the file is. This article covers how I'd build a wallpaper app around that fact. That means downsampled thumbnails with ImageIO, full-resolution images only when needed, a Core Image blur editor, saving with add-only Photos access, and an honest explanation of the "set as wallpaper" step. API names and availability are checked against Apple's documentation, and this is the approach I'd use rather than the app's exact source.


1. The memory maths everyone forgets


A 4K image (3840 × 2160) might be a 2 MB JPEG on disk. Decoded for display, it's:

3840 × 2160 pixels × 4 bytes (RGBA) ≈ 33 MB

A grid showing 12 of those at once is around 400 MB of bitmap memory before you've scrolled anywhere. Yet a grid cell might be 120 × 260 points, about 360 × 780 pixels on a 3× display, which needs roughly 1.1 MB decoded.

So the rule is: never decode a bigger image than you're going to display. UIImage(data:) decodes at full size. ImageIO can decode straight to the size you need.


2. Downsampling with ImageIO


CGImageSourceCreateThumbnailAtIndex (iOS 4+) creates a smaller image directly from the compressed data, without ever holding the full-size bitmap in memory:

import ImageIO
import UIKit

enum Downsampler {
    /// Decodes `data` at most `maxPixelSize` on its longest side.
    static func image(from data: Data, maxPixelSize: CGFloat, scale: CGFloat) -> UIImage? {
        let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { return nil }

        let options = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceShouldCacheImmediately: true,      // decode now, off the main thread
            kCGImageSourceCreateThumbnailWithTransform: true, // respect EXIF orientation
            kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
        ] as CFDictionary

        guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { return nil }
        return UIImage(cgImage: cg, scale: scale, orientation: .up)
    }

    /// Same, from a file URL: avoids loading the whole file into memory first.
    static func image(at url: URL, maxPixelSize: CGFloat, scale: CGFloat) -> UIImage? {
        let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { return nil }
        let options = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceShouldCacheImmediately: true,
            kCGImageSourceCreateThumbnailWithTransform: true,
            kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
        ] as CFDictionary
        return CGImageSourceCreateThumbnailAtIndex(source, 0, options)
            .map { UIImage(cgImage: $0, scale: scale, orientation: .up) }
    }
}

Two options do the important work:
  • kCGImageSourceShouldCache: false on the source stops ImageIO from caching a full-size decode as a side effect.
  • kCGImageSourceShouldCacheImmediately: true on the thumbnail forces decoding now, which you'll do on a background task, instead of lazily on the main thread during the first draw, where it causes scroll hitches.

On iOS 15+, UIImage.preparingThumbnail(of:) is a convenient alternative when you already have a UIImage. But a UIImage made from data hasn't necessarily been decoded yet, and ImageIO gives you the most control.


3. A grid that stays smooth


Better still: don't download 4K images for the grid at all. Have your image host serve a small rendition for browsing and the full file only for the detail screen. Then downsample whatever arrives to the cell's pixel size anyway, because servers change.

import SwiftUI

actor ThumbnailCache {
    static let shared = ThumbnailCache()
    private let cache = NSCache<NSURL, UIImage>()
    init() { cache.totalCostLimit = 80 * 1024 * 1024 }    // ~80 MB of decoded thumbnails

    func image(for url: URL, pixelSize: CGFloat, scale: CGFloat) async -> UIImage? {
        if let hit = cache.object(forKey: url as NSURL) { return hit }
        guard let (data, _) = try? await URLSession.shared.data(from: url),
              let image = Downsampler.image(from: data, maxPixelSize: pixelSize, scale: scale)
        else { return nil }
        let cost = Int(image.size.width * image.scale * image.size.height * image.scale * 4)
        cache.setObject(image, forKey: url as NSURL, cost: cost)
        return image
    }
}

struct WallpaperCell: View {
    let thumbnailURL: URL
    @Environment(\.displayScale) private var scale
    @State private var image: UIImage?

    var body: some View {
        GeometryReader { geo in
            ZStack {
                Color(.secondarySystemBackground)
                if let image { Image(uiImage: image).resizable().scaledToFill() }
            }
            .frame(width: geo.size.width, height: geo.size.height)
            .clipped()
            .task(id: thumbnailURL) {
                let px = max(geo.size.width, geo.size.height) * scale
                image = await ThumbnailCache.shared.image(for: thumbnailURL, pixelSize: px, scale: scale)
            }
        }
        .aspectRatio(9.0 / 19.5, contentMode: .fit)
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

The details that matter:
  • NSCache with a cost limit sized in decoded bytes, not image count, so the system can also evict under memory pressure.
  • .task(id:) cancels the load when a cell scrolls off-screen before finishing.
  • @Environment(\.displayScale), not UIScreen.main.scale. Apple's docs now mark UIScreen.main as deprecated in iOS 26. Read the scale and screen size from the view's environment or window scene instead.

Why not just AsyncImage (iOS 15+)? It's fine for small images, but it decodes whatever it downloads at full size and has no memory-cost cache. For a wallpaper grid, owning the pipeline pays off.


4. The detail screen: full resolution, exactly once


When the user opens a wallpaper, they deserve every pixel, but only one full-size image should be alive at a time. Download to a file (it streams to disk instead of RAM) and decode to the device's native screen size rather than the source's 4K:

import UIKit

enum FullImageLoader {
    /// Downloads to a temporary file, then decodes at the screen's native pixel size.
    static func load(_ url: URL, nativeScreenSize: CGSize, scale: CGFloat) async throws -> (UIImage, URL) {
        let (tempURL, _) = try await URLSession.shared.download(from: url)
        // Move out of the system's temporary location before it's cleaned up.
        let kept = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
        try? FileManager.default.removeItem(at: kept)
        try FileManager.default.moveItem(at: tempURL, to: kept)

        let longest = max(nativeScreenSize.width, nativeScreenSize.height)
        guard let image = Downsampler.image(at: kept, maxPixelSize: longest, scale: scale) else {
            throw CocoaError(.fileReadCorruptFile)
        }
        return (image, kept)    // keep the file: saving uses the original bytes
    }
}

UIScreen.nativeBounds (iOS 8+) gives the screen's size in physical pixels. Get the screen from the current window scene (view.window?.windowScene?.screen) rather than UIScreen.main.

What about "parallax-ready"? iOS can apply motion and depth effects to wallpapers, and the system crops and zooms to fit the device. The practical rule: supply images at least as large as the native screen, in portrait, with the subject away from the edges, so the system has room to crop and move. That's why a 4K source is useful even though you decode the preview smaller. You save the original, not the preview.


5. Create mode: blur that keeps clean edges


"Create your own wallpapers — edit photos and add blur effects." Core Image's Gaussian blur is the right tool, with one classic gotcha: blurring pulls in transparent pixels from outside the image, which gives you a dark, soft border. Clamp the image to its extent first, then crop back:

import CoreImage
import CoreImage.CIFilterBuiltins
import UIKit

final class BlurEditor {
    private let context = CIContext(options: [.useSoftwareRenderer: false])
    private let source: CIImage

    init?(image: UIImage) {
        guard let ci = CIImage(image: image) else { return nil }
        source = ci
    }

    /// radius in pixels of the *output*; scale it if you preview at a smaller size.
    func render(radius: Double, dim: Double = 0) -> UIImage? {
        let blur = CIFilter.gaussianBlur()
        blur.inputImage = source.clampedToExtent()     // no dark edges
        blur.radius = Float(radius)
        var output = blur.outputImage?.cropped(to: source.extent) ?? source

        if dim > 0 {                                   // darken for legible clock and widgets
            let controls = CIFilter.colorControls()
            controls.inputImage = output
            controls.brightness = Float(-dim)
            output = controls.outputImage ?? output
        }

        guard let cg = context.createCGImage(output, from: source.extent) else { return nil }
        return UIImage(cgImage: cg)
    }
}

Performance tips for a live slider:
  • Preview on the downsampled image and render the full-resolution output only when the user taps Save. Blur cost grows with the pixel count and the radius.
  • Scale the radius between preview and export. A 20-pixel blur on a 1,290-pixel-wide preview is about a 60-pixel blur on a 3,840-pixel export.
  • Reuse one CIContext. Creating a context per slider tick is the slowest possible way to use Core Image.

The optional dim parameter is a small design touch: a slightly darker, blurred background keeps the Lock Screen clock and widgets readable.


6. Saving with the minimum permission


Saving an image to Photos doesn't require read access to the user's library. iOS has an add-only access level for exactly this:

import Photos
import UIKit

enum PhotoSaver {
    enum SaveError: Error { case denied }

    /// Saves the original file bytes (not a re-encoded UIImage) to Photos.
    static func saveOriginal(fileURL: URL) async throws {
        let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)   // iOS 14+
        guard status == .authorized || status == .limited else { throw SaveError.denied }

        try await PHPhotoLibrary.shared().performChanges {
            let request = PHAssetCreationRequest.forAsset()
            request.addResource(with: .photo, fileURL: fileURL, options: nil)
        }
    }

    /// For edited images from Create mode.
    static func save(_ image: UIImage) async throws {
        let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
        guard status == .authorized || status == .limited else { throw SaveError.denied }
        try await PHPhotoLibrary.shared().performChanges {
            PHAssetChangeRequest.creationRequestForAsset(from: image)
        }
    }
}

  • Declare NSPhotoLibraryAddUsageDescription (iOS 11+), not the full-library key. For example: "Save wallpapers you choose to your photo library."
  • requestAuthorization(for: .addOnly) (iOS 14+) asks only for what you need. Users are far more likely to allow it, and you never see their photos.
  • Save the original file with PHAssetCreationRequest.addResource(with:fileURL:options:) (iOS 9+). Re-encoding a UIImage can cost quality and strip metadata.

7. The honest part: you can't set the wallpaper for the user


There's no public iOS API that lets an app set the Lock Screen or Home Screen wallpaper. A wallpaper app's job ends at "saved to Photos", and the user sets it from the Photos app or from Settings → Wallpaper.

So make that last step effortless:

  • After saving, show a short, illustrated guide: Photos → open the image → Share → Use as Wallpaper.
  • Offer a button that opens Photos. Don't promise a one-tap "Set" button you can't deliver; App Review and users both dislike that.
  • Mention that the system lets you pinch and position the image, and that depth and motion options depend on the device and iOS version.

The listing's own wording handles this well: "Save the image… Set it as your Lock Screen or Home Screen wallpaper". It's a user step, described as one.


8. Real-world example: Wallpapers HD: Exotic 4K


These are the techniques I'd use, and recommend, for Wallpapers HD: Exotic 4K.

Wallpapers HD App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free with an optional Premium tier, in Photo & Video and Graphics & Design
  • First released in December 2022
  • 6 ratings in the US store, all 5 stars
  • Supports iOS 16.0 and later

How the listing maps to this article:

What the listing saysWhere it's covered
"Fresh 4K HD wallpapers added regularly" / "Browse by category… Quick search"A small rendition for the grid, downsampled and cost-cached (sections 2–3)
"Parallax-ready backgrounds that look sharp on every iPhone display"Native-size preview, original saved, room for system cropping (section 4)
"Create your own wallpapers — edit photos and add blur effects"Clamped Gaussian blur with optional dimming (section 5)
"Save the image… Set it as your Lock Screen or Home Screen wallpaper"Add-only Photos saving, then a guided user step (sections 6–7)
"Free: Browse and download HD wallpapers with ads / Premium: … advanced blur effects… ad-free"Gate the content and editor features by entitlement; the pipeline is the same


Wallpapers HD App Store screenshot 2


9. Before you ship an image-heavy app: a checklist


  1. Nothing is decoded larger than it's displayed. Grid cells use downsampled thumbnails sized in pixels.
  2. The cache is costed in decoded bytes with a sensible limit, and it responds to memory pressure.
  3. Scrolling is profiled on your oldest supported device with Instruments' Allocations and Animation Hitches tools.
  4. Full-resolution images are downloaded to disk, previewed at screen size, and saved as original bytes.
  5. Blur previews run on the small image and export at full size with a scaled radius.
  6. Photos access is add-only, with NSPhotoLibraryAddUsageDescription and graceful handling of denial.
  7. You don't promise "set wallpaper" automation. Guide the user through Photos instead.
  8. UIScreen.main is gone from new code. Use the environment's displayScale and the window scene's screen.
  9. Image licensing is clear for every image you serve.

Wrapping up


A wallpaper app is really a memory-management app with nice pictures. Downsample with ImageIO, cache by decoded cost, keep exactly one full-size image alive, preview edits small and export big, save with the narrowest permission, and be straight with users about the last step only they can take.

Try it, or explore my other apps:

What's the largest memory spike an image grid has ever given you? Tell me in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/shippi…


Stop Hand-Rolling Unit Conversions: Foundation's Measurement, Dimension and MeasurementFormatter in Practice


Originally published at bestlatestapps.com/blog/stop-h…

Built-in units, custom dimensions, non-linear converters, locale-aware input and output, and the temperature trap that catches everyone

Units Converter & Calculator app icon

Every unit converter I've seen in a code review starts the same way: a dictionary of conversion factors, a switch on the category and a special case for temperature that someone added after the bug report. It works, until the third person adds a unit and gets one factor upside down.

My app Units Converter & Calculator converts across 30+ categories and 200+ units, completely offline. Alongside it sit a library of 180+ physical constants, learning modes and a conversion history. On a project like that, the conversion table becomes the code you trust least, unless you hand the job to Foundation.

Apple has shipped a proper units system since iOS 10: Measurement, Unit, Dimension and UnitConverter, plus MeasurementFormatter and the newer Measurement.FormatStyle for output. Most developers I talk to have never used more than UnitLength.meters. This article goes further: built-in units, custom dimensions for the categories Foundation doesn't cover, a non-linear converter, locale-correct input and output, the temperature-difference trap, and a sensible way to persist history. API names and availability are checked against Apple's documentation, and I describe the approach I'd use rather than claiming the app's exact internals.


1. Why conversion tables rot


A hand-written table has three problems:

  1. N² relationships. If you store "A to B" factors, you need a factor for every pair. Add one unit and you owe the table N new entries.
  2. Hidden non-linearity. Temperature has an offset; fuel economy is reciprocal (L/100 km vs mpg). These get special-cased, and special cases breed bugs.
  3. Formatting is an afterthought. "1.0000000000002 km" shows up in the UI, and users in Germany type "1,5" and get an error.

Foundation's model fixes the first two by design: every unit in a dimension knows how to convert to and from that dimension's base unit. That's N relationships, not N². Converting A to B is always A → base → B. For the third problem, Foundation ships formatters that understand locales.


2. The basics: Measurement and built-in dimensions

import Foundation

let marathon = Measurement(value: 42.195, unit: UnitLength.kilometers)
let miles = marathon.converted(to: .miles)
print(miles.value)  // ≈ 26.219

// Arithmetic across units just works: both sides convert to the base unit first.
let total = Measurement(value: 1, unit: UnitLength.miles)
          + Measurement(value: 500, unit: UnitLength.meters)
print(total.converted(to: .kilometers))  // ≈ 2.109 km

// Comparison works across units too.
print(Measurement(value: 1, unit: UnitMass.kilograms)
      > Measurement(value: 2, unit: UnitMass.pounds))  // true

Foundation ships a little over twenty Dimension subclasses: length, mass, volume, area, speed, temperature, pressure, energy, power, duration, frequency, angle, acceleration, fuel efficiency, electric current, charge, potential difference and resistance, illuminance, concentration, dispersion and information storage (UnitInformationStorage, iOS 13+). Each has many units: UnitEnergy.kilowattHours, UnitSpeed.knots, UnitPressure.inchesOfMercury and so on.

For a converter UI, wrap the dimension in a small description type so a category screen can list its units generically:

struct UnitCategory: Identifiable {
    let id: String                 // stable key, e.g. "length"
    let name: String
    let units: [Dimension]
}

let length = UnitCategory(id: "length", name: "Length", units: [
    UnitLength.millimeters, UnitLength.centimeters, UnitLength.meters,
    UnitLength.kilometers, UnitLength.inches, UnitLength.feet,
    UnitLength.yards, UnitLength.miles, UnitLength.nauticalMiles
])

func convert(_ value: Double, from: Dimension, to: Dimension) -> Double {
    Measurement(value: value, unit: from).converted(to: to).value
}

Measurement<Dimension> works here because converted(to:) is available whenever UnitType is a Dimension. Just make sure from and to belong to the same dimension, because converting metres to kilograms is a programmer error that Foundation won't catch for you at compile time with this erased type.

3. Custom dimensions for what Foundation doesn't cover


Getting to 30+ categories means going beyond the built-ins: density, torque, data transfer rate, radiation dose, flow rate. A custom dimension is a Dimension subclass with a base unit and static units defined by UnitConverterLinear:

final class UnitDensity: Dimension {
    // Base unit: kilograms per cubic metre.
    static let kilogramsPerCubicMeter = UnitDensity(
        symbol: "kg/m³", converter: UnitConverterLinear(coefficient: 1))
    static let gramsPerCubicCentimeter = UnitDensity(
        symbol: "g/cm³", converter: UnitConverterLinear(coefficient: 1_000))
    static let gramsPerLiter = UnitDensity(
        symbol: "g/L", converter: UnitConverterLinear(coefficient: 1))
    static let poundsPerCubicFoot = UnitDensity(
        symbol: "lb/ft³", converter: UnitConverterLinear(coefficient: 16.018_463))

    override class func baseUnit() -> UnitDensity { kilogramsPerCubicMeter }
}

let water = Measurement(value: 1, unit: UnitDensity.gramsPerCubicCentimeter)
print(water.converted(to: .poundsPerCubicFoot).value)   // ≈ 62.43

The coefficient is "how many base units in one of this unit". One g/cm³ is 1,000 kg/m³, so its coefficient is 1,000. Get that sentence right and you'll never flip a factor again.

UnitConverterLinear(coefficient:constant:) also takes a constant, applied as base = value × coefficient + constant. That's how Foundation models Celsius and Fahrenheit against kelvin. You'll rarely need it for custom units, but it's there.

Write a round-trip test for every unit. It catches typos instantly:

import XCTest

final class DensityTests: XCTestCase {
    func testRoundTrip() {
        let units: [UnitDensity] = [.kilogramsPerCubicMeter, .gramsPerCubicCentimeter,
                                    .gramsPerLiter, .poundsPerCubicFoot]
        for a in units {
            for b in units {
                let x = Measurement(value: 123.456, unit: a)
                XCTAssertEqual(x.converted(to: b).converted(to: a).value, 123.456,
                               accuracy: 1e-9, "\(a.symbol) ↔ \(b.symbol)")
            }
        }
    }
}

Round-trips catch arithmetic mistakes, not wrong factors, so add one "known value" assertion per unit from a trusted reference too.

4. Non-linear conversions: write your own UnitConverter


Some relationships aren't a × x + b. The classic one is fuel economy: litres per 100 km and miles per gallon are reciprocals. Foundation's UnitFuelEfficiency already handles that internally, but you'll hit the same shape in your own categories. Runners, for example, want pace (minutes per kilometre) and speed (km/h) to convert into each other.

UnitConverter is an open class with two methods to override:

/// base = k / value, value = k / base
final class UnitConverterReciprocal: UnitConverter {
    private let k: Double
    init(reciprocal k: Double) { self.k = k }

    override func baseUnitValue(fromValue value: Double) -> Double {
        value == 0 ? .infinity : k / value
    }
    override func value(fromBaseUnitValue baseUnitValue: Double) -> Double {
        baseUnitValue == 0 ? .infinity : k / baseUnitValue
    }
}

final class UnitPace: Dimension {
    // Base unit: seconds per metre.
    static let secondsPerMeter = UnitPace(symbol: "s/m",
        converter: UnitConverterLinear(coefficient: 1))
    static let minutesPerKilometer = UnitPace(symbol: "min/km",
        converter: UnitConverterLinear(coefficient: 60.0 / 1_000))
    static let minutesPerMile = UnitPace(symbol: "min/mi",
        converter: UnitConverterLinear(coefficient: 60.0 / 1_609.344))
    // Speed expressed inside the pace dimension: s/m = 3.6 / (km/h)
    static let kilometersPerHour = UnitPace(symbol: "km/h",
        converter: UnitConverterReciprocal(reciprocal: 3.6))

    override class func baseUnit() -> UnitPace { secondsPerMeter }
}

let pace = Measurement(value: 5, unit: UnitPace.minutesPerKilometer)
print(pace.converted(to: .kilometersPerHour).value)  // 12.0
print(pace.converted(to: .minutesPerMile).value)     // ≈ 8.05

Guard against zero. A pace of 0 is a meaningless input, and .infinity displays far better than a crash.

5. Input and output that respect the user's locale

Parsing what the user typed


Double("1,5") is nil. In much of Europe, that's how you write one and a half. Parse with the user's locale:

struct LocalizedNumberParser {
    private let formatter: NumberFormatter

    init(locale: Locale = .current) {
        formatter = NumberFormatter()
        formatter.locale = locale
        formatter.numberStyle = .decimal
        formatter.isLenient = true
    }

    func parse(_ text: String) -> Double? {
        let trimmed = text.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else { return nil }
        return formatter.number(from: trimmed)?.doubleValue
    }
}

Pair it with .keyboardType(.decimalPad), which shows the locale's decimal separator automatically.

Formatting the result


Raw doubles leak floating-point noise. Measurement.FormatStyle (iOS 15+) formats value and unit together, localised, with the precision you choose:

let result = Measurement(value: 26.218_757_5, unit: UnitLength.miles)

let text = result.formatted(.measurement(
    width: .abbreviated,
    usage: .asProvided,                       // keep the unit the user chose
    numberFormatStyle: .number.precision(.significantDigits(1...6))
))
// "26.2188 mi" in en_US; "26,2188 mi" in de_DE

Two options matter for a converter:
  • usage: .asProvided. Without it, formatters may "helpfully" switch to the locale's preferred unit. That's great in a weather app and wrong in a converter.
  • Significant digits, not fixed decimals. 0.000 012 3 and 12,300,000 both deserve a readable result.

On iOS 10–14, MeasurementFormatter with unitOptions = .providedUnit gives the same "don't switch my unit" behaviour.

Custom units format fine too. Their symbol is used, so choose symbols you're happy to show on screen.


6. The temperature trap: points vs differences


Here's a bug that ships in real apps:

let delta = Measurement(value: 10, unit: UnitTemperature.celsius)
print(delta.converted(to: .fahrenheit).value)   // 50.0

If the user meant "it's 10 °C outside", 50 °F is right. If they meant "the temperature rose by 10 °C", the answer is 18 °F: a difference doesn't carry the 32° offset.

Foundation's UnitTemperature models points on a scale. For differences, convert with the coefficient only:

enum TemperatureDelta {
    /// Kelvin-sized steps per unit: 1 °C step = 1 K, 1 °F step = 5/9 K.
    static func convert(_ value: Double, from: UnitTemperature, to: UnitTemperature) -> Double {
        func coefficient(_ u: UnitTemperature) -> Double {
            (u.converter as? UnitConverterLinear)?.coefficient ?? 1
        }
        return value * coefficient(from) / coefficient(to)
    }
}

print(TemperatureDelta.convert(10, from: .celsius, to: .fahrenheit))  // 18.0

In the UI, a "temperature difference" toggle, or a separate category, makes the intent explicit. It's a good teaching moment for a learning-focused app too.

7. History, constants and challenges

Persisting history with stable keys


Measurement is Codable, but I don't persist units by trusting the encoded symbol: symbols can change, and custom units need to come back as your static instances. Store your own stable IDs instead:

struct ConversionRecord: Codable, Identifiable {
    var id = UUID()
    let categoryID: String     // "length"
    let fromUnitID: String     // "km"
    let toUnitID: String       // "mi"
    let input: Double
    let date: Date
}

// A registry maps IDs back to Dimension instances.
enum UnitRegistry {
    static let length: [String: UnitLength] = [
        "m": .meters, "km": .kilometers, "mi": .miles, "ft": .feet, "in": .inches
    ]
}

Recompute the output on display, rather than storing it. If you ever fix a factor, old history entries silently become correct.

Constants are data, not code


A library of physical constants is a bundled JSON file: name, symbol, value, unit, uncertainty, plus the explanatory text. Use exact CODATA values. Since the 2019 SI redefinition, constants such as the speed of light, Planck's constant and the elementary charge are exact by definition, which is a nice fact to surface in a "why it's important" field.

Challenge mode: scoring an estimate


For estimation games ("how many feet in a kilometre?"), score by ratio, not absolute difference, so being off by 10% feels the same at every scale:

/// 100 for a perfect answer, falling to 0 at a factor-of-two error.
func estimationScore(guess: Double, truth: Double) -> Int {
    guard guess > 0, truth > 0 else { return 0 }
    let error = abs(log2(guess / truth))    // 0 = exact, 1 = off by 2×
    return Int((max(0, 1 - error) * 100).rounded())
}

8. Real-world example: Units Converter & Calculator


These are the techniques I'd use, and recommend, for a converter like Units Converter & Calculator.

Units Converter App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Productivity and Education
  • First released in May 2020
  • 32 ratings in the US store, averaging about 4.7 stars
  • Supports iOS 16.0 and later

How the listing lines up with this article:

What the listing saysWhere it's covered
"Convert 200+ units across 30+ categories… completely offline"Built-in dimensions plus custom Dimension subclasses, all on-device (sections 2–4)
"Real-time conversions with instant results"Locale-aware parsing on every keystroke, formatted output (section 5)
"180+ physical constants with detailed explanations"Constants as bundled data (section 7)
"Challenge Mode: Test your estimation skills"Ratio-based scoring (section 7)
"Automatic saving of all conversions… Organized by category"History with stable unit IDs, output recomputed on display (section 7)


Units Converter App Store screenshot 2

The listing's audience is "students learning physics and chemistry, engineers and scientists". That's exactly the audience that will notice a wrong factor or a temperature-delta mistake, and the reason I lean on Foundation rather than hand-written tables.


9. Before you ship a unit converter: a checklist


  1. Every unit has a round-trip test and at least one known-value test from a trusted reference.
  2. The coefficient means "base units per one of this unit", and it's written that way everywhere.
  3. Non-linear units use a real UnitConverter subclass, and zero and negative inputs are handled.
  4. Input parsing uses the user's locale, and the decimal-pad keyboard matches it.
  5. Output uses significant digits and usage: .asProvided (or .providedUnit), so the unit the user picked is the unit they see.
  6. Temperature differences are a separate, explicit mode.
  7. History stores IDs and inputs, not outputs, so fixes apply retroactively.
  8. Everything works in Airplane Mode, including constants and learning content.

Wrapping up


Unit conversion is one of those problems that looks like a lookup table and is really a type system. Foundation's Measurement and Dimension give you that type system: one base unit per dimension, linear and custom converters, unit-aware arithmetic and comparisons, and formatters that respect the user's locale. Add custom dimensions for whatever Foundation doesn't cover, test every unit, and treat temperature differences with suspicion.

Try the app, or browse the rest of my work:

Which unit has bitten you in production? For me, temperature differences top the list. Let me know in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/stop-h…


Text Plus Audio, Fully Offline: Building a Prayer App with AVAudioPlayer and Lock-Screen Controls


Originally published at bestlatestapps.com/blog/text-p…

Bundled recitation audio, verse-by-verse highlighting from a cue sheet, background playback, Now Playing controls and the interruptions you must handle, on an app that still supports iOS 11

Hanuman Chalisa app icon

Some apps are used in a very particular moment. A prayer app is opened in the morning, often before anything else, frequently by someone older who isn't interested in settings screens, and sometimes somewhere with no signal at all. It has to open instantly, play immediately and never ask for an account.

Hanuman Chalisa : Offline was the second app I published, in April 2020, one day after my Bible app. The Hanuman Chalisa is a devotional hymn to Lord Hanuman, attributed to the poet-saint Tulsidas. The app does two things: it shows the Chalisa in English-language script for reading, and it plays an audio recitation, all without an internet connection.

That combination, text plus audio that stay in step, is a pattern you'll find in language-learning apps, audiobooks with text, karaoke lyrics and guided meditations. This article walks through how I'd build it on iOS: bundling text and audio, a cue sheet for verse timing, AVAudioPlayer with a proper .playback session, highlighting the current verse, lock-screen controls through MPNowPlayingInfoCenter and MPRemoteCommandCenter, and the interruptions and route changes a real device throws at you. The app supports iOS 11 and later, so the code is UIKit-first and stays on long-established APIs. I'm describing the approach I'd use and recommend, not the app's exact source.


1. What "offline text plus audio" requires


Three requirements shape the design:

  • Zero network, zero setup. Everything ships in the app bundle. First launch is the same as the hundredth.
  • Audio and text are one experience. When the recitation reaches a verse, that verse is highlighted and scrolled into view. When the user taps a verse, the audio jumps there.
  • It behaves like a real audio app. It keeps playing with the screen locked, shows controls on the Lock Screen and in Control Center, pauses for a phone call, and stops when headphones are pulled out.

The first two are about your data model. The third is about respecting the system.


2. Bundle the content with a cue sheet


I keep the text and the timing together in one small JSON file in the bundle, next to the audio file:

{
  "title": "Hanuman Chalisa",
  "audio": "chalisa.m4a",
  "verses": [
    { "id": 1, "kind": "doha",  "start": 0.0,  "text": "…" },
    { "id": 2, "kind": "doha",  "start": 14.2, "text": "…" },
    { "id": 3, "kind": "chaupai", "start": 27.8, "text": "…" }
  ]
}

Each verse carries the time, in seconds, at which its recitation starts. That's the whole cue sheet. You produce it once, by listening with a waveform editor open, and it never changes unless the recording does.
import Foundation

struct Verse: Decodable {
    let id: Int
    let kind: String
    let start: TimeInterval
    let text: String
}

struct Recitation: Decodable {
    let title: String
    let audio: String
    let verses: [Verse]

    static func loadBundled(named name: String = "chalisa") throws -> Recitation {
        guard let url = Bundle.main.url(forResource: name, withExtension: "json") else {
            throw CocoaError(.fileNoSuchFile)
        }
        let recitation = try JSONDecoder().decode(Recitation.self, from: Data(contentsOf: url))
        // Cue sheets must be sorted; fail loudly in debug if someone edits one by hand.
        assert(zip(recitation.verses, recitation.verses.dropFirst()).allSatisfy { $0.start < $1.start })
        return recitation
    }

    /// Index of the verse being recited at `time` (binary search over start times).
    func verseIndex(at time: TimeInterval) -> Int {
        var low = 0, high = verses.count - 1, answer = 0
        while low <= high {
            let mid = (low + high) / 2
            if verses[mid].start <= time { answer = mid; low = mid + 1 } else { high = mid - 1 }
        }
        return answer
    }
}

A few practical notes:
  • AAC (.m4a) is the right format for a bundled spoken or sung recording: small, and decoded in hardware.
  • Keep the text as Unicode text, not images. It scales with Dynamic Type, it works with VoiceOver, and you can offer other scripts later from the same structure.
  • Binary search is overkill for 40-odd verses, but it costs nothing, and the same code works for a 300-chapter audiobook.

3. AVAudioPlayer and a session that keeps playing


For a single bundled file, AVAudioPlayer (available since iOS 2.2) is all you need. There's no engine graph and nothing to stream. What matters is the audio session category:

import AVFoundation

final class RecitationPlayer: NSObject, AVAudioPlayerDelegate {
    private(set) var player: AVAudioPlayer?
    var onFinish: (() -> Void)?

    func load(_ recitation: Recitation) throws {
        guard let url = Bundle.main.url(forResource: recitation.audio, withExtension: nil) else {
            throw CocoaError(.fileNoSuchFile)
        }
        let player = try AVAudioPlayer(contentsOf: url)
        player.delegate = self
        player.enableRate = true          // must be set before prepareToPlay() to allow rate changes
        player.prepareToPlay()
        self.player = player
    }

    func play() throws {
        let session = AVAudioSession.sharedInstance()
        // .playback: audio is central to the app; it plays with the Silent switch on
        // and, with the "audio" background mode, keeps going when the screen locks.
        try session.setCategory(.playback, mode: .spokenAudio, options: [])
        try session.setActive(true)
        player?.play()
    }

    func pause() { player?.pause() }

    func seek(to time: TimeInterval) {
        guard let player else { return }
        player.currentTime = max(0, min(time, player.duration))
    }

    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
        onFinish?()
    }
}

Three details that matter more than they look:
  • .playback, not the default. The default category (.soloAmbient) is silenced by the ring/silent switch and stops when the screen locks. For a prayer app, both are bugs.
  • Add the background mode. In Xcode, enable Background Modes → "Audio, AirPlay, and Picture in Picture" (audio in UIBackgroundModes). Without it, .playback alone won't keep you alive after the screen locks.
  • The .spokenAudio mode (iOS 9+) is meant for continuous spoken audio. Per Apple's docs, your audio pauses rather than ducks when another app plays a short spoken prompt, such as a navigation instruction, and you can resume afterwards. A sung recitation sits somewhere between speech and music, so .default is a reasonable choice too. setCategory(_:mode:options:) is iOS 10+, so it's safe on an iOS 11 target.

4. Highlighting the current verse


To keep the text in step with the audio, poll currentTime a few times a second and move the highlight when the verse index changes. On iOS 11 I use a CADisplayLink (or a plain Timer) rather than anything newer:

import UIKit

final class RecitationViewController: UITableViewController {
    private let recitation: Recitation
    private let audio = RecitationPlayer()
    private var link: CADisplayLink?
    private var currentIndex = -1

    init(recitation: Recitation) {
        self.recitation = recitation
        super.init(style: .plain)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "verse")
        tableView.rowHeight = UITableView.automaticDimension
        try? audio.load(recitation)
    }

    func startPlayback() {
        try? audio.play()
        link = CADisplayLink(target: self, selector: #selector(tick))
        link?.preferredFramesPerSecond = 10     // plenty for text; saves battery
        link?.add(to: .main, forMode: .common)
    }

    func stopUpdates() {
        link?.invalidate(); link = nil
    }

    @objc private func tick() {
        guard let time = audio.player?.currentTime else { return }
        let index = recitation.verseIndex(at: time)
        guard index != currentIndex else { return }
        let previous = currentIndex
        currentIndex = index
        let rows = [previous, index].filter { $0 >= 0 }.map { IndexPath(row: $0, section: 0) }
        tableView.reloadRows(at: rows, with: .fade)
        tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
    }

    // Tap a verse to jump the audio there.
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        audio.seek(to: recitation.verses[indexPath.row].start)
        tableView.deselectRow(at: indexPath, animated: true)
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        recitation.verses.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "verse", for: indexPath)
        let isCurrent = indexPath.row == currentIndex
        cell.textLabel?.text = recitation.verses[indexPath.row].text
        cell.textLabel?.numberOfLines = 0
        cell.textLabel?.font = UIFont.preferredFont(forTextStyle: isCurrent ? .headline : .body)
        cell.textLabel?.adjustsFontForContentSizeCategory = true
        cell.backgroundColor = isCurrent ? UIColor.orange.withAlphaComponent(0.15) : .clear
        return cell
    }
}

Two accessibility points that matter especially for this audience:
  • Dynamic Type everywhere (preferredFont(forTextStyle:) with adjustsFontForContentSizeCategory). Many people reading a prayer app use larger text sizes.
  • Don't fight the user's scrolling. If they've scrolled away to read ahead, pause auto-scrolling for a few seconds. Detect it in scrollViewWillBeginDragging(_:).

Stop the display link when the view disappears or playback ends. A display link running in the background is wasted battery.


5. Lock Screen and Control Center


Once you're a real .playback app, people expect to control it from the Lock Screen, Control Center, headphones and the car. That's two APIs: MPNowPlayingInfoCenter (iOS 5+) for what's playing and MPRemoteCommandCenter (iOS 7.1+) for what the buttons do.

import MediaPlayer
import UIKit

final class NowPlayingController {
    private weak var audio: RecitationPlayer?

    init(audio: RecitationPlayer, title: String, artwork: UIImage?) {
        self.audio = audio
        configureCommands()
        var info: [String: Any] = [
            MPMediaItemPropertyTitle: title,
            MPMediaItemPropertyArtist: "Recitation",
            MPMediaItemPropertyPlaybackDuration: audio.player?.duration ?? 0
        ]
        if let artwork {
            info[MPMediaItemPropertyArtwork] =
                MPMediaItemArtwork(boundsSize: artwork.size) { _ in artwork }
        }
        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    }

    /// Call on play, pause and seek, not every frame. The system extrapolates
    /// elapsed time from the rate you give it.
    func update() {
        guard let player = audio?.player else { return }
        var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
        info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime
        info[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? 1.0 : 0.0
        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    }

    private func configureCommands() {
        let center = MPRemoteCommandCenter.shared()

        center.playCommand.addTarget { [weak self] _ in
            try? self?.audio?.play(); self?.update(); return .success
        }
        center.pauseCommand.addTarget { [weak self] _ in
            self?.audio?.pause(); self?.update(); return .success
        }
        center.togglePlayPauseCommand.addTarget { [weak self] _ in
            guard let player = self?.audio?.player else { return .commandFailed }
            if player.isPlaying { self?.audio?.pause() } else { try? self?.audio?.play() }
            self?.update(); return .success
        }
        center.changePlaybackPositionCommand.addTarget { [weak self] event in
            guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
            self?.audio?.seek(to: e.positionTime); self?.update(); return .success
        }
        // Commands you don't support should be disabled, so the UI doesn't show dead buttons.
        center.nextTrackCommand.isEnabled = false
        center.previousTrackCommand.isEnabled = false
    }
}

MPMediaItemArtwork(boundsSize:requestHandler:) is iOS 10+, so it's fine on iOS 11. The scrubber on the Lock Screen needs changePlaybackPositionCommand (iOS 9.1+); without it, the scrubber is display-only.

Don't update Now Playing on every tick. Set elapsed time and rate on play, pause and seek, and the system animates the progress itself.


6. Interruptions and route changes


Real phones ring. Here are the two notifications every audio app must handle:

import AVFoundation

final class AudioInterruptionHandler {
    private weak var audio: RecitationPlayer?
    private var tokens: [NSObjectProtocol] = []
    private var wasPlaying = false
    var onChange: (() -> Void)?

    init(audio: RecitationPlayer) {
        self.audio = audio
        let center = NotificationCenter.default
        let session = AVAudioSession.sharedInstance()

        tokens.append(center.addObserver(forName: AVAudioSession.interruptionNotification,
                                         object: session, queue: .main) { [weak self] note in
            self?.handleInterruption(note)
        })
        tokens.append(center.addObserver(forName: AVAudioSession.routeChangeNotification,
                                         object: session, queue: .main) { [weak self] note in
            self?.handleRouteChange(note)
        })
    }

    deinit { tokens.forEach(NotificationCenter.default.removeObserver) }

    private func handleInterruption(_ note: Notification) {
        guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
              let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
        switch type {
        case .began:
            wasPlaying = audio?.player?.isPlaying ?? false   // the system has already paused us
        case .ended:
            let optionsRaw = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
            if wasPlaying, AVAudioSession.InterruptionOptions(rawValue: optionsRaw).contains(.shouldResume) {
                try? audio?.play()
            }
            wasPlaying = false
        @unknown default: break
        }
        onChange?()
    }

    private func handleRouteChange(_ note: Notification) {
        guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
              let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
        // Headphones unplugged / Bluetooth gone: pause, as Apple recommends for media apps,
        // instead of suddenly reciting out loud on the speaker.
        if reason == .oldDeviceUnavailable { audio?.pause(); onChange?() }
    }
}

Two version notes. The Swift names AVAudioSession.interruptionNotification and routeChangeNotification are the modern spellings; on the very oldest SDKs they were NSNotification.Name.AVAudioSessionInterruption and AVAudioSessionRouteChange. And Apple's current docs mark .shouldResume as deprecated in iOS 27 in favour of a newer resumption-recommendation mechanism. It still works on every version this app supports, so I've kept it here.

7. Real-world example: Hanuman Chalisa : Offline


This is the approach I'd use for Hanuman Chalisa : Offline, and for any text-plus-audio app.

Hanuman Chalisa App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in the Book and Music categories
  • First released in April 2020
  • 22 ratings in the US store, averaging about 4.8 stars, the highest average among my apps with ten or more ratings
  • Supports iOS 11.0 and later, on iPhone, iPad and iPod touch

The listing describes the content simply: "This version has both Chalisa in English Language Script (for reading) and Audio." Here's how that maps to this article:

What the listing saysWhere it's covered
"Hanuman Chalisa : Offline"Text, cue sheet and audio all bundled; no network code at all (section 2)
"Chalisa in English Language Script (for reading)"Unicode text with Dynamic Type in a table view (section 4)
"…and Audio"AVAudioPlayer with a .playback session and background mode (section 3)
"The iPhone, iPod & iPad version"UIKit-first code on long-standing APIs, iOS 11 and up (throughout)


Hanuman Chalisa App Store screenshot 2

I later built a much larger devotional app, Mandir Darshan, with live temple streams, the Bhagavad Gita and a chalisa collection. That one's a very different technical story, built on live streaming and picture-in-picture, and I'm covering it in its own article.


8. Before you ship a text-plus-audio app: a checklist


  1. It works in Airplane Mode from a fresh install: text, audio and highlighting.
  2. The category is .playback and the audio background mode is enabled, so audio survives the Silent switch and the Lock Screen.
  3. The cue sheet is validated. Start times are sorted, and every verse has one. Assert it in debug builds.
  4. Tapping a verse seeks the audio, and the highlight follows the audio, never the other way round.
  5. Lock-screen controls work, including the scrubber, and unsupported commands are disabled.
  6. A phone call pauses playback, and it resumes only if it was playing and the system says it may.
  7. Unplugging headphones pauses playback.
  8. Text respects Dynamic Type and VoiceOver. Test at the largest accessibility sizes.
  9. The session is deactivated when playback finishes, with .notifyOthersOnDeactivation, so other audio can resume.

Wrapping up


A text-plus-audio app looks simple, and it can be, as long as you treat the audio session with respect. Bundle everything, describe timing with a tiny cue sheet, let AVAudioPlayer play, keep the highlight driven by currentTime, and give the system what it needs for the Lock Screen and for interruptions. It's well-established API, which is exactly why it still runs on iOS 11.

Try the app, or explore my other work:

Have you built synchronised text and audio for lyrics, language lessons or audiobooks? I'd love to hear how you produced your timings. Tell me in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/text-p…


The Device Side of APNs: Building a Push Notification Test Client in Swift


Originally published at bestlatestapps.com/blog/the-de…

Permission, registration, device tokens, foreground presentation and payload inspection, plus the exact HTTP/2 request your backend needs to send

Push Notification – Client app icon

Every backend developer who has implemented Apple push notifications knows this moment. The server code is written and the request goes out. APNs answers 200, and nothing appears on the phone. Or APNs answers 400 BadDeviceToken, and you have no idea whether the problem is your token, your certificate, your environment or your JSON.

The hard part is that the backend developer usually doesn't own the iOS app. They have to ask the mobile team for a build, a device token and the right certificate, and then wait again every time something changes.

That's why I built Push Notification – Client, a small iPhone app with one job: be a known-good receiver. It hands you a device token, a bundle identifier and a certificate, so you can point your server code at it and see exactly what arrives. It's been on the App Store since June 2020 and is one of my more popular developer tools.

This article covers both halves of that loop. First, the device side: what an iOS app has to do to receive a push, written so you can build your own test client. Then the server side: the exact request APNs expects, and the error codes you'll see when it's wrong. I've checked API names, header names and limits against Apple's documentation.


1. How a push actually travels


It helps to have the whole picture before writing code:

┌──────────┐ 1. register  ┌──────┐
│ iOS app  │─────────────▶│ APNs │
│          │◀─────────────│      │ 2. device token
└────┬─────┘              └──▲───┘
     │ 3. send token           │ 5. HTTP/2 POST /3/device/<token>
     ▼                         │    (cert or JWT, topic = bundle ID)
┌──────────────────────────────┴──┐
│ Your provider server            │ 4. store token
└─────────────────────────────────┘
               6. APNs delivers to the device → system shows it / app handles it

Three identities have to line up for delivery to work:
  1. The app's bundle ID is the APNs topic.
  2. The environment. A development build gets a sandbox token; an App Store or TestFlight build gets a production token. Tokens aren't interchangeable.
  3. The credential. A push certificate for that bundle ID, or a token-signing key from the same team.

Most "it doesn't arrive" bugs are one of these three not matching. A test client makes all three visible.


2. Capabilities and permission


In Xcode, add the Push Notifications capability. This adds the aps-environment entitlement, whose value (development or production) is set by your signing. If you want to test silent pushes too, add Background Modes → Remote notifications.

Then ask for permission. UNUserNotificationCenter.requestAuthorization(options:) (iOS 10+) has an async form:

import UserNotifications
import UIKit

enum PushPermission {
    /// Returns true if the user allowed alerts, sounds or badges.
    @MainActor
    static func request() async -> Bool {
        let center = UNUserNotificationCenter.current()
        do {
            let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
            // Register even if denied: you still get a token,
            // and silent (background) pushes don't need alert permission.
            UIApplication.shared.registerForRemoteNotifications()
            return granted
        } catch {
            print("Authorization request failed: \(error)")
            return false
        }
    }

    static func currentStatus() async -> UNAuthorizationStatus {
        await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
    }
}

Two details people often miss:
  • Registering and authorising are separate. registerForRemoteNotifications() gets you a device token whether or not the user allows alerts. Permission decides only how a push is shown.
  • Provisional authorisation (.provisional, iOS 12+) delivers quietly to Notification Center without prompting. For a test tool I want the real prompt, because I want the full alert, but provisional is worth knowing about for consumer apps.

3. Getting and showing the device token


APNs returns the token to your app delegate as raw Data. Servers expect a lowercase hex string. Getting this conversion wrong (for example, using description on the Data) is the classic cause of BadDeviceToken.

In a SwiftUI app you wire the app delegate in with @UIApplicationDelegateAdaptor (iOS 14+):

import SwiftUI
import UserNotifications

@MainActor
final class PushModel: ObservableObject {
    static let shared = PushModel()
    @Published var deviceToken: String?
    @Published var registrationError: String?
    @Published var received: [ReceivedPush] = []
}

struct ReceivedPush: Identifiable {
    let id = UUID()
    let date = Date()
    let json: String
}

final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        UNUserNotificationCenter.current().delegate = self
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
        Task { @MainActor in PushModel.shared.deviceToken = hex }
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        // Common on the Simulator without a configured environment,
        // or when the entitlement is missing.
        Task { @MainActor in PushModel.shared.registrationError = error.localizedDescription }
    }
}

@main
struct PushClientApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    var body: some Scene {
        WindowGroup { ContentView().environmentObject(PushModel.shared) }
    }
}

The token screen should make the token trivially easy to get off the phone: a big monospaced label, a Copy button (UIPasteboard.general.string = token) and a Share button (ShareLink(item: token)). Show the bundle ID next to it (Bundle.main.bundleIdentifier), plus a clear label for the environment this build uses, because the person on the other end will need all three.

One more rule: treat the token as changeable. The system can issue a new one, for example after a restore to a new device. Real apps send it to their server on every launch, not only the first.


4. Seeing pushes while the app is open


By default, if your app is in the foreground when a push arrives, iOS doesn't show a banner. For a test client that's backwards: the user is staring at the app, waiting for the push. Implement userNotificationCenter(_:willPresent:withCompletionHandler:) and ask for a banner anyway. While you're there, capture the payload so you can show it.

extension AppDelegate {

    // Foreground delivery: show it AND log it.
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler:
                                    @escaping (UNNotificationPresentationOptions) -> Void) {
        log(notification.request.content.userInfo)
        completionHandler([.banner, .list, .sound, .badge])
    }

    // The user tapped a notification (app was in background or not running).
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -> Void) {
        log(response.notification.request.content.userInfo)
        completionHandler()
    }

    // Silent pushes ("content-available": 1) arrive here, if Background Modes allows it.
    func application(_ application: UIApplication,
                     didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                     fetchCompletionHandler completionHandler:
                        @escaping (UIBackgroundFetchResult) -> Void) {
        log(userInfo)
        completionHandler(.newData)
    }

    private func log(_ userInfo: [AnyHashable: Any]) {
        // userInfo is the decoded JSON payload. Re-serialise it so you see exactly what arrived.
        let dict = Dictionary(uniqueKeysWithValues: userInfo.map { ("\($0.key)", $0.value) })
        let data = (try? JSONSerialization.data(withJSONObject: dict,
                                                options: [.prettyPrinted, .sortedKeys])) ?? Data()
        let json = String(decoding: data, as: UTF8.self)
        Task { @MainActor in PushModel.shared.received.insert(ReceivedPush(json: json), at: 0) }
    }
}

.banner and .list are iOS 14+. They replaced the older .alert option.

Showing the raw JSON is the single most useful debugging feature in a test client. Half the "my custom data didn't arrive" bugs turn out to be a backend nesting a key inside aps that belongs outside it, or the other way round.


5. Payload anatomy, and the limits that bite


A push payload is a JSON dictionary with an aps key for Apple-defined fields. Your own data goes alongside aps, never inside it:

{
  "aps": {
    "alert": { "title": "Order shipped", "body": "Your parcel is on its way." },
    "sound": "default",
    "badge": 1,
    "thread-id": "orders",
    "interruption-level": "active"
  },
  "orderId": "A-1042"
}

The fields I test most often:
  • alert: a string, or a dictionary with title, subtitle and body.
  • content-available: 1: a background (silent) push. Apple's docs say to omit alert, badge and sound for a silent update, and to send it with apns-push-type: background and apns-priority: 5.
  • mutable-content: 1: sends the push through your Notification Service Extension (UNNotificationServiceExtension, iOS 10+) before it's shown. This is how you attach images or decrypt content.
  • interruption-level: passive, active, time-sensitive or critical. Time-sensitive needs its own capability; critical needs an entitlement from Apple.

And the hard limit: 4 KB (4,096 bytes) for a normal remote notification (VoIP pushes get 5 KB). Over that, APNs answers 413 PayloadTooLarge. Push an ID and fetch the details; don't push the whole document.


6. The other half: what your server sends


Here's the request in its plainest form. APNs uses HTTP/2 over TLS 1.2 or later, at api.sandbox.push.apple.com (development) or api.push.apple.com (production), on port 443 (or 2197).

With a certificate (the listing's flow: use the device token, bundle ID and certificate from the app), curl is enough to prove the pipeline end to end:

TOKEN=<hex device token from the app>
TOPIC=<bundle id shown in the app>

curl -v --http2 \
  --cert-type P12 --cert ./push-cert.p12:<p12-password> \
  -H "apns-topic: ${TOPIC}" \
  -H "apns-push-type: alert" \
  -H "apns-priority: 10" \
  -d '{"aps":{"alert":{"title":"Hello","body":"From curl"},"sound":"default"}}' \
  "https://api.sandbox.push.apple.com/3/device/${TOKEN}"

With token-based auth, you sign a short JWT with your .p8 key instead. Apple's docs require ES256, a 10-character key ID (kid) and your team ID (iss). Refresh the token no more than once every 20 minutes and no less than once every 60. In Python, with PyJWT and httpx[http2]:
import time, jwt, httpx

TEAM_ID, KEY_ID = "ABCDE12345", "FGHIJ67890"
P8 = open("AuthKey_FGHIJ67890.p8").read()

def provider_token() -> str:
    # Cache this and reuse it for ~30-50 minutes; don't mint one per request.
    return jwt.encode({"iss": TEAM_ID, "iat": int(time.time())}, P8,
                      algorithm="ES256", headers={"kid": KEY_ID})

def send(device_token: str, topic: str, payload: dict, sandbox: bool = True):
    host = "api.sandbox.push.apple.com" if sandbox else "api.push.apple.com"
    with httpx.Client(http2=True) as client:
        r = client.post(f"https://{host}/3/device/{device_token}", json=payload, headers={
            "authorization": f"bearer {provider_token()}",
            "apns-topic": topic,
            "apns-push-type": "alert",
            "apns-priority": "10",
        })
    print(r.status_code, r.headers.get("apns-id"), r.text)

The app's listing includes guidance and code examples for Java, Go, Python, Ruby, Erlang and Node.js. The shape is the same in every language: an HTTP/2 POST to /3/device/<token>, the topic header, the push-type header, and either a client certificate or a bearer token.

7. Reading APNs errors like a checklist


When it fails, APNs tells you why, in a JSON body with a reason. These are the ones I see most, straight from Apple's response table:

StatusreasonWhat it usually means
400BadDeviceTokenToken is malformed, or doesn't match the environment (sandbox token sent to production, or vice versa)
400DeviceTokenNotForTopicThe token belongs to a different bundle ID than your apns-topic
400TopicDisallowed / BadTopicThe topic isn't one your credential may push to
403BadCertificateEnvironmentDevelopment certificate against the production host, or the reverse
403ExpiredProviderToken / InvalidProviderTokenJWT too old (over an hour), or signed with the wrong key, key ID or team
410Unregistered / ExpiredTokenThe token is no longer active: stop sending to it
413PayloadTooLargeOver 4 KB
429TooManyRequestsToo many pushes to the same token; back off


Apple's docs are explicit that you shouldn't retry BadDeviceToken, DeviceTokenNotForTopic, Forbidden, ExpiredToken, Unregistered or PayloadTooLarge; only TooManyRequests is worth retrying after a delay. And a 200 means APNs accepted the push, not that the device displayed it. That's precisely the gap a test client closes.


8. Real-world example: Push Notification – Client


This is what Push Notification – Client is for. The code above is the approach I'd use to build any receiver like it; I'm describing the technique rather than publishing the app's source.

Push Notification – Client App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Developer Tools and Utilities
  • First released in June 2020
  • 58 ratings in the US store, averaging about 4.1 stars
  • Supports iOS 17.6 and later

The listing describes it in one line: "Test your server-side APNS implementation by sending push notifications to this testing app." Mapping that to the sections above:

What the listing saysWhere it's covered
"Simply use the device token, bundle identifier, and certificate provided through the app"Token capture and display (section 3), the three identities (section 1), certificate-based curl (section 6)
"Guidelines with code examples… Java, Go, Python, Ruby, Erlang, Node.js"The request shape in section 6, which is identical across languages
"Perfect for backend and server-side developers… who want to test their code"Foreground presentation and raw-payload logging (section 4), the error table (section 7)


Push Notification – Client App Store screenshot 2

It has a sibling on the Mac: Push Notification – Tester sends APNs pushes from your Mac using a certificate from Keychain, with no server at all. Between the two you can test both ends of the pipe. I'm writing up the Mac side, Keychain identities and client-certificate auth in URLSession, as a separate article.


9. Before you ship push notifications: a checklist


  1. The capability and entitlement are present in every target that needs them, and your build's aps-environment matches the APNs host your server uses.
  2. The token is hex-encoded correctly, sent to your server on every launch, and replaced when it changes.
  3. Foreground behaviour is deliberate. willPresent returns the options you actually want; don't leave it to the default.
  4. Custom data sits outside aps, and the whole payload is comfortably under 4 KB.
  5. Silent pushes are silent. content-available: 1, no alert, badge or sound, apns-push-type: background, priority 5. Don't rely on them for anything critical; the system may throttle them.
  6. Server credentials are rotated safely. JWTs are cached and refreshed every 20–60 minutes; certificates have a calendar reminder before they expire.
  7. Errors drive cleanup. On 410 Unregistered, delete the token. On BadDeviceToken, check the environment before blaming the token.
  8. You've tested on a real device in both environments: a development build and a TestFlight build.

Wrapping up


Push notifications are a three-party protocol, and when it breaks the problem is usually a mismatched identity, not your code. Build the device side once, properly: permission, registration, a correctly encoded token, foreground presentation and raw-payload logging. The server side then becomes a single, well-understood HTTP/2 request with a readable error table.

If you'd rather skip building the receiver, the apps are free:

What's the strangest APNs bug you've chased? The one I hear about most is a perfectly valid sandbox token sent to the production host. Tell me yours in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/the-de…


The Sending Side of APNs: A Mac Push Tester with Keychain Certificates, URLSession Client Auth and HTTP/2


Originally published at bestlatestapps.com/blog/the-se…

Finding push certificates in the Keychain, importing .p12 files, answering APNs's client-certificate challenge in URLSession, building the exact request (path, headers, payload), sandbox vs production, silent pushes, and reading APNs's error responses, all without a server

Push Notification

In my article on Push Notification – Client, I covered the device side of APNs: permission, registration, device tokens and showing what arrives. This is the other end of the pipe.

When you're building push notifications, the slowest part of the loop is often the sending side. The backend isn't ready. Or it's owned by another team, or every test means deploying a change to a server just to see whether a payload renders. Push Notification – Tester is my Mac app for skipping all of that. You choose a push certificate from Keychain, paste a device token, write a JSON payload, pick Development (sandbox) or Production, and send. There's no server dependency.

This article shows how a tool like that works under the hood. It's all standard Apple API: the Security framework to find identities, URLSession to authenticate with a client certificate over HTTP/2, and Apple's documented APNs request format. The protocol facts are taken from Apple's APNs documentation. Where I describe how I'd build something rather than the shipped app, I say so.


1. Two ways to authenticate with APNs, and why a tester uses certificates


APNs accepts two kinds of provider authentication:

  • Token-based (a .p8 signing key): you sign a short-lived JWT and send it in an authorization: bearer … header. One key works for all your apps.
  • Certificate-based (a push certificate, often exported as .p12): the TLS connection itself proves who you are, using a client certificate.

Apple's docs show both. For a desktop testing tool, certificates have a practical advantage: developers already have them in their Keychain, and the whole authentication step happens in the TLS handshake. Nothing needs to be signed per request.

One consequence, straight from Apple's documentation of the request headers: the authorization: bearer <provider_token> header is for token auth only, and "APNs ignores this header if you use certificate-based authentication." apns-topic is still required: "In general, the topic is your app's bundle ID/app ID." Keep the two modes clearly separate in your code.


2. Finding push identities in the Keychain


An identity in Keychain terms is a certificate plus its private key, which is what TLS client auth needs. SecItemCopyMatching with kSecClassIdentity lists them. I'd filter to push certificates by their subject summary, which for APNs certificates names the push service and your bundle ID:

import Security
import Foundation

struct PushIdentity: Identifiable, Hashable {
    let id: String              // subject summary, also used as the display name
    let identity: SecIdentity
    var isProbablyPush: Bool { id.localizedCaseInsensitiveContains("Push") }
}

enum KeychainIdentities {
    static func all() -> [PushIdentity] {
        let query: [String: Any] = [
            kSecClass as String: kSecClassIdentity,
            kSecReturnRef as String: true,
            kSecMatchLimit as String: kSecMatchLimitAll
        ]
        var result: CFTypeRef?
        guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
              let identities = result as? [SecIdentity] else { return [] }

        return identities.compactMap { identity in
            var cert: SecCertificate?
            guard SecIdentityCopyCertificate(identity, &cert) == errSecSuccess, let cert,
                  let summary = SecCertificateCopySubjectSummary(cert) as String? else { return nil }
            return PushIdentity(id: summary, identity: identity)
        }
        .sorted { ($0.isProbablyPush ? 0 : 1, $0.id) < ($1.isProbablyPush ? 0 : 1, $1.id) }
    }
}

Sorting push certificates to the top and keeping the others is deliberate. Names vary by certificate type and era, and a tester that hides the one certificate you need is worse than one that shows a few extras.

3. Importing a .p12 file


The listing mentions ".p12 / .pem workflows". For a .p12 (PKCS #12), the Security framework does the parsing. SecPKCS12Import returns the identity inside, given the export password:

enum P12Import {
    enum Failure: Error { case wrongPassword, noIdentity, status(OSStatus) }

    static func identity(from data: Data, password: String) throws -> SecIdentity {
        let options = [kSecImportExportPassphrase as String: password] as CFDictionary
        var items: CFArray?
        let status = SecPKCS12Import(data as CFData, options, &items)
        switch status {
        case errSecSuccess: break
        case errSecAuthFailed: throw Failure.wrongPassword
        default: throw Failure.status(status)
        }
        guard let first = (items as? [[String: Any]])?.first,
              let identity = first[kSecImportItemIdentity as String] else { throw Failure.noIdentity }
        return identity as! SecIdentity      // CF type bridging: the dictionary value is a SecIdentity
    }
}

A clear "wrong password" message matters more than it looks. It's by far the most common failure when importing a .p12, and a generic error code makes people think the certificate itself is broken.

.pem files hold the certificate and key as text. The approach I'd recommend is to convert them to .p12 with openssl pkcs12 -export and import that. The import path then stays the same either way.


4. Answering the client-certificate challenge in URLSession


When URLSession connects to APNs, the server asks for a client certificate. You answer in a delegate method by building a URLCredential from the identity:

import Foundation

final class APNsClient: NSObject, URLSessionDelegate {
    private let identity: SecIdentity
    private lazy var session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)

    init(identity: SecIdentity) { self.identity = identity }

    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        switch challenge.protectionSpace.authenticationMethod {
        case NSURLAuthenticationMethodClientCertificate:
            let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession)
            completionHandler(.useCredential, credential)
        default:
            // Server trust etc.: let the system evaluate Apple's certificate normally.
            completionHandler(.performDefaultHandling, nil)
        }
    }
}

Two notes:
  • Use a session-level delegate. The client-certificate challenge is a connection-level challenge, delivered to urlSession(_:didReceive:completionHandler:). There's also a task-level variant; implement the session one at minimum.
  • Don't override server trust. .performDefaultHandling for everything else means the system still validates Apple's server certificate. A testing tool is no excuse to disable TLS validation.

URLSession negotiates HTTP/2 automatically with servers that support it, and APNs requires HTTP/2 and TLS 1.2 or later. You don't need a custom HTTP/2 library.


5. Building the request: path, headers, payload


Apple's documentation spells out the request precisely: a POST to /3/device/<device token> on one of two hosts. I'd model it directly:

enum APNsEnvironment: String, CaseIterable {
    case development, production
    var host: String {
        switch self {
        case .development: "api.sandbox.push.apple.com"
        case .production:  "api.push.apple.com"
        }
    }
}

enum PushType: String, CaseIterable { case alert, background }

struct PushRequest {
    var environment: APNsEnvironment
    var deviceToken: String
    var topic: String                 // usually your bundle ID
    var pushType: PushType
    var payloadJSON: String

    func urlRequest() throws -> URLRequest {
        let token = deviceToken.filter(\.isHexDigit).lowercased()   // tolerate pasted spaces and <>
        var request = URLRequest(url: URL(string: "https://\(environment.host)/3/device/\(token)")!)
        request.httpMethod = "POST"
        request.setValue(topic, forHTTPHeaderField: "apns-topic")
        request.setValue(pushType.rawValue, forHTTPHeaderField: "apns-push-type")
        request.setValue(pushType == .background ? "5" : "10", forHTTPHeaderField: "apns-priority")
        request.setValue(UUID().uuidString.lowercased(), forHTTPHeaderField: "apns-id")

        let body = Data(payloadJSON.utf8)
        _ = try JSONSerialization.jsonObject(with: body)          // fail fast on invalid JSON
        guard body.count <= 4096 else { throw PushError.payloadTooLarge(body.count) }
        request.httpBody = body
        return request
    }
}

enum PushError: Error { case payloadTooLarge(Int) }

Each line maps to Apple's documentation:
  • Hosts. api.sandbox.push.apple.com is the development server and api.push.apple.com is production. A device token from a debug build only works against the sandbox, which is the single most common "my push never arrives" cause.
  • apns-push-type. It must "accurately reflect the contents of your notification's payload". It's required for watchOS and recommended for iOS, macOS, tvOS and iPadOS.
  • Payload size. Apple's docs say it's limited to "a maximum size of 4 KB (4096 bytes)", and must not be compressed. Checking it locally saves a round trip and a 413 response.
  • apns-id. It's optional, but setting your own UUID makes it easy to match a response to a request in your log.

6. Alerts, badges, sounds and silent pushes


The listing promises "Custom JSON payloads for alerts, badges, sounds, and silent content". I'd offer templates for each, since the shape of aps is where most mistakes happen:

enum PayloadTemplate: String, CaseIterable {
    case alert, badge, sound, silent

    var json: String {
        switch self {
        case .alert:  #"{ "aps": { "alert": { "title": "Hello", "body": "From my Mac" } } }"#
        case .badge:  #"{ "aps": { "badge": 3 } }"#
        case .sound:  #"{ "aps": { "alert": "Ding", "sound": "default" } }"#
        case .silent: #"{ "aps": { "content-available": 1 }, "refresh": "inbox" }"#
        }
    }
    var pushType: PushType { self == .silent ? .background : .alert }
}

The silent template follows Apple's "Pushing background updates to your app" guide: the aps dictionary contains only content-available, with custom keys alongside it, and the request uses apns-push-type: background with apns-priority: 5. The same guide warns that the system treats background notifications as low priority and "doesn't guarantee their delivery", and may throttle them. A tester should say that in the UI, so nobody spends an afternoon debugging a throttled silent push.

7. Sending, and reading APNs's answer


APNs replies with an HTTP status and, on failure, a small JSON body with a reason. Surfacing that reason verbatim is the most useful thing a tester does:

extension APNsClient {
    struct Outcome { let status: Int; let apnsID: String?; let reason: String? }

    func send(_ push: PushRequest) async throws -> Outcome {
        let (data, response) = try await session.data(for: try push.urlRequest())
        let http = response as! HTTPURLResponse
        let reason = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["reason"] as? String
        return Outcome(status: http.statusCode,
                       apnsID: http.value(forHTTPHeaderField: "apns-id"),
                       reason: reason)
    }
}

And a human explanation for the common reasons, taken from Apple's "Handling notification responses from APNs":
Status / reasonWhat it means in practice
200Accepted by APNs. Delivery to the device is a separate step.
400 BadDeviceTokenThe token is invalid. Usually a sandbox token sent to production, or the reverse; Apple says to check "that the token matches the environment".
403 BadCertificate / BadCertificateEnvironmentThe certificate is invalid, or doesn't match the server you chose.
410 Unregistered / ExpiredToken"The device token is inactive for the specified topic", or has expired. Typically the app was deleted or the token changed; stop sending to it.
413 PayloadTooLargeOver 4 KB.
429 TooManyRequestsToo many pushes to the same token. Apple says you can retry with a delay.


Apple's guidance is explicit that you shouldn't retry BadDeviceToken, DeviceTokenNotForTopic, Forbidden, ExpiredToken, Unregistered or PayloadTooLarge. A tester can turn that into a "fix this first" hint instead of a Retry button.


8. Real-world example: Push Notification - Tester


This is the approach behind Push Notification - Tester. The code above is written for this article, not the app's source.

Push Notification

At the time of writing, its Mac App Store listing shows:

  • Free, in Developer Tools
  • First released in June 2020
  • No ratings yet in the US store
  • Requires macOS 14.6 or later

How the listing maps to this article:

What the listing saysWhere it's covered
"Select the push certificate directly from Keychain (.p12 / .pem workflows)"Keychain identities and PKCS #12 import (sections 2–3)
"Send APNs pushes from your Mac with no server dependency"URLSession client-certificate auth over HTTP/2 (section 4)
"Paste any device token and send in seconds"Token clean-up and the request path (section 5)
"Target Development (sandbox) or Production environments"The two APNs hosts (section 5)
"Custom JSON payloads for alerts, badges, sounds, and silent content"Payload templates and silent-push rules (section 6)
"Perfect for verifying device tokens, validating certificates…"Reading APNs's status and reason (section 7)


Push Notification

Pair it with its iOS sibling, Push Notification – Client, which shows the device token to paste and displays what arrives. Between the two, you can test both ends without a backend.


9. Before you trust a push test: a checklist


  1. The environment matches the build. Debug and development builds use the sandbox; TestFlight and App Store builds use production.
  2. The topic is the bundle ID the certificate was issued for.
  3. apns-push-type matches the payload: alert for visible pushes, background (priority 5) for silent ones.
  4. The payload is valid JSON, under 4,096 bytes, and not compressed.
  5. Server trust is never disabled. Only answer the client-certificate challenge.
  6. The reason string is read, and non-retryable errors aren't retried.
  7. Silent pushes aren't expected to be guaranteed. Throttling is normal.
  8. Certificates expire. Check the expiry date when you pick an identity.
  9. For production backends, consider token-based (.p8) auth. Certificates are handy for testing, but tokens don't need yearly renewal.

Wrapping up


Sending a push is less magic than it seems: an identity from the Keychain, a credential in a URLSession delegate, and a POST to /3/device/<token> with four headers and a small JSON body. A good tester makes each step visible and turns APNs's terse reasons into plain advice, so the next time a push "doesn't arrive", you know within a minute whether it's the token, the environment, the certificate or the payload.

Try the app, or explore my other work:

What's the strangest push-delivery bug you've chased? Share it in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/the-se…

#12


Tic Tac Toe in SwiftUI, Done Properly: Value-Type Game State, Win Detection and an Unbeatable Minimax Opponent


Originally published at bestlatestapps.com/blog/tic-ta…

The smallest game worth architecting: an immutable board, testable rules, a view that just renders state, and the classic AI you can bolt on in 40 lines

Tic Tac Toe : Time Pass app icon

Tic tac toe is the "hello world" of game development, which is exactly why it's worth doing properly. It's small enough to hold in your head, but it still has every piece a real game needs: state, rules, turns, an end condition, a UI that reflects state, and, if you want it, an opponent that thinks.

One of my earliest App Store apps is Tic Tac Toe : Time Pass, published in April 2020. Its listing is as short as the game: "Put away your pencil and paper", with one-player and two-player modes, so you can play against another person or against yourself.

This article uses tic tac toe to show an architecture I'd use for any turn-based game: a value-type model that owns all the rules, a SwiftUI view that only renders state and forwards taps, unit tests for the rules, and a minimax opponent as an optional extension. To be clear: the listing doesn't say the app has a computer opponent. The minimax section is the approach I'd add, not a description of the app. API names are checked against Apple's documentation.


1. Why architecture matters even here


The quick version of tic tac toe puts nine @State strings in a view and checks for a winner inside the button action. It works for an afternoon. Then you want an undo button, a computer player, a "new game" animation, a stats screen and tests, and every one of those features has to reach into the view.

Instead, split it like this:

Game (struct)      ← all rules: whose turn, legal moves, winner, draw
   ▲     │
   │     ▼
GameViewModel      ← owns a Game, applies moves, triggers AI, publishes state
   ▲     │
   │     ▼
GameView (SwiftUI) ← renders state, sends taps; no rules at all

The model is a plain struct with no UIKit, SwiftUI or timers, which means you can test it, copy it (undo is free), and hand a copy to an AI to explore future moves without touching the real game.

2. The model: a board that can't be wrong

enum Player: String, CaseIterable {
    case x = "X", o = "O"
    var opponent: Player { self == .x ? .o : .x }
}

enum Outcome: Equatable {
    case inProgress
    case win(Player, line: [Int])
    case draw
}

struct Game: Equatable {
    private(set) var cells: [Player?] = Array(repeating: nil, count: 9)
    private(set) var current: Player = .x
    private(set) var moves: [Int] = []          // history, for undo and replays

    static let lines: [[Int]] = [
        [0, 1, 2], [3, 4, 5], [6, 7, 8],         // rows
        [0, 3, 6], [1, 4, 7], [2, 5, 8],         // columns
        [0, 4, 8], [2, 4, 6]                     // diagonals
    ]

    var outcome: Outcome {
        for line in Self.lines {
            if let p = cells[line[0]], cells[line[1]] == p, cells[line[2]] == p {
                return .win(p, line: line)
            }
        }
        return cells.allSatisfy { $0 != nil } ? .draw : .inProgress
    }

    var legalMoves: [Int] {
        guard outcome == .inProgress else { return [] }
        return cells.indices.filter { cells[$0] == nil }
    }

    /// Returns a new game with the move applied, or nil if the move is illegal.
    func playing(_ index: Int) -> Game? {
        guard legalMoves.contains(index) else { return nil }
        var next = self
        next.cells[index] = current
        next.moves.append(index)
        next.current = current.opponent
        return next
    }

    func undoing() -> Game {
        guard let last = moves.last else { return self }
        var previous = self
        previous.cells[last] = nil
        previous.moves.removeLast()
        previous.current = current.opponent
        return previous
    }
}

A few deliberate choices:
  • private(set) everywhere. Nothing outside the model can put an X in an occupied square or skip a turn. The only way to change the game is playing(_:), and it validates.
  • playing(_:) returns a new value instead of mutating. That makes the AI trivial, because it explores copies, and it makes "what if?" previews free.
  • Eight hard-coded lines beat a clever loop. For a 3×3 board, a literal table is the most readable rules engine possible. (For an N×N variant, you'd generate the lines instead.)

3. Testing the rules


Because the model is pure, the tests read like the rules of the game:

import XCTest

final class GameTests: XCTestCase {

    private func play(_ moves: [Int]) -> Game {
        moves.reduce(Game()) { game, move in
            guard let next = game.playing(move) else {
                XCTFail("Illegal move \(move)"); return game
            }
            return next
        }
    }

    func testRowWin() {
        // X: 0, 1, 2   O: 3, 4
        XCTAssertEqual(play([0, 3, 1, 4, 2]).outcome, .win(.x, line: [0, 1, 2]))
    }

    func testDiagonalWinForO() {
        // X: 0, 1, 5   O: 2, 4, 6
        XCTAssertEqual(play([0, 2, 1, 4, 5, 6]).outcome, .win(.o, line: [2, 4, 6]))
    }

    func testDraw() {
        // X O X / X O O / O X X
        XCTAssertEqual(play([0, 1, 2, 4, 3, 5, 7, 6, 8]).outcome, .draw)
    }

    func testCannotPlayOccupiedSquareOrAfterWin() {
        let game = play([0, 3, 1, 4, 2])          // X has won
        XCTAssertNil(Game().playing(0)?.playing(0))
        XCTAssertNil(game.playing(8))
    }

    func testUndoRestoresTurn() {
        let game = play([4]).undoing()
        XCTAssertEqual(game, Game())
    }
}

These run in milliseconds, with no simulator UI. When you later add an AI or a new mode, they're your safety net.

4. The view: render state, forward taps


The view model holds the game and the mode. The view is a grid:

import SwiftUI

@MainActor
final class GameViewModel: ObservableObject {
    enum Mode { case twoPlayer, onePlayer(computer: Player) }

    @Published private(set) var game = Game()
    @Published var mode: Mode = .twoPlayer
    @Published private(set) var isThinking = false

    func tap(_ index: Int) {
        if case .onePlayer(let computer) = mode, game.current == computer { return }
        guard let next = game.playing(index) else { return }
        game = next
        computerMoveIfNeeded()
    }

    func undo() { game = game.undoing() }
    func reset() { game = Game(); computerMoveIfNeeded() }

    private func computerMoveIfNeeded() {
        guard case .onePlayer(let computer) = mode,
              game.current == computer, game.outcome == .inProgress else { return }
        isThinking = true
        let snapshot = game
        Task {
            try? await Task.sleep(for: .milliseconds(350))   // feels less robotic
            let move = Minimax.bestMove(in: snapshot)
            if let move, let next = game.playing(move) { game = next }
            isThinking = false
        }
    }
}

struct GameView: View {
    @StateObject private var model = GameViewModel()
    private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)

    var body: some View {
        VStack(spacing: 20) {
            Text(status).font(.title2.bold())

            LazyVGrid(columns: columns, spacing: 8) {
                ForEach(0..<9, id: \.self) { index in
                    let mark = model.game.cells[index]
                    Button { model.tap(index) } label: {
                        Text(mark?.rawValue ?? " ")
                            .font(.system(size: 56, weight: .heavy, design: .rounded))
                            .frame(maxWidth: .infinity, minHeight: 96)
                            .background(background(for: index),
                                        in: RoundedRectangle(cornerRadius: 12))
                    }
                    .buttonStyle(.plain)
                    .disabled(mark != nil || model.game.outcome != .inProgress)
                    .accessibilityLabel(label(for: index, mark: mark))
                }
            }
            .padding(.horizontal)

            HStack {
                Button("Undo", action: model.undo).disabled(model.game.moves.isEmpty)
                Spacer()
                Button("New Game", action: model.reset)
            }
            .padding(.horizontal)
        }
        .animation(.easeOut(duration: 0.2), value: model.game)
        .sensoryFeedback(.selection, trigger: model.game.moves.count)   // iOS 17+
    }

    private var status: String {
        switch model.game.outcome {
        case .inProgress: return model.isThinking ? "Thinking…" : "\(model.game.current.rawValue) to play"
        case .win(let p, _): return "\(p.rawValue) wins!"
        case .draw: return "It's a draw"
        }
    }

    private func background(for index: Int) -> Color {
        if case .win(_, let line) = model.game.outcome, line.contains(index) {
            return .green.opacity(0.3)
        }
        return Color(.secondarySystemBackground)
    }

    private func label(for index: Int, mark: Player?) -> String {
        let row = index / 3 + 1, column = index % 3 + 1
        return "Row \(row), column \(column), \(mark.map { $0.rawValue } ?? "empty")"
    }
}

Notice what the view doesn't do: it never decides who won or whether a move is legal. Even the disabled state just reads the model.

Version notes, since the app supports iOS 12: LazyVGrid is iOS 14+, .sensoryFeedback is iOS 17+, and Task.sleep(for:) is iOS 16+. On an older target, use nested HStacks, UIImpactFeedbackGenerator and Task.sleep(nanoseconds:), or build the same view model behind a UIKit grid. SwiftUI itself starts at iOS 13, so a genuinely iOS 12-compatible app is UIKit. The architecture doesn't change; only the view layer does.


5. Adding a computer opponent: minimax


Tic tac toe is small enough to search completely. Minimax explores every possible continuation and assumes both players play perfectly: the computer picks the move with the best guaranteed outcome, and assumes you'll pick the one that's worst for it.

enum Minimax {
    /// Best move for `game.current`, or nil if the game is over.
    static func bestMove(in game: Game) -> Int? {
        let me = game.current
        var best: (move: Int, score: Int)?
        for move in game.legalMoves {
            guard let next = game.playing(move) else { continue }
            let score = minimax(next, maximizing: false, me: me, depth: 1,
                                alpha: Int.min, beta: Int.max)
            if best == nil || score > best!.score { best = (move, score) }
        }
        return best?.move
    }

    private static func minimax(_ game: Game, maximizing: Bool, me: Player,
                                depth: Int, alpha: Int, beta: Int) -> Int {
        switch game.outcome {
        case .win(let p, _): return p == me ? 10 - depth : depth - 10   // win sooner, lose later
        case .draw: return 0
        case .inProgress: break
        }
        var alpha = alpha, beta = beta
        var best = maximizing ? Int.min : Int.max
        for move in game.legalMoves {
            guard let next = game.playing(move) else { continue }
            let score = minimax(next, maximizing: !maximizing, me: me,
                                depth: depth + 1, alpha: alpha, beta: beta)
            if maximizing {
                best = max(best, score); alpha = max(alpha, best)
            } else {
                best = min(best, score); beta = min(beta, best)
            }
            if beta <= alpha { break }      // alpha-beta pruning: this branch can't matter
        }
        return best
    }
}

Two details make this feel right:
  • Depth in the score. 10 - depth makes the AI prefer winning now over winning later, and losing later over losing now. Without it, a perfect AI sometimes "plays with its food" and looks broken.
  • Alpha-beta pruning skips branches that can't change the result. The full game tree is only a few hundred thousand positions, so the AI is instant either way, but pruning is a habit worth building for bigger games.


Making it beatable


A perfect tic tac toe opponent never loses, which makes it boring quickly. A simple difficulty dial:

extension Minimax {
    /// skill 0.0 = random, 1.0 = perfect
    static func move(in game: Game, skill: Double) -> Int? {
        if Double.random(in: 0..<1) > skill { return game.legalMoves.randomElement() }
        return bestMove(in: game)
    }
}

"Easy" around 0.3, "medium" around 0.7 and "hard" at 1.0 gives three genuinely different games from one function.

6. Real-world example: Tic Tac Toe : Time Pass


The patterns above are how I'd structure Tic Tac Toe : Time Pass today, or any small turn-based game.

Tic Tac Toe App Store screenshot 1

At the time of writing, its App Store listing shows:

  • Free, in Games (Strategy, Casual) and Entertainment
  • First released in April 2020
  • 13 ratings in the US store, averaging about 3.9 stars
  • Supports iOS 12.0 and later

The listing says: "Tic Tac Toe supports one player and two player gameplay, so you can play against another human or against yourself."

What the listing saysWhere it's covered
"Put away your pencil and paper"A value-type Game model that enforces the rules (section 2)
"Two player gameplay… against another human".twoPlayer mode: one device, alternating turns (section 4)
"One player… against yourself".onePlayer mode; minimax is the extension I'd add for a computer opponent (section 5)
iOS 12.0 and laterUIKit is required below iOS 13; the model is identical either way (section 4)


Tic Tac Toe App Store screenshot 2

A 3.9 average on a game this simple tells me something too. Casual players expect polish even from "time pass" games: clear win highlighting, a satisfying tap, an easy rematch. The architecture above is what makes that polish cheap to add.


7. Before you ship a small game: a checklist


  1. Rules live in one pure model, with no UI framework imports.
  2. Illegal states are impossible: private(set) state, and validated moves that return new values.
  3. Every rule has a test: each kind of win, a draw, illegal moves, moves after a win, and undo.
  4. The view only renders and forwards. If a view contains if winner… logic, move it.
  5. The AI works on copies and never on the live game. It's tested too: it should never lose from any position where a draw is possible.
  6. Difficulty is a dial, not three AIs.
  7. Accessibility: every cell has a spoken label with row, column and contents, and the result is announced.
  8. Rematch is one tap. The fastest way to a better rating in a casual game.

Wrapping up


Tic tac toe is small, but the lessons scale. Put the rules in a value type that can't be put into an illegal state, test it like a spec, keep the view dumb, and treat the AI as just another consumer of the model, one that explores copies. The same shape works for chess, card games and anything else turn-based.

Play it, or explore my other apps:

What's your favourite "tiny game, big lesson" project? Tell me in the comments.

— Alok Kumar Singh

Originally published at bestlatestapps.com/blog/tic-ta…

This website uses cookies. If you continue browsing this website, you agree to the usage of cookies.

⇧