<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Alok Singh's Dev Notes]]></title><description><![CDATA[Alok Singh's Dev Notes]]></description><link>https://aryansbtloe.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 02:58:44 GMT</lastBuildDate><atom:link href="https://aryansbtloe.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[A Document Scanner in an Afternoon: VisionKit's Camera, Core Image Clean-Up and AirPrint]]></title><description><![CDATA[Apple's document camera on iOS 13+, a Vision rectangle-detection fallback for older devices, enhancing scans with Core Image, saving locally, sharing PNGs…]]></description><link>https://aryansbtloe.hashnode.dev/a-document-scanner-in-an-afternoon-visionkit-s-camera-core-image-clean-up-and-airprint</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/a-document-scanner-in-an-afternoon-visionkit-s-camera-core-image-clean-up-and-airprint</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[VisionKit]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:09:43 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/a-document-scanner-in-an-afternoon/scan-via-cam-icon-512.jpg" alt="Scan Via Cam app icon" /></p>
<p>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.</p>
<p><strong>Scan Via Cam</strong> is my take on the simple version: point the camera at a receipt, note, invoice, whiteboard, business card or certificate, and get "clear &amp; 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".</p>
<p>This article covers how I'd build that on iOS: <code>VNDocumentCameraViewController</code> 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.</p>
<hr />
<h2>1. The scanner pipeline</h2>
<pre><code class="language-swift">capture â”€â”€â–¶ detect edges â”€â”€â–¶ perspective-correct â”€â”€â–¶ enhance â”€â”€â–¶ save (PNG) â”€â”€â–¶ share / print
   â”‚                 â””â”€â”€â”€â”€ VNDocumentCameraViewController does these on iOS 13+ â”€â”€â”€â”€â”˜
   â””â”€â”€ iOS 12 fallback: camera photo â†’ VNDetectRectanglesRequest â†’ CIPerspectiveCorrection
</code></pre>
<p>On iOS 13 and later, Apple's document camera handles capture, edge detection, auto-capture and perspective correction. The app supports <strong>iOS 12.0 and later</strong>, so it also needs a path for devices that can't use it. Both paths end up with plain <code>UIImage</code>s, and everything after that is shared.</p>
<hr />
<h2>2. Capture with VNDocumentCameraViewController (iOS 13+)</h2>
<p><code>VNDocumentCameraViewController</code> (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 <code>VNDocumentCameraScan</code> with one corrected image per page.</p>
<pre><code class="language-swift">import UIKit
import VisionKit

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

    static var isSupported: Bool { VNDocumentCameraViewController.isSupported }

    func makeController() -&gt; VNDocumentCameraViewController {
        let controller = VNDocumentCameraViewController()
        controller.delegate = self
        return controller
    }

    func documentCameraViewController(_ controller: VNDocumentCameraViewController,
                                      didFinishWith scan: VNDocumentCameraScan) {
        let pages = (0..&lt;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)")
    }
}
</code></pre>
<p>Notes:</p>
<ul>
<li><strong>Camera permission is still yours to declare.</strong> Add <code>NSCameraUsageDescription</code>, for example "Scan Via Cam uses the camera to scan your documents." The system shows the prompt the first time the scanner opens.</li>
<li><strong>Check <code>isSupported</code></strong> before offering the button. If it's false, use the fallback below.</li>
<li><strong>The scanner's images are already cropped and flattened</strong>, so don't run your own perspective correction on them again.</li>
</ul>
<hr />
<h2>3. The iOS 12 fallback: Vision rectangles and perspective correction</h2>
<p>Vision's <code>VNDetectRectanglesRequest</code> 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 <code>UIImagePickerController</code>, then:</p>
<pre><code class="language-swift">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?) -&gt; 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) -&gt; UIImage? {
        // Vision returns normalised coordinates (0...1, origin bottom-left); scale to pixels.
        let size = image.extent.size
        func point(_ p: CGPoint) -&gt; 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
        }
    }
}
</code></pre>
<p>The string-keyed <code>CIFilter(name:)</code> form is used on purpose. The type-safe <code>CIFilter.perspectiveCorrection()</code> builders are iOS 13+, so on an iOS 12 target the classic API is the one that works everywhere.</p>
<p>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.</p>
<hr />
<h2>4. "Optimise scan quality": enhancing with Core Image</h2>
<p>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, <code>CIDocumentEnhancer</code>, which "adjusts an image's shadows and contrast", plus the general-purpose <code>CIColorControls</code>:</p>
<pre><code class="language-swift">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) -&gt; 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)
    }
}
</code></pre>
<p>A few rules I'd follow:</p>
<ul>
<li><strong>Keep the original.</strong> 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.</li>
<li><strong>Offer three modes, not ten sliders</strong>: Original, Enhanced and Black &amp; White. That covers receipts, colourful whiteboards and text-heavy pages.</li>
<li><strong>Black &amp; White isn't thresholding.</strong> A hard threshold destroys faint thermal-printer receipts. A contrast push on greyscale keeps them readable.</li>
<li><strong>One shared <code>CIContext</code>.</strong> Creating one per image is the most common Core Image performance mistake.</li>
</ul>
<hr />
<h2>5. "Saved on your phone": local storage done properly</h2>
<p>There's no server and no account, so the files on the device <em>are</em> the product. Store each scan as a folder with the original and its settings:</p>
<pre><code class="language-swift">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 -&gt; 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
    }
}
</code></pre>
<p>Two privacy decisions are worth calling out:</p>
<ul>
<li><strong><code>.completeFileProtection</code>.</strong> 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.</li>
<li><strong>Think about backups deliberately.</strong> 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 <code>URLResourceValues.isExcludedFromBackup</code> and say so in the app.</li>
</ul>
<hr />
<h2>6. Share as PNG, and print with AirPrint</h2>
<p>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".</p>
<pre><code class="language-swift">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)
    }
}
</code></pre>
<p>Sharing PNG <code>Data</code> rather than <code>UIImage</code> objects means the receiving app gets the lossless file you saved, not a re-compressed JPEG. And <code>popoverPresentationController.sourceView</code> isn't optional on iPad: without it, presenting the share sheet crashes.</p>
<p>Set <code>outputType</code> to <code>.photo</code> for scanned photos or colour certificates. For text documents, <code>.grayscale</code> is the better default.</p>
<hr />
<h2>7. Real-world example: Scan Via Cam</h2>
<p>This is the approach I'd use, and recommend, for <strong><a href="https://apps.apple.com/us/app/scan-via-cam/id1525874032">Scan Via Cam</a></strong>.</p>
<p><img src="https://bestlatestapps.com/assets/blog/a-document-scanner-in-an-afternoon/scan-via-cam-screenshot-1.jpg" alt="Scan Via Cam App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Productivity</strong> and <strong>Utilities</strong></li>
<li>First released in <strong>August 2020</strong></li>
<li><strong>6 ratings</strong> in the US store, averaging about <strong>4.7 stars</strong></li>
<li>Supports <strong>iOS 12.0 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Use your phone camera to scan receipts, notes, invoices, whiteboard discussions, business cards, certificates"</td>
<td>Document camera on iOS 13+, Vision fallback on iOS 12 (sections 2â€“3)</td>
</tr>
<tr>
<td>"Smart cropping and auto enhancing make the texts and graphics look clear and sharp"</td>
<td>Rectangle detection and perspective correction; <code>CIDocumentEnhancer</code> and colour controls (sections 3â€“4)</td>
</tr>
<tr>
<td>"Easily share documents in Png formatâ€¦ via social media, email attachment"</td>
<td><code>UIActivityViewController</code> with PNG data (section 6)</td>
</tr>
<tr>
<td>"Instantly print out docsâ€¦ via AirPrint"</td>
<td><code>UIPrintInteractionController</code> (section 6)</td>
</tr>
<tr>
<td>"Documents are saved on your phone, and we do not save them on our server" / "No Signup, No Credit Card"</td>
<td>Local folder storage with complete file protection, no account (section 5)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/a-document-scanner-in-an-afternoon/scan-via-cam-screenshot-2.jpg" alt="Scan Via Cam App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>8. Before you ship a scanner: a checklist</h2>
<ol>
<li><strong>Camera permission has a clear purpose string</strong>, and a denied state links to Settings.</li>
<li><strong><code>VNDocumentCameraViewController.isSupported</code> is checked</strong>, and older devices get a working fallback.</li>
<li><strong>Detected corners are adjustable</strong> before flattening.</li>
<li><strong>Originals are kept</strong>, and enhancement is re-applied, never baked in.</li>
<li><strong>Faint receipts survive Black &amp; White mode.</strong> Test with a real thermal-paper receipt.</li>
<li><strong>Files use complete protection</strong>, and your backup behaviour is a deliberate choice.</li>
<li><strong>Sharing sends PNG data</strong>, and the share sheet has a source view on iPad.</li>
<li><strong>AirPrint is tested on a real printer</strong>, in greyscale and colour.</li>
<li><strong>Your privacy claims are literally true.</strong> No upload, no analytics on document content, and App Privacy details to match.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>The hardest parts of document scanning are now system features: on iOS 13 and later, <code>VNDocumentCameraViewController</code> 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.</p>
<p>Try it, or explore my other apps:</p>
<ul>
<li><strong>Scan Via Cam (iOS):</strong> <a href="https://apps.apple.com/us/app/scan-via-cam/id1525874032">https://apps.apple.com/us/app/scan-via-cam/id1525874032</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>What's the hardest document you've had to scan? Glossy certificates under office lights are my nemesis. Tell me yours in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/a-document-scanner-in-an-afternoon/">https://bestlatestapps.com/blog/a-document-scanner-in-an-afternoon/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[A Practical Guide to iOS Haptics: UIFeedbackGenerator, SwiftUI sensoryFeedback and Core Haptics with AHAP]]></title><description><![CDATA[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…]]></description><link>https://aryansbtloe.hashnode.dev/a-practical-guide-to-ios-haptics-uifeedbackgenerator-swiftui-sensoryfeedback-and-core-haptics-with-ahap</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/a-practical-guide-to-ios-haptics-uifeedbackgenerator-swiftui-sensoryfeedback-and-core-haptics-with-ahap</guid><category><![CDATA[Swift]]></category><category><![CDATA[SwiftUI]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Core Haptics]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:09:14 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/a-practical-guide-to-ios-haptics/haptics-explorer-icon-512.jpg" alt="Haptics Explorer app icon" /></p>
<p>Haptics are the most under-designed part of most iOS apps. Developers add <code>.impactOccurred()</code> 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.</p>
<p>The trouble is that you can't <em>see</em> a haptic. You can read that <code>.rigid</code> is "sharper" than <code>.soft</code>, but you have to feel the difference. That's why I built <strong>Haptics Explorer</strong>: 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.</p>
<p>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.</p>
<hr />
<h2>1. Three layers, three jobs</h2>
<p>iOS gives you haptics at three levels of control:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>API</th>
<th>Use it for</th>
</tr>
</thead>
<tbody><tr>
<td>System feedback (UIKit)</td>
<td><code>UIImpactFeedbackGenerator</code>, <code>UISelectionFeedbackGenerator</code>, <code>UINotificationFeedbackGenerator</code> (iOS 10+)</td>
<td>Standard UI moments: taps, toggles, picker ticks, success/error</td>
</tr>
<tr>
<td>System feedback (SwiftUI)</td>
<td><code>.sensoryFeedback(_:trigger:)</code> (iOS 17+)</td>
<td>The same, declaratively, tied to state changes</td>
</tr>
<tr>
<td>Custom patterns</td>
<td>Core Haptics: <code>CHHapticEngine</code>, <code>CHHapticPattern</code> (iOS 13+)</td>
<td>Rhythms, textures, game effects, anything timed or shaped</td>
</tr>
</tbody></table>
<p>My rule: <strong>start at the top and go down only when you must.</strong> 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.</p>
<hr />
<h2>2. UIKit feedback generators, and what changed</h2>
<p>The three generators map to three meanings:</p>
<pre><code class="language-swift">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)
    }
}
</code></pre>
<p>Things worth knowing:</p>
<ul>
<li><strong>Impact styles</strong>: <code>.light</code>, <code>.medium</code> and <code>.heavy</code>, plus <code>.soft</code> and <code>.rigid</code> (iOS 13+). Rigid is short and crisp, soft is rounder. Feel them side by side; the names don't do them justice.</li>
<li><strong><code>prepare()</code> before you need it.</strong> 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.</li>
<li><strong>Use notifications for outcomes only.</strong> Success, warning and error are distinct, recognisable patterns. Don't use <code>.success</code> as a generic "tap" feel.</li>
</ul>
<p><strong>What's new.</strong> iOS 17.5 added view-based initialisers, such as <code>init(style:view:)</code>, and location-aware calls like <code>impactOccurred(intensity:at:)</code>. Apple's current documentation now marks the original <code>UIImpactFeedbackGenerator.init(style:)</code> as <strong>deprecated in iOS 27</strong>, with <code>init(style:view:)</code> as the replacement. If you support iOS 17.4 or earlier, branch with <code>if #available(iOS 17.5, *)</code>. There's also a new <code>UICanvasFeedbackGenerator</code> (iOS 17.5) for drawing apps: alignment and path-completion moments.</p>
<hr />
<h2>3. SwiftUI: sensoryFeedback</h2>
<p>In SwiftUI (iOS 17+), feedback is declared against a value. Whenever the trigger changes, the feedback plays:</p>
<pre><code class="language-swift">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
                }
        }
    }
}
</code></pre>
<p><code>SensoryFeedback</code> includes <code>.selection</code>, <code>.success</code>, <code>.warning</code>, <code>.error</code>, <code>.increase</code>, <code>.decrease</code>, <code>.start</code>, <code>.stop</code>, <code>.alignment</code>, <code>.levelChange</code> and <code>.impact(weight:intensity:)</code> / <code>.impact(flexibility:intensity:)</code>. Several of these, such as <code>.increase</code> and <code>.decrease</code>, describe <em>meaning</em> rather than feel, and the system picks an appropriate sensation per platform. That's exactly the abstraction you want in SwiftUI.</p>
<hr />
<h2>4. Core Haptics: the engine and its lifecycle</h2>
<p>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:</p>
<pre><code class="language-swift">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)")
        }
    }
}
</code></pre>
<p>The three lines that save you from support emails:</p>
<ul>
<li><strong>Check <code>capabilitiesForHardware().supportsHaptics</code>.</strong> 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.</li>
<li><strong>Handle <code>stoppedHandler</code> and <code>resetHandler</code>.</strong> The engine <em>will</em> stop when your app backgrounds, and the server can reset. Without a reset handler, haptics silently die after the first interruption.</li>
<li><strong><code>isAutoShutdownEnabled</code></strong> saves power between plays. Pair it with calling <code>start()</code> before each play.</li>
</ul>
<hr />
<h2>5. Building patterns: intensity, sharpness and time</h2>
<p>A Core Haptics pattern is a list of events on a timeline. There are two kinds:</p>
<ul>
<li><strong>Transient</strong>: a single tap, like a click or knock.</li>
<li><strong>Continuous</strong>: a sustained vibration with a duration, like a buzz or rumble.</li>
</ul>
<p>Each event has two key parameters, both from 0 to 1:</p>
<ul>
<li><strong>Intensity</strong>: how <em>strong</em> it is.</li>
<li><strong>Sharpness</strong>: how <em>crisp</em> it is. Low is round and dull; high is precise and clicky.</li>
</ul>
<p>Here's a heartbeat, one of the rhythm patterns in the app's library, built from four transients:</p>
<pre><code class="language-swift">import CoreHaptics

enum Patterns {
    static func tap(at time: TimeInterval, intensity: Float, sharpness: Float) -&gt; 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 -&gt; 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 -&gt; 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])
    }
}
</code></pre>
<p><code>CHHapticParameterCurve</code> (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.</p>
<p><strong>Design tip:</strong> 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.</p>
<hr />
<h2>6. AHAP: patterns as files</h2>
<p>Writing patterns in code is fine for a few. For a library, or for handing patterns between designers and developers, Apple defines <strong>AHAP</strong> (Apple Haptic and Audio Pattern), a JSON format describing the same events and curves:</p>
<pre><code class="language-swift">{
  "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 } ] } }
  ]
}
</code></pre>
<p>Loading and saving:</p>
<pre><code class="language-swift">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 -&gt; CHHapticPattern {
        try CHHapticPattern(contentsOf: url)
    }
}

/// Export any pattern you built in code as AHAP JSON.
func ahapData(for pattern: CHHapticPattern) throws -&gt; Data {
    let dictionary = try pattern.exportDictionary()  // iOS 13+
    return try JSONSerialization.data(withJSONObject: dictionary, options: [.prettyPrinted, .sortedKeys])
}
</code></pre>
<p><code>exportDictionary()</code> 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.</p>
<hr />
<h2>7. Looping, speed and "record a rhythm"</h2>
<p>Testing a haptic once isn't enough; you need to feel it repeatedly, faster and slower. <code>CHHapticAdvancedPatternPlayer</code> (iOS 13+) adds looping and playback rate:</p>
<pre><code class="language-swift">import CoreHaptics

extension HapticEngineManager {
    func makeLoopingPlayer(_ pattern: CHHapticPattern, rate: Float = 1.0) throws -&gt; 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)
}
</code></pre>
<p><strong>Recording a rhythm</strong> is pleasingly simple. Record tap timestamps, normalise them so the first tap is at zero, and turn each into a transient:</p>
<pre><code class="language-swift">func pattern(fromTapTimes times: [TimeInterval], maxDuration: TimeInterval = 4) throws -&gt; CHHapticPattern {
    guard let first = times.first else { return try CHHapticPattern(events: [], parameters: []) }
    let events = times
        .map { $0 - first }
        .filter { $0 &lt;= maxDuration }
        .map { Patterns.tap(at: $0, intensity: 0.9, sharpness: 0.5) }
    return try CHHapticPattern(events: events, parameters: [])
}
</code></pre>
<p>Capture the timestamps with <code>CACurrentMediaTime()</code> in the tap handler, not <code>Date()</code>. It's monotonic and won't jump if the clock changes.</p>
<hr />
<h2>8. Real-world example: Haptics Explorer</h2>
<p>Everything above comes from building <strong><a href="https://apps.apple.com/us/app/haptics-explorer/id1660403082">Haptics Explorer</a></strong>. The code is the approach I'd recommend, written for this article, not the app's source.</p>
<p><img src="https://bestlatestapps.com/assets/blog/a-practical-guide-to-ios-haptics/haptics-explorer-screenshot-1.jpg" alt="Haptics Explorer App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong> with an optional Premium tier, in <strong>Developer Tools</strong> and <strong>Utilities</strong></li>
<li>First released in <strong>December 2022</strong></li>
<li><strong>4 ratings</strong> in the US store, all <strong>5 stars</strong></li>
<li>Supports <strong>iOS 17.0 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Impact (Light, Medium, Heavy, Rigid, Soft)", "Notifications (Success, Error, Warning)", "Selection feedback"</td>
<td>UIKit generators and SwiftUI <code>sensoryFeedback</code> (sections 2â€“3)</td>
</tr>
<tr>
<td>"Rhythm patterns (Heartbeat, Double/Triple Tap, Tick Tock)", "Game patterns (Explosionâ€¦)"</td>
<td>Core Haptics events, sharpness and parameter curves (section 5)</td>
</tr>
<tr>
<td>"Adjust intensity and sharpness" / "Custom Pattern Creator"</td>
<td><code>CHHapticEventParameter</code> values (section 5)</td>
</tr>
<tr>
<td>"Code Generationâ€¦ UIKit, SwiftUI, Combine, Async/Await"</td>
<td>System feedback stays UIKit or SwiftUI; custom patterns use Core Haptics (sections 2â€“6)</td>
</tr>
<tr>
<td>"AHAP - Import a haptic file. Exporting an AHAP file is Premium."</td>
<td><code>playPattern(from:)</code>, <code>CHHapticPattern(contentsOf:)</code>, <code>exportDictionary()</code> (section 6)</td>
</tr>
<tr>
<td>"Test haptic feedbacks instantly with play, loop, a long test, and speed controls" / "Speed control (0.5x, 1x, 2x)"</td>
<td><code>CHHapticAdvancedPatternPlayer</code> with <code>loopEnabled</code> and <code>playbackRate</code> (section 7)</td>
</tr>
<tr>
<td>"Record and audio - Tap a rhythm"; release notes: "Tap the timeline for up to 4 seconds"</td>
<td>Tap timestamps â†’ transient events (section 7)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/a-practical-guide-to-ios-haptics/haptics-explorer-screenshot-2.jpg" alt="Haptics Explorer App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>9. Before you ship haptics: a checklist</h2>
<ol>
<li><strong>Use system feedback first.</strong> Core Haptics only for genuinely custom patterns.</li>
<li><strong>Every haptic has a meaning.</strong> Selection, impact, success and error are used consistently across the app.</li>
<li><strong><code>prepare()</code> is called</strong> ahead of latency-sensitive feedback.</li>
<li><strong>There's a hardware check.</strong> <code>supportsHaptics</code> gates custom features; the app is fine on iPad and the Simulator.</li>
<li><strong>The engine survives interruptions.</strong> <code>stoppedHandler</code> and <code>resetHandler</code> are implemented and tested by backgrounding mid-pattern.</li>
<li><strong>Haptics are paired with something visible or audible.</strong> Never use a haptic as the <em>only</em> signal: some users turn System Haptics off, and some devices don't have them.</li>
<li><strong>Patterns are short and sparse.</strong> Constant buzzing is fatiguing and drains battery.</li>
<li><strong>iOS 27 readiness.</strong> Move to <code>UIImpactFeedbackGenerator(style:view:)</code> where you can (iOS 17.5+).</li>
<li><strong>Test on several iPhone models.</strong> The Taptic Engine differs, and so does the feel.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>Good haptics are a vocabulary, not decoration. Lean on the system generators and SwiftUI's <code>sensoryFeedback</code> 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.</p>
<p>Try the app, or explore my other work:</p>
<ul>
<li><strong>Haptics Explorer (iOS):</strong> <a href="https://apps.apple.com/us/app/haptics-explorer/id1660403082">https://apps.apple.com/us/app/haptics-explorer/id1660403082</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>What's the best haptic you've felt in someone else's app? Tell me in the comments. I collect good examples.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/a-practical-guide-to-ios-haptics/">https://bestlatestapps.com/blog/a-practical-guide-to-ios-haptics/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Animating Algorithms in SwiftUI: A Step-Recorder Pattern for Visualising Sorts, Searches and Graphs]]></title><description><![CDATA[Separate the algorithm from the animation, record every step as data, then play it back with play, pause, speed and scrubbing, accessibly When I was…]]></description><link>https://aryansbtloe.hashnode.dev/animating-algorithms-in-swiftui-a-step-recorder-pattern-for-visualising-sorts-searches-and-graphs</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/animating-algorithms-in-swiftui-a-step-recorder-pattern-for-visualising-sorts-searches-and-graphs</guid><category><![CDATA[Swift]]></category><category><![CDATA[SwiftUI]]></category><category><![CDATA[iOS]]></category><category><![CDATA[algorithms]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:08:44 GMT</pubDate><content:encoded><![CDATA[<p><em>Separate the algorithm from the animation, record every step as data, then play it back with play, pause, speed and scrubbing, accessibly</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/animating-algorithms-in-swiftui/dsa-master-icon-512.jpg" alt="DSA Master app icon" /></p>
<p>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.</p>
<p>That's the gap <strong>DSA Master â€“ Data &amp; Algorithms</strong> 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.</p>
<p>The first visualiser I wrote was the obvious one: put <code>Task.sleep</code> inside the sorting loop and update some <code>@State</code> 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.</p>
<p>The fix is a pattern I now use for every visualisation: <strong>record, then replay</strong>. The algorithm runs once, at full speed, and emits a list of <em>steps</em> 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.</p>
<hr />
<h2>1. The problem with animating inside the algorithm</h2>
<p>Here's the tempting version:</p>
<pre><code class="language-swift">// Don't do this.
func bubbleSortAnimated() async {
    for i in 0..&lt;values.count {
        for j in 0..&lt;values.count - i - 1 {
            highlighted = [j, j + 1]
            try? await Task.sleep(for: .milliseconds(300))
            if values[j] &gt; values[j + 1] { values.swapAt(j, j + 1) }
        }
    }
}
</code></pre>
<p>It looks fine, but:</p>
<ul>
<li><strong>You can't go backwards.</strong> The state is gone once the loop moves on.</li>
<li><strong>Pause and speed changes leak into the algorithm.</strong> Every algorithm needs the same <code>if paused { â€¦ }</code> plumbing.</li>
<li><strong>The algorithm isn't testable.</strong> You can't assert on "the sequence of comparisons" without running the UI.</li>
<li><strong>Recursive algorithms get ugly.</strong> Merge sort and DFS turn into <code>async</code> recursion just to show a highlight.</li>
</ul>
<p>Recording steps as values fixes all four.</p>
<hr />
<h2>2. The step model</h2>
<p>A step is a snapshot of <em>what the viewer should see</em>, plus a sentence explaining it. For array algorithms:</p>
<pre><code class="language-swift">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&lt;Int&gt; = []      // indices being looked at
    var sorted: Set&lt;Int&gt; = []      // indices known to be final
    var caption: String
}
</code></pre>
<p>Two design choices matter here.</p>
<p><strong>Each bar has a stable <code>id</code>, separate from its value.</strong> Duplicate values are common in sample data; if identity were the value, SwiftUI couldn't tell two <code>5</code>s apart and the swap animation would glitch.</p>
<p><strong>A step holds the full array, not a diff.</strong> 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.</p>
<hr />
<h2>3. Recording an algorithm</h2>
<p>The algorithm becomes a pure function: input in, <code>[ArrayStep]</code> out. Here's insertion sort with a small recorder helper:</p>
<pre><code class="language-swift">struct StepRecorder {
    private(set) var steps: [ArrayStep] = []
    var bars: [Bar]
    var sorted: Set&lt;Int&gt; = []

    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&lt;Int&gt;, _ caption: String) {
        steps.append(ArrayStep(bars: bars, action: action,
                               active: active, sorted: sorted, caption: caption))
    }
}

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

    for i in 1..&lt;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 &gt; 0 {
            r.record(.compare, [j - 1, j], "Compare \(r.bars[j - 1].value) and \(r.bars[j].value).")
            guard r.bars[j - 1].value &gt; 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
}
</code></pre>
<p>That's the whole visualisation logic for insertion sort, with no SwiftUI and no timers, and it's unit-testable:</p>
<pre><code class="language-swift">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)
    }
}
</code></pre>
<p>Recursive algorithms are just as easy, because the recorder is a value you pass <code>inout</code>. Merge sort records <code>.overwrite</code> steps as it copies values back from the temporary buffer. Quick sort records the pivot as <code>active</code> and the partition boundary in the caption. Each algorithm stays readable as an <em>algorithm</em>.</p>
<hr />
<h2>4. The player: play, pause, speed and scrub</h2>
<p>The player owns an index into the step array, and nothing else about the algorithm:</p>
<pre><code class="language-swift">import SwiftUI

@MainActor
final class StepPlayer&lt;Step&gt;: 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&lt;Void, Never&gt;?

    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)
    }
}
</code></pre>
<p>Because the speed is read on every tick, changing it mid-playback just works. Step back is <code>step(by: -1)</code>, and a scrubber is a <code>Slider</code> bound to <code>seek(to:)</code>. None of this knows what sorting is, so the same player drives arrays, trees and graphs.</p>
<hr />
<h2>5. Rendering bars that animate themselves</h2>
<p>With stable identity, SwiftUI does the hard animation work for you. Render the current step and attach an animation to the step index:</p>
<pre><code class="language-swift">struct ArrayStepView: View {
    @ObservedObject var player: StepPlayer&lt;ArrayStep&gt;

    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) -&gt; Color {
        if step.sorted.contains(index) { return .green }
        if step.active.contains(index) { return step.action == .swap ? .orange : .yellow }
        return .blue.opacity(0.6)
    }
}
</code></pre>
<p><code>ForEach</code> uses <code>\.element.id</code>, not the index. When two bars swap places in the array, SwiftUI sees the <em>same</em> views in new positions and slides them, with no custom geometry code. The <code>.animation(_:value:)</code> modifier (iOS 13+) ties the animation to step changes only, so unrelated state changes don't animate.</p>
<hr />
<h2>6. Beyond arrays: binary search and BFS</h2>
<p>The pattern generalises by changing only the step type.</p>
<p><strong>Binary search</strong> needs a window, not swaps:</p>
<pre><code class="language-swift">struct SearchStep: Hashable {
    var low: Int, high: Int, mid: Int?
    var found: Int?
    var caption: String
}

func binarySearchSteps(_ sorted: [Int], target: Int) -&gt; [SearchStep] {
    var steps: [SearchStep] = []
    var low = 0, high = sorted.count - 1
    while low &lt;= 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] &lt; target {
            low = mid + 1
            steps.append(.init(low: low, high: high, mid: nil, found: nil,
                               caption: "\(sorted[mid]) &lt; \(target): discard the left half."))
        } else {
            high = mid - 1
            steps.append(.init(low: low, high: high, mid: nil, found: nil,
                               caption: "\(sorted[mid]) &gt; \(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
}
</code></pre>
<p><strong>Breadth-first search</strong> 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:</p>
<pre><code class="language-swift">struct GraphStep: Hashable {
    var visited: Set&lt;Int&gt;
    var frontier: [Int]       // the queue
    var current: Int?
    var caption: String
}

func bfsSteps(adjacency: [Int: [Int]], start: Int) -&gt; [GraphStep] {
    var steps: [GraphStep] = []
    var visited: Set&lt;Int&gt; = [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
}
</code></pre>
<p>Draw nodes with <code>Circle()</code> at fixed positions and edges with a <code>Path</code> inside a <code>Canvas</code> or <code>ZStack</code>. 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.</p>
<hr />
<h2>7. Complexity charts with Swift Charts</h2>
<p>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:</p>
<pre><code class="language-swift">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 -&gt; [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)
    }
}
</code></pre>
<p>The <code>chartYScale</code> clip is the teaching trick: let O(nÂ²) shoot off the top of the chart, and learners see immediately why it matters.</p>
<hr />
<h2>8. Accessibility and running user code</h2>
<p><strong>VoiceOver.</strong> An animation is invisible to a VoiceOver user, but the <em>captions</em> aren't. Because every step already has a sentence, accessibility is nearly free: give the visualisation an <code>accessibilityLabel</code> of the current caption, and announce it when the step changes while playing:</p>
<pre><code class="language-swift">.onChange(of: player.index) { _ in
    UIAccessibility.post(notification: .announcement, argument: player.current.caption)
}
</code></pre>
<p>(<code>onChange(of:perform:)</code> 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 (<code>UIAccessibility.isVoiceOverRunning</code>) so announcements aren't cut off, and honour Reduce Motion by swapping the spring for a cross-fade.</p>
<p><strong>A code playground.</strong> 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:</p>
<ul>
<li><strong>On-device JavaScript</strong> with <code>JSContext</code> from JavaScriptCore (iOS 7+). It's sandboxed and fast, and it needs no network.</li>
<li><strong>A remote, sandboxed runner</strong> for compiled languages: send the source, get back output, errors and timing. You need strict timeouts and resource limits on the server.</li>
</ul>
<p>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.</p>
<hr />
<h2>9. Real-world example: DSA Master â€“ Data &amp; Algorithms</h2>
<p>Everything above is the approach I'd use, and I'd recommend it for any teaching visualiser. You'll find it in spirit in <strong><a href="https://apps.apple.com/us/app/dsa-master-data-algorithms/id1672776683">DSA Master â€“ Data &amp; Algorithms</a></strong>.</p>
<p><img src="https://bestlatestapps.com/assets/blog/animating-algorithms-in-swiftui/dsa-master-screenshot-1.jpg" alt="DSA Master App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Education</strong> and <strong>Developer Tools</strong></li>
<li>First released in <strong>February 2023</strong></li>
<li><strong>36 ratings</strong> in the US store, averaging about <strong>4.1 stars</strong></li>
<li>Supports <strong>iOS 16.2 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Step-by-Step Animationsâ€¦ Play, pause, replay, and control animation speed"</td>
<td>Recorded steps and <code>StepPlayer</code> (sections 2â€“4)</td>
</tr>
<tr>
<td>"Visual Data Flow â€“ See how data structures transform during algorithm execution"</td>
<td>Stable-identity bars that animate themselves (section 5)</td>
</tr>
<tr>
<td>"Multiple Visualization Types â€“ Sorting, searching, tree traversal, graph algorithms"</td>
<td>The same player with different step types (section 6)</td>
</tr>
<tr>
<td>"Time Complexity Charts â€“ Visual comparisons of algorithm performance"</td>
<td>Swift Charts <code>LineMark</code> curves (section 7)</td>
</tr>
<tr>
<td>"Accessibility â€“ Full VoiceOver support"</td>
<td>Captions as labels and announcements (section 8)</td>
</tr>
<tr>
<td>"Live Code Playgroundâ€¦ Python, JavaScript, Java, C++, Kotlin, Dart, and Go"</td>
<td>Options and the Review Guideline note (section 8)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/animating-algorithms-in-swiftui/dsa-master-screenshot-2.jpg" alt="DSA Master App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>10. Before you ship an algorithm visualiser: a checklist</h2>
<ol>
<li><strong>Algorithms are pure.</strong> Each one takes input and returns <code>[Step]</code>; no SwiftUI, no timers, no <code>async</code>.</li>
<li><strong>There are unit tests per algorithm</strong>: the final step is correct, and the step count is sane for small inputs.</li>
<li><strong>Identity is stable.</strong> Elements have IDs independent of their values, so duplicates animate correctly.</li>
<li><strong>The player is generic.</strong> Play, pause, step, scrub, replay and speed work for every step type.</li>
<li><strong>Every step has a caption.</strong> It's your explanation, your VoiceOver label and your test oracle, all at once.</li>
<li><strong>Reduce Motion and VoiceOver are respected</strong>: a slower default, announcements and cross-fades instead of springs.</li>
<li><strong>Inputs are capped.</strong> Arrays of 10â€“40 elements; bigger inputs don't teach better, they just get slower.</li>
<li><strong>Code execution passes review.</strong> If you run user code, make sure you meet Guideline 2.5.2, and that remote runners are sandboxed with timeouts.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Try the app, or explore my other work:</p>
<ul>
<li><strong>DSA Master â€“ Data &amp; Algorithms (iOS):</strong> <a href="https://apps.apple.com/us/app/dsa-master-data-algorithms/id1672776683">https://apps.apple.com/us/app/dsa-master-data-algorithms/id1672776683</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>Which algorithm only made sense to you once you <em>saw</em> it? For me it was the partition step in quick sort. Tell me yours in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/animating-algorithms-in-swiftui/">https://bestlatestapps.com/blog/animating-algorithms-in-swiftui/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building a Chromatic Guitar Tuner on iOS: Real-Time Pitch Detection with AVAudioEngine and Accelerate]]></title><description><![CDATA[From microphone samples to "A2, +3 cents": session setup, an input tap, autocorrelation with vDSP, note maths, smoothing and tuning presets A tuner is the…]]></description><link>https://aryansbtloe.hashnode.dev/building-a-chromatic-guitar-tuner-on-ios-real-time-pitch-detection-with-avaudioengine-and-accelerate</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-a-chromatic-guitar-tuner-on-ios-real-time-pitch-detection-with-avaudioengine-and-accelerate</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[AVFoundation]]></category><category><![CDATA[audio]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:08:12 GMT</pubDate><content:encoded><![CDATA[<p><em>From microphone samples to "A2, +3 cents": session setup, an input tap, autocorrelation with vDSP, note maths, smoothing and tuning presets</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-chromatic-guitar-tuner-on-ios/guitar-tuner-icon-512.jpg" alt="Guitar Tuner: Chromatic app icon" /></p>
<p>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.</p>
<p>My app <strong>Guitar Tuner: Chromatic</strong> 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.</p>
<p>This article builds the core of a tuner like that, end to end: configuring the audio session for <em>measurement</em>, pulling samples from the mic with an <code>AVAudioEngine</code> tap, detecting pitch with autocorrelation using Accelerate's <code>vDSP</code>, 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.</p>
<hr />
<h2>1. What a tuner actually has to do</h2>
<p>Every frame, a tuner answers three questions:</p>
<ol>
<li><strong>Is anyone playing?</strong> Silence and room noise shouldn't move the needle.</li>
<li><strong>What is the fundamental frequency?</strong> That means the <em>fundamental</em>, 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.</li>
<li><strong>How far is that from the nearest target note?</strong> The answer is in <strong>cents</strong>: hundredths of a semitone.</li>
</ol>
<p>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 <del>24 ms of audio, and in practice you want several periods: I work with windows of about 4,096 samples (</del>85 ms at 48 kHz).</p>
<hr />
<h2>2. Permission and a session set up for measurement</h2>
<p>Microphone permission works exactly as in any audio app: an <code>NSMicrophoneUsageDescription</code> purpose string ("Guitar Tuner listens to your instrument to detect its pitch"), then <code>AVAudioApplication.requestRecordPermission()</code> on iOS 17+, with the <code>AVAudioSession</code> 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: <strong>the session mode</strong>.</p>
<pre><code class="language-swift">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)
    }
}
</code></pre>
<p>Apple describes <code>.measurement</code> (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.</p>
<p>If your tuner also plays reference tones, use <code>.playAndRecord</code> instead, but keep the <code>.measurement</code> mode.</p>
<hr />
<h2>3. Getting samples: an input tap and a rolling window</h2>
<p><code>AVAudioEngine</code>'s input node delivers microphone audio. A <strong>tap</strong> on it hands you buffers of samples without routing anything to the speaker:</p>
<pre><code class="language-swift">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) -&gt; Void)?

    func start() throws {
        let input = engine.inputNode
        let format = input.outputFormat(forBus: 0)
        guard format.sampleRate &gt; 0, format.channelCount &gt; 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 &gt;= 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()
    }
}
</code></pre>
<p>A few details that matter:</p>
<ul>
<li><strong>The requested buffer size is a request.</strong> 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.</li>
<li><strong>Copy, then hand off.</strong> The buffer is only valid during the block, so copy the samples and do the analysis on your own queue.</li>
<li><strong>Use only channel 0.</strong> A tuner needs mono.</li>
</ul>
<p><strong>What's new in iOS 27:</strong> Apple's documentation now marks <code>installTap(onBus:bufferSize:format:block:)</code> as deprecated in iOS 27 and adds <code>installAudioTap(onBus:bufferSize:format:tapProvider:)</code>. The new method takes a <code>@Sendable</code> closure that receives an <code>AVReadOnlyAudioPCMBuffer</code> (iOS 27+), "safe for concurrent use". If you build with the iOS 27 SDK, it's the Swift-concurrency-friendly choice. Because it <code>throws</code>, call it with <code>try</code>. On earlier systems, the classic tap above is still the way.</p>
<hr />
<h2>4. Pitch detection with autocorrelation and vDSP</h2>
<p>Autocorrelation asks: <em>"if I shift this signal by <code>lag</code> samples, how similar is it to itself?"</em> A periodic signal lines up with itself at its period, so the first strong peak (after lag zero) gives you the period, and <code>frequency = sampleRate / lag</code>. Unlike picking the biggest FFT bin, this naturally favours the fundamental, because every harmonic also repeats at the fundamental's period.</p>
<p>Accelerate's <code>vDSP_conv</code> (iOS 4+) computes a sliding dot product. Apple's docs note that <strong>a positive filter stride gives correlation</strong>, which is exactly what we want, and it's fast.</p>
<pre><code class="language-swift">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) -&gt; Double? {
        // 1. Noise gate.
        var rms: Float = 0
        vDSP_rmsqv(x, 1, &amp;rms, vDSP_Length(x.count))
        guard rms &gt; 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..&lt;filterLength
            vDSP_conv(xp.baseAddress!, 1, xp.baseAddress!, 1,
                      &amp;r, 1, vDSP_Length(lagCount), vDSP_Length(filterLength))
        }
        guard r[0] &gt; 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 &lt; maxLag, r[lag] &gt; 0 { lag += 1 }

        var bestLag = 0
        var bestValue: Float = 0
        let threshold: Float = 0.5          // how periodic it must be to count
        while lag &lt; maxLag {
            if r[lag] &gt; r[lag - 1], r[lag] &gt;= r[lag + 1], r[lag] &gt; threshold {
                bestLag = lag; bestValue = r[lag]
                break                          // first good peak = fundamental
            }
            lag += 1
        }
        guard bestLag &gt; 0, bestValue &gt; 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))
    }
}
</code></pre>
<p>Why each step matters:</p>
<ul>
<li><strong>The noise gate</strong> stops room noise from producing random notes. Expose the threshold in a hidden debug setting while you test; phones differ.</li>
<li><strong>Taking the <em>first</em> good peak, not the highest</strong>, avoids "sub-octave" errors where the peak at twice the period is marginally taller.</li>
<li><strong>Parabolic interpolation</strong> 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.</li>
</ul>
<p>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 <code>vDSP.FFT</code> (iOS 13+) via the Wienerâ€“Khinchin theorem, but measure first; you probably don't need it.</p>
<hr />
<h2>5. From Hz to note name and cents</h2>
<p>Equal temperament makes the maths pleasant. With A4 = 440 Hz, MIDI note 69:</p>
<pre><code class="language-swift">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) -&gt; Double {
        69 + 12 * log2(frequency / a4)
    }

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

    static func reading(for frequency: Double, a4: Double = 440) -&gt; 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) -&gt; Double {
        1200 * log2(frequency / target)
    }
}

// 110 Hz â†’ A2, 0 cents. 82.41 Hz â†’ E2, â‰ˆ 0 cents.
</code></pre>
<p>Let users change the reference pitch (<code>a4</code>). Some orchestras and recordings use 442 Hz or 432 Hz, and it's one parameter.</p>
<hr />
<h2>6. Smoothing: a calm needle that's still responsive</h2>
<p>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:</p>
<pre><code class="language-swift">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?) -&gt; 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)) &gt; 80 {
            recent.removeAll(); displayed = nil
        }
        recent.append(f)
        if recent.count &gt; 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
    }
}
</code></pre>
<p>The median handles <em>outliers</em>; the exponential average handles <em>jitter</em>. Resetting on a big jump keeps the tuner responsive when you move to the next string.</p>
<p>Then drive the UI with an animation that's slightly slower than your reading rate, for example <code>.animation(.easeOut(duration: 0.12), value: cents)</code>, and the needle glides instead of stepping.</p>
<hr />
<h2>7. Tuning presets, Auto Mode and colour feedback</h2>
<p>A preset is just a list of target frequencies. Using MIDI numbers keeps them exact:</p>
<pre><code class="language-swift">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 ..&lt;5:  return .inTune
        case ..&lt;15: return .close
        default:    return .adjust
        }
    }
}

/// Auto Mode: pick the string whose target is nearest, in cents.
func autoMatch(_ frequency: Double, in preset: TuningPreset) -&gt; StringMatch? {
    preset.frequencies.enumerated()
        .map { StringMatch(index: $0.offset, target: $0.element,
                           cents: NoteMath.cents(from: frequency, to: $0.element)) }
        .min { abs($0.cents) &lt; abs($1.cents) }
}
</code></pre>
<p>Measuring "nearest" in <strong>cents</strong>, 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.</p>
<p>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 <code>.inTune</code>, <code>.close</code> and <code>.adjust</code> to your colour scheme, and add an <code>accessibilityValue</code> like "A2, 3 cents sharp" so the tuner also works with VoiceOver.</p>
<hr />
<h2>8. Real-world example: Guitar Tuner: Chromatic</h2>
<p>This is the approach I'd use, and recommend, for a tuner like <strong><a href="https://apps.apple.com/us/app/guitar-tuner-chromatic/id1518293355">Guitar Tuner: Chromatic</a></strong>.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-chromatic-guitar-tuner-on-ios/guitar-tuner-screenshot-1.jpg" alt="Guitar Tuner App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Music</strong> and <strong>Utilities</strong>, with an optional Premium tier</li>
<li>First released in <strong>June 2020</strong></li>
<li><strong>28 ratings</strong> in the US store, averaging about <strong>4.1 stars</strong></li>
<li>Supports <strong>iOS 16.0 and later</strong></li>
</ul>
<p>How the listing maps to the sections above:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Real-time pitch detectionâ€¦ accurate to the cent, with no cables or extra hardware"</td>
<td>Measurement-mode session, input tap, autocorrelation with interpolation (sections 2â€“4)</td>
</tr>
<tr>
<td>"Chromatic tuner with live Hz display"</td>
<td>Hz â†’ note and cents (section 5)</td>
</tr>
<tr>
<td>"Color-coded feedback (blue = in tune, yellow = close, red = adjust)"</td>
<td>Three-state cents bands (section 7)</td>
</tr>
<tr>
<td>"Auto Mode detects the string you're playing automatically"</td>
<td>Nearest target in cents (section 7)</td>
</tr>
<tr>
<td>"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"</td>
<td>Presets as MIDI note lists (section 7)</td>
</tr>
<tr>
<td>"Pluck a string near your iPhone microphone"</td>
<td>The noise gate and windowing realities (sections 3â€“4)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-chromatic-guitar-tuner-on-ios/guitar-tuner-screenshot-2.jpg" alt="Guitar Tuner App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>9. Before you ship a tuner: a checklist</h2>
<ol>
<li><strong>Test with real instruments, not just a tone generator.</strong> Sine waves are easy; a plucked low E with a strong second harmonic is the real test.</li>
<li><strong>Check the extremes.</strong> The bass low E (â‰ˆ 41 Hz), any drop tunings you offer, and the guitar high E's upper frets.</li>
<li><strong>Use the measurement mode.</strong> <code>.measurement</code> avoids system processing colouring the signal.</li>
<li><strong>Tune the noise gate on several devices</strong>, in a quiet room and a noisy one.</li>
<li><strong>Interpolate the peak.</strong> Without sub-sample accuracy, readings jump in multi-cent steps on higher notes.</li>
<li><strong>Smooth, but reset.</strong> Use a median plus an exponential average, and reset on silence and on a big jump.</li>
<li><strong>Measure "nearest string" in cents</strong>, never in Hz.</li>
<li><strong>Release the mic when you're not tuning.</strong> Stop the engine, remove the tap and deactivate the session when the screen goes away or the app backgrounds.</li>
<li><strong>Plan for iOS 27's tap API.</strong> Wrap the tap in one place, so moving to <code>installAudioTap</code> is a one-file change.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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 <code>vDSP</code> calls, and SwiftUI does the rest.</p>
<p>Try the app, or see the rest of my work:</p>
<ul>
<li><strong>Guitar Tuner: Chromatic (iOS):</strong> <a href="https://apps.apple.com/us/app/guitar-tuner-chromatic/id1518293355">https://apps.apple.com/us/app/guitar-tuner-chromatic/id1518293355</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>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.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-a-chromatic-guitar-tuner-on-ios/">https://bestlatestapps.com/blog/building-a-chromatic-guitar-tuner-on-ios/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building a Hashtag Copier for iOS: Clean Tags, Editable Lists, Clipboard Privacy and Instagram's New Five-Hashtag Limit]]></title><description><![CDATA[How to normalise tags so they actually work, model bundled categories with user edits, copy to the pasteboard without tripping privacy prompts, and…]]></description><link>https://aryansbtloe.hashnode.dev/building-a-hashtag-copier-for-ios-clean-tags-editable-lists-clipboard-privacy-and-instagram-s-new-five-hashtag-limit</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-a-hashtag-copier-for-ios-clean-tags-editable-lists-clipboard-privacy-and-instagram-s-new-five-hashtag-limit</guid><category><![CDATA[Swift]]></category><category><![CDATA[SwiftUI]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:07:43 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-hashtag-copier-for-ios/hashtags-creator-icon-512.jpg" alt="Hashtags Creator app icon" /></p>
<p><strong>Hashtags Creator</strong> 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.</p>
<p>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 <strong>text that has rules</strong>: a hashtag with a space or a <code>$</code> in it silently stops being a hashtag. The second is <strong>the pasteboard</strong>, which has become one of iOS's most privacy-sensitive APIs. The third is <strong>platform rules that change under you</strong>: 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.</p>
<p>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.</p>
<hr />
<h2>1. Normalise tags so they actually work</h2>
<p>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 <code>#Travel Photography</code>, <code>sunset!!</code> or <code>#cafÃ©</code>. So every tag should pass through one normaliser before it's stored or copied:</p>
<pre><code class="language-swift">import Foundation

enum Hashtag {
    /// Turns user input into a usable tag, or nil if nothing usable remains.
    /// "Travel Photography!" -&gt; "#travelphotography", "#cafÃ©" -&gt; "#cafÃ©", "$$$" -&gt; nil
    static func normalize(_ raw: String) -&gt; 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) -&gt; [String] {
        var seen = Set&lt;String&gt;()
        return text
            .components(separatedBy: CharacterSet.whitespacesAndNewlines.union(.init(charactersIn: ",;")))
            .compactMap(normalize)
            .filter { seen.insert($0).inserted }
    }
}
</code></pre>
<p>Three decisions worth explaining:</p>
<ul>
<li><strong><code>Character.isLetter</code> instead of <code>[a-z]</code>.</strong> Hashtags work in Hindi, Japanese and Arabic, and an ASCII-only filter would quietly delete them. Swift's <code>Character</code> properties are Unicode-aware.</li>
<li><strong>Lowercase for de-duplication.</strong> <code>#Travel</code> and <code>#travel</code> are the same tag to the platform, and keeping both wastes a slot. If you want to keep readable casing, like <code>#TravelTips</code>, store the display form and de-duplicate on the lowercased form.</li>
<li><strong>Allowing underscores.</strong> These are widely used in tags. Instagram's help page only calls out spaces and symbols like <code>$</code> and <code>%</code>, so test any other character you allow.</li>
</ul>
<hr />
<h2>2. Bundled categories plus the user's edits</h2>
<p>The listing promises "customize your lists, delete irrelevant tags". The clean way to model that is <strong>bundled defaults + a per-user overlay</strong>. You can then ship updated categories in an app update without wiping anyone's edits:</p>
<pre><code class="language-swift">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&lt;String&gt;] = [:]
    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) -&gt; [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) &lt; (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)
    }
}
</code></pre>
<p>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.</p>
<hr />
<h2>3. An editable list in SwiftUI</h2>
<p>With the library in place, the editing screen is mostly built-in SwiftUI behaviour: swipe to delete, an <code>EditButton</code> for reordering, and a field that accepts one tag or a pasted batch.</p>
<pre><code class="language-swift">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 = "" }
}
</code></pre>
<p>A note on versions. The shipped app supports <strong>iOS 12</strong>, 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.</p>
<hr />
<h2>4. Copying to the pasteboard, carefully</h2>
<p>Copying is one line, <code>UIPasteboard.general.string = text</code>, but <code>setItems(_:options:)</code> (iOS 10+) gives you two privacy options worth knowing about:</p>
<pre><code class="language-swift">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)
    }
}
</code></pre>
<ul>
<li><strong><code>.localOnly</code></strong> 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.</li>
<li><strong><code>.expirationDate</code></strong> 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.</li>
<li><strong>Always confirm the copy.</strong> A haptic plus a brief "Copied 5 tags" banner. With no visual change, users tap "Copy" three times to be sure.</li>
</ul>
<hr />
<h2>5. Reading the pasteboard: privacy prompts and how to avoid them</h2>
<p><em>Writing</em> to the pasteboard is silent. <em>Reading</em> 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 <code>UIPasteboard</code> documentation lists the properties you can use to avoid those prompts when the user hasn't shown intent, such as <code>hasStrings</code>, along with the pattern-detection methods.</p>
<p>So, for an "import tags from clipboard" feature:</p>
<pre><code class="language-swift">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.
</code></pre>
<p>The design rule I follow: <strong>never read the pasteboard on launch or on appear.</strong> 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 <code>PasteButton</code> or <code>UIPasteControl</code>, which carry the user's intent with them.</p>
<hr />
<h2>6. When the platform changes the rules: five hashtags</h2>
<p>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.)</p>
<p>For a hashtag app, that changes the core interaction from "copy the whole category" to <strong>"choose your five"</strong>:</p>
<pre><code class="language-swift">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 &lt; 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 &gt;= Self.limit &amp;&amp; !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)
    }
}
</code></pre>
<p>Lessons that apply well beyond hashtags:</p>
<ul>
<li><strong>Keep platform limits in one constant</strong>, ideally one you can update from a small remote config, not scattered as <code>30</code>s through the code.</li>
<li><strong>Make the limit visible.</strong> "Copy 3 of 5" teaches the rule without a tutorial.</li>
<li><strong>Lean into the new guidance.</strong> "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.</li>
</ul>
<hr />
<h2>7. Real-world example: Hashtags Creator</h2>
<p>All of this is grounded in <strong><a href="https://apps.apple.com/us/app/hashtags-creator/id1522156509">Hashtags Creator</a></strong>. The code above is the approach I'd use, written for this article, not the app's source.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-hashtag-copier-for-ios/hashtags-creator-screenshot-1.jpg" alt="Hashtags Creator App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Social Networking</strong> and <strong>Photo &amp; Video</strong></li>
<li>First released in <strong>July 2020</strong></li>
<li><strong>2 ratings</strong> in the US store, both <strong>5 stars</strong></li>
<li>Supports <strong>iOS 12.0 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"most popular tags that could help you gain likes and followers"</td>
<td>Bundled categories (section 2)</td>
</tr>
<tr>
<td>"You can customize your lists, delete irrelevant tags"</td>
<td>User-edit overlay and an editable list (sections 2â€“3)</td>
</tr>
<tr>
<td>"copy and paste tags quickly"</td>
<td>Pasteboard writes with options (section 4)</td>
</tr>
<tr>
<td>"Just select your category and tags are copied and ready to get posted in Instagram"</td>
<td>The copy flow, and the five-tag redesign (sections 4 and 6)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-hashtag-copier-for-ios/hashtags-creator-screenshot-2.jpg" alt="Hashtags Creator App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>8. Before you ship a text-and-clipboard utility: a checklist</h2>
<ol>
<li><strong>Every tag passes through one normaliser</strong>, which handles non-Latin scripts, emoji input and pasted batches.</li>
<li><strong>De-duplication is case-insensitive.</strong></li>
<li><strong>User edits live in an overlay</strong>, so content updates never wipe them.</li>
<li><strong>There's no pasteboard read on launch or on appear.</strong> Imports use <code>PasteButton</code> or <code>UIPasteControl</code>.</li>
<li><strong>Copies are confirmed</strong> with haptics and a visible message.</li>
<li><strong>Platform limits are a single, updatable constant</strong>, and the UI shows them.</li>
<li><strong>Test the real destination.</strong> Paste into Instagram (or wherever you target) on a current build, because platform rules move.</li>
<li><strong>Accessibility.</strong> VoiceOver reads tags sensibly; consider camel-casing display forms like <code>#TravelTips</code> for readability.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Try the app, or explore my other work:</p>
<ul>
<li><strong>Hashtags Creator (iOS):</strong> <a href="https://apps.apple.com/us/app/hashtags-creator/id1522156509">https://apps.apple.com/us/app/hashtags-creator/id1522156509</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>Has a platform rule change ever forced a redesign of your app? Tell me about it in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-a-hashtag-copier-for-ios/">https://bestlatestapps.com/blog/building-a-hashtag-copier-for-ios/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building a Live Temple Darshan App for iOS: HLS Streaming, Picture-in-Picture, Time-Zone-Correct Aarti Reminders and Notification Actions]]></title><description><![CDATA[Playing live and recorded video with AVPlayer, keeping darshan going in picture-in-picture, falling back gracefully when a stream is down, scheduling aarti…]]></description><link>https://aryansbtloe.hashnode.dev/building-a-live-temple-darshan-app-for-ios-hls-streaming-picture-in-picture-time-zone-correct-aarti-reminders-and-notification-actions</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-a-live-temple-darshan-app-for-ios-hls-streaming-picture-in-picture-time-zone-correct-aarti-reminders-and-notification-actions</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[AVFoundation]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:07:15 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-live-temple-darshan-app-for-ios/mandir-darshan-icon-512.jpg" alt="Mandir Darshan app icon" /></p>
<p>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 <strong>Mandir Darshan</strong>, 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.</p>
<p>I've written before about the offline audio side of devotional apps, in my Hanuman Chalisa article. This one is about the <strong>live</strong> side, which turns out to be a tour of some of the most practical media and notification APIs on iOS:</p>
<ul>
<li>streaming HLS video with <code>AVPlayer</code></li>
<li><strong>picture-in-picture</strong>, so darshan keeps playing while you use other apps</li>
<li>falling back to recorded video when a live stream is unavailable</li>
<li><strong>aarti reminders that fire at the right moment wherever the devotee lives</strong></li>
<li>notification actions, a next-aarti widget, and opening a temple in Apple Maps with the new iOS 26 MapKit initialisers</li>
</ul>
<p>The APIs are checked against Apple's documentation. Where I describe how I'd build something rather than the shipped code, I say so.</p>
<hr />
<h2>1. Model temples around their own time zone</h2>
<p>The most important design decision isn't about video at all. <strong>Aarti times belong to the temple, not the phone.</strong> 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 <em>plus</em> the temple's time zone:</p>
<pre><code class="language-swift">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) -&gt; Date? {
        var calendar = Calendar(identifier: .gregorian)
        calendar.timeZone = timeZone
        return calendar.nextDate(after: now,
                                 matching: DateComponents(hour: aarti.hour, minute: aarti.minute),
                                 matchingPolicy: .nextTime)
    }
}
</code></pre>
<p>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.</p>
<hr />
<h2>2. Playing live darshan with AVPlayer</h2>
<p>Live temple streams are typically delivered as <strong>HLS</strong>, which <code>AVPlayer</code> plays natively. <code>AVPlayerViewController</code> gives you the system controls, AirPlay and picture-in-picture support for free:</p>
<pre><code class="language-swift">import AVKit
import SwiftUI

struct DarshanPlayer: UIViewControllerRepresentable {
    let player: AVPlayer

    func makeUIViewController(context: Context) -&gt; 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) -&gt; 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
}
</code></pre>
<p>Three things are essential for picture-in-picture to work at all:</p>
<ol>
<li><strong>The audio session category is <code>.playback</code>.</strong> With the default category, PiP won't start.</li>
<li><strong>The Background Modes capability includes "Audio, AirPlay, and Picture in Picture".</strong> Without it, the video stops when the app leaves the foreground.</li>
<li><strong>The player is shown in <code>AVPlayerViewController</code></strong> (or an <code>AVPlayerLayer</code> managed by <code>AVPictureInPictureController</code>, iOS 9+, if you build custom controls).</li>
</ol>
<p><code>canStartPictureInPictureAutomaticallyFromInline</code> is the one that makes PiP feel effortless. Swipe home during darshan, and the video floats instead of stopping.</p>
<hr />
<h2>3. Bringing the app back from picture-in-picture</h2>
<p>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:</p>
<pre><code class="language-swift">final class PlayerCoordinator: NSObject, AVPlayerViewControllerDelegate {
    var restore: (() -&gt; Void)?

    func playerViewController(_ playerViewController: AVPlayerViewController,
        restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -&gt; 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) -&gt; Bool {
        false
    }
}
</code></pre>
<p>The approach I'd use is to keep the active <code>AVPlayer</code> 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 <em>same</em> player without a reload.</p>
<hr />
<h2>4. When the live stream is down: recorded darshan and playback speed</h2>
<p>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 <code>status</code> and fall back:</p>
<pre><code class="language-swift">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)
    }
}
</code></pre>
<p>Playback speed only makes sense for recorded video. On iOS 16+, <code>AVPlayerViewController.speeds</code> (an array of <code>AVPlaybackSpeed</code>) controls the speed menu, and <code>AVPlayer.defaultRate</code> sets the rate that <code>play()</code> uses. For a live stream, I'd set <code>speeds = []</code> so the menu disappears. Fast-forwarding past "now" isn't possible, and showing the control would only confuse.</p>
<hr />
<h2>5. Aarti reminders that are right everywhere</h2>
<p>"Aarti schedules with reminders 5, 10, 15, or 30 minutes before." This is where the temple-time-zone model pays off. <code>UNCalendarNotificationTrigger</code> (iOS 10+) matches date components, and those components can carry their own time zone:</p>
<pre><code class="language-swift">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)
    }
}
</code></pre>
<p>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).</p>
<p>Practical limits:</p>
<ul>
<li><strong>Use stable identifiers.</strong> 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.</li>
<li><strong>Watch the pending-notification cap.</strong> 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.</li>
<li><strong>Time-sensitive interruption</strong> (<code>interruptionLevel = .timeSensitive</code>, iOS 15+) can break through Focus modes, but it requires the Time Sensitive Notifications capability and should be the user's choice.</li>
</ul>
<hr />
<h2>6. "Watch Now" and "Remind Later"</h2>
<p>The listing mentions two notification actions, "Watch Now" and "Remind Later". Both are a <code>UNNotificationCategory</code> registered at launch, plus a delegate that handles the response:</p>
<pre><code class="language-swift">import UserNotifications

final class NotificationRouter: NSObject, UNUserNotificationCenterDelegate {
    var openTemple: ((String) -&gt; 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 -&gt; UNNotificationPresentationOptions {
        [.banner, .sound]
    }
}
</code></pre>
<p><code>.foreground</code> on "Watch Now" opens the app; "Remind Later" works without opening it. Set the delegate <strong>before</strong> the app finishes launching (in the app delegate), or the response to a notification that launched the app can be missed.</p>
<hr />
<h2>7. A next-aarti widget and opening Maps</h2>
<p>The same <code>nextOccurrence(of:)</code> function from section 1 powers a "next aarti" widget. A WidgetKit <code>TimelineProvider</code> (iOS 14+) can return one entry per upcoming aarti, so the widget updates itself at each one without the app running:</p>
<pre><code class="language-swift">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) -&gt; Timeline&lt;NextAartiEntry&gt; {
    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 &lt; $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))
}
</code></pre>
<p>"Open in Maps" changed in iOS 26. Apple's documentation marks <code>MKMapItem.init(placemark:)</code> as <strong>deprecated in iOS 26</strong>, with new initialisers built on <code>CLLocation</code> and the new <code>MKAddress</code> type. Since the app supports iOS 17.6, I'd branch:</p>
<pre><code class="language-swift">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+
}
</code></pre>
<hr />
<h2>8. Real-world example: Mandir Darshan</h2>
<p>This article comes from building <strong><a href="https://apps.apple.com/us/app/mandir-darshan/id6756669016">Mandir Darshan</a></strong>. The code above is the approach I'd use, written for this article, not a copy of the app's source.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-live-temple-darshan-app-for-ios/mandir-darshan-screenshot-1.jpg" alt="Mandir Darshan App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Lifestyle</strong> and <strong>Utilities</strong></li>
<li>First released in <strong>February 2026</strong></li>
<li><strong>1 rating</strong> in the US store, <strong>5 stars</strong></li>
<li>Supports <strong>iOS 17.6 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Watch live streaming from famous temples across India"</td>
<td>HLS with <code>AVPlayer</code> (section 2)</td>
</tr>
<tr>
<td>"picture-in-picture so you can keep darshan while using other apps"</td>
<td>PiP setup and UI restore (sections 2â€“3)</td>
</tr>
<tr>
<td>"Recorded videos when live is unavailable; playback speed control"</td>
<td>Status-based fallback and <code>speeds</code> (section 4)</td>
</tr>
<tr>
<td>"Aarti schedules with reminders 5, 10, 15, or 30 minutes before"</td>
<td>Time-zone-aware calendar triggers (section 5)</td>
</tr>
<tr>
<td>"Notification actions: Watch Now or Remind Later"</td>
<td>Categories and the delegate (section 6)</td>
</tr>
<tr>
<td>"Home Screen widgets: Live Darshan (live count/next aarti)â€¦"</td>
<td>A next-aarti timeline (section 7)</td>
</tr>
<tr>
<td>"Share temple or open in Maps"</td>
<td><code>MKMapItem</code> with the iOS 26 branch (section 7)</td>
</tr>
<tr>
<td>"Data stored on your device; no sign-in required"</td>
<td>Everything here is local: notifications, favourites, widget data</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-live-temple-darshan-app-for-ios/mandir-darshan-screenshot-2.jpg" alt="Mandir Darshan App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>9. Before you ship a live-streaming devotional app: a checklist</h2>
<ol>
<li><strong>Times belong to the temple.</strong> Every schedule stores its own time zone, and reminders are tested with the phone set to the US and UK.</li>
<li><strong>PiP works.</strong> The audio session is <code>.playback</code>, the background mode is on, automatic PiP from inline is set, and UI restore is implemented.</li>
<li><strong>Stream failure is a state, not an error.</strong> There's a recorded fallback and a clear "Live" or "Recorded" badge.</li>
<li><strong>There's no speed control on live.</strong></li>
<li><strong>Reminders use stable IDs and repeating triggers</strong>, and stay under the pending limit.</li>
<li><strong>The notification delegate is set at launch</strong>, and "Watch Now" deep-links into the right temple.</li>
<li><strong>The widget timeline advances at each aarti</strong> and has a sensible refresh policy.</li>
<li><strong>MapKit is branched for iOS 26.</strong> No deprecated initialisers on new systems.</li>
<li><strong>Respect and accuracy.</strong> Temple names, deity information and aarti times are checked, and the app never shows a stream it doesn't have the right to show.</li>
<li><strong>Be mindful of data use.</strong> Live video on cellular can be expensive; consider a Wi-Fi-only option.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Try the app, or explore my other work:</p>
<ul>
<li><strong>Mandir Darshan (iOS):</strong> <a href="https://apps.apple.com/us/app/mandir-darshan/id6756669016">https://apps.apple.com/us/app/mandir-darshan/id6756669016</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>Which temple's darshan would you most like to see in the app? Let me know in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-a-live-temple-darshan-app-for-ios/">https://bestlatestapps.com/blog/building-a-live-temple-darshan-app-for-ios/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building a Live iOS Component Catalogue: UIKit and SwiftUI Side by Side, Real Demos, and Code Snippets That Don't Rot]]></title><description><![CDATA[One registry for every component, hosting UIKit inside SwiftUI with UIViewRepresentable, wrapping the system pickers, comparing UIKit and SwiftUI versions…]]></description><link>https://aryansbtloe.hashnode.dev/building-a-live-ios-component-catalogue-uikit-and-swiftui-side-by-side-real-demos-and-code-snippets-that-don-t-rot</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-a-live-ios-component-catalogue-uikit-and-swiftui-side-by-side-real-demos-and-code-snippets-that-don-t-rot</guid><category><![CDATA[Swift]]></category><category><![CDATA[SwiftUI]]></category><category><![CDATA[iOS]]></category><category><![CDATA[UIkit]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:06:47 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-live-ios-component-catalogue/catalog-ui-icon-512.jpg" alt="Catalog" /></p>
<p>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.</p>
<p><strong>Catalog - UI</strong> 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.</p>
<p>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.</p>
<hr />
<h2>1. One registry to rule them all</h2>
<p>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:</p>
<pre><code class="language-swift">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&lt;CatalogTag&gt;
    let uiKitDemo: (() -&gt; UIView)?              // nil if SwiftUI-only
    let swiftUIDemo: (() -&gt; 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)
    }
}
</code></pre>
<p>Adding a component is now one entry, and every screen (list, search, favourites, detail) works from this one array. The <code>uiKitDemo</code> and <code>swiftUIDemo</code> closures keep construction lazy, so nothing is built until you open it.</p>
<hr />
<h2>2. Hosting UIKit demos in a SwiftUI shell</h2>
<p>I'd write the shell (navigation, lists, search) in SwiftUI and host UIKit demos inside it. <code>UIViewRepresentable</code> (iOS 13+) does the bridging, and iOS 16's <code>sizeThatFits(_:uiView:context:)</code> finally lets a UIKit view tell SwiftUI how big it wants to be:</p>
<pre><code class="language-swift">import SwiftUI

struct UIKitDemoHost: UIViewRepresentable {
    let make: () -&gt; UIView

    func makeUIView(context: Context) -&gt; 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) -&gt; 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))
    }
}
</code></pre>
<p>Without <code>sizeThatFits</code>, 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.</p>
<p>If your shell is UIKit instead, the mirror image is <code>UIHostingController</code> (iOS 13+) for embedding the SwiftUI demos. Both directions are first-class.</p>
<hr />
<h2>3. UIKit and SwiftUI, side by side</h2>
<p>"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:</p>
<pre><code class="language-swift">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&lt;Content: View&gt;: 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))
        }
    }
}
</code></pre>
<p>Seeing them together teaches things documentation doesn't. A SwiftUI <code>Slider</code> picks up the accent colour, while <code>UISlider</code> has its own tint properties. And SwiftUI's <code>Toggle</code> and UIKit's <code>UISwitch</code> look the same, but they differ in how labels and accessibility are attached.</p>
<hr />
<h2>4. The pickers are view controllers, so wrap them properly</h2>
<p>Colour, font and image pickers in UIKit aren't views; they're view controllers with delegates. <code>UIColorPickerViewController</code> (iOS 14), <code>UIFontPickerViewController</code> (iOS 13) and <code>PHPickerViewController</code> (iOS 14) all follow the same wrapping pattern, <code>UIViewControllerRepresentable</code> plus a coordinator:</p>
<pre><code class="language-swift">import SwiftUI
import UIKit

struct UIKitColorPicker: UIViewControllerRepresentable {
    @Binding var color: Color

    func makeUIViewController(context: Context) -&gt; 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() -&gt; 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)
        }
    }
}
</code></pre>
<p>The SwiftUI parallel is one line, <code>ColorPicker("Tint", selection: $color)</code> (iOS 14), which makes the comparison instructive. The same goes for images: <code>PhotosPicker</code> (iOS 16, PhotosUI) on the SwiftUI side versus <code>PHPickerViewController</code> on the UIKit side. Both run out of process and need <strong>no photo-library permission prompt</strong>, which is a detail worth surfacing in a reference app, because developers still add <code>NSPhotoLibraryUsageDescription</code> "just in case".</p>
<p>For the date picker demo, <code>preferredDatePickerStyle</code> (iOS 13.4) is the property to show off. <code>.compact</code>, <code>.inline</code> and <code>.wheels</code> are almost three different controls.</p>
<hr />
<h2>5. Code snippets that don't rot</h2>
<p>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 <strong>make snippets real Swift files in the project</strong>, compiled as part of a build target, and load their text from the bundle at runtime:</p>
<pre><code class="language-swift">Snippets/
  Slider.UIKit.swift        // compiled in a "Snippets" target; also copied as a resource
  Slider.SwiftUI.swift
</code></pre>
<pre><code class="language-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) -&gt; 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
    }
}
</code></pre>
<p>The build setup: the <code>.swift</code> 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 <code>.swift.txt</code> resources. Then:</p>
<ul>
<li><strong>Every snippet compiles</strong>, because the build proves it on every change.</li>
<li><strong>The demo can call the same code.</strong> The <code>uiKitDemo</code> closure can call a function defined in the snippet file, so what you see is literally what you copy.</li>
<li><strong>A unit test can assert</strong> that every <code>CatalogItem</code> has its snippet files in the bundle.</li>
</ul>
<p>It's a little build plumbing for a lot of trust.</p>
<hr />
<h2>6. Tags, search, favourites and recently viewed</h2>
<p>With everything in one registry, discovery features are a few lines each. Tags are a filter on a <code>Set</code>, search is a case-insensitive match, and favourites and recents are small ID lists in <code>@AppStorage</code>:</p>
<pre><code class="language-swift">struct CatalogList: View {
    @State private var query = ""
    @State private var selectedTag: CatalogTag?
    @AppStorage("favorites") private var favoritesRaw = ""

    private var favorites: Set&lt;String&gt; { Set(favoritesRaw.split(separator: ",").map(String.init)) }

    private var results: [CatalogItem] {
        Catalog.all.filter { item in
            (selectedTag.map { item.tags.contains($0) } ?? true) &amp;&amp;
            (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)
    }
}
</code></pre>
<p>Storing <strong>IDs</strong>, 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.</p>
<hr />
<h2>7. The interesting new entry: WebView</h2>
<p>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 <code>WKWebView</code> (iOS 8) in a <code>UIViewRepresentable</code>. Apple's current documentation lists a native SwiftUI <strong><code>WebView</code></strong> and a <strong><code>WebPage</code></strong> model in WebKit, both <strong>iOS 26</strong>:</p>
<pre><code class="language-swift">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) -&gt; WKWebView {
        let web = WKWebView()
        web.load(URLRequest(url: url))
        return web
    }
    func updateUIView(_ uiView: WKWebView, context: Context) {}
}
</code></pre>
<p>This is exactly what a catalogue is for. The same component now has three entries worth comparing: UIKit <code>WKWebView</code>, the wrapped version, and the native SwiftUI view, each behind the right availability check.</p>
<hr />
<h2>8. Real-world example: Catalog - UI</h2>
<p>This architecture comes from building <strong><a href="https://apps.apple.com/us/app/catalog-ui/id6446035510">Catalog - UI</a></strong>. The code above is the approach I'd use, written for this article, not a copy of the shipped source.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-live-ios-component-catalogue/catalog-ui-screenshot-1.jpg" alt="Catalog" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Reference</strong> and <strong>Education</strong></li>
<li>First released in <strong>March 2023</strong></li>
<li><strong>2 ratings</strong> in the US store, averaging <strong>3 stars</strong></li>
<li>Supports <strong>iOS 17.6 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Controls â€” Buttons, menus, segmented controls, sliders, switchesâ€¦"</td>
<td>The registry (section 1)</td>
</tr>
<tr>
<td>"Tap any component to see a live demo"</td>
<td>UIKit hosting with <code>sizeThatFits</code> (section 2)</td>
</tr>
<tr>
<td>"Compare UIKit and SwiftUI side by side" / "Parallel demos for key SwiftUI components"</td>
<td>Two-pane detail (section 3)</td>
</tr>
<tr>
<td>"Pickers â€” Date picker, color picker, font picker, image picker, custom picker view"</td>
<td>View-controller pickers wrapped with coordinators (section 4)</td>
</tr>
<tr>
<td>"View code snippets and copy them into your project"</td>
<td>Compiled snippet files (section 5)</td>
</tr>
<tr>
<td>"Filter by tagâ€¦", "Search the full catalogue by name", "Favorites andâ€¦ Recently Viewed"</td>
<td>Discovery features (section 6)</td>
</tr>
<tr>
<td>"Views â€” â€¦ visual effects, WebView"</td>
<td><code>WKWebView</code> vs SwiftUI <code>WebView</code> (section 7)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-live-ios-component-catalogue/catalog-ui-screenshot-2.jpg" alt="Catalog" /></p>
<p>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.</p>
<hr />
<h2>9. Before you ship a reference or catalogue app: a checklist</h2>
<ol>
<li><strong>One registry drives every screen.</strong> There's no per-component switch statements.</li>
<li><strong>Bridged UIKit views size correctly.</strong> <code>sizeThatFits</code> is implemented and checked in Dynamic Type sizes.</li>
<li><strong>Pickers use coordinators</strong> and report continuous changes where the API offers them.</li>
<li><strong>Snippets compile in CI</strong>, and a test checks every item has its snippet.</li>
<li><strong>Availability is explicit.</strong> iOS 26-only demos (like SwiftUI <code>WebView</code>) are gated and labelled.</li>
<li><strong>Favourites and recents store IDs</strong>, capped in size.</li>
<li><strong>Every demo works in Dark Mode, at large text sizes and with VoiceOver.</strong> A reference app should model good behaviour.</li>
<li><strong>No account and no tracking</strong> are needed for a reference tool.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Try the app, or explore my other work:</p>
<ul>
<li><strong>Catalog - UI (iOS):</strong> <a href="https://apps.apple.com/us/app/catalog-ui/id6446035510">https://apps.apple.com/us/app/catalog-ui/id6446035510</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>Which component should every iOS catalogue include that most leave out? Tell me in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-a-live-ios-component-catalogue/">https://bestlatestapps.com/blog/building-a-live-ios-component-catalogue/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building a Low-Latency Virtual Piano and Drum Kit on iOS with AVAudioEngine]]></title><description><![CDATA[Samplers for pitched instruments, a player-node pool for drums, a multi-touch keyboard that handles chords and glissandos, and note names for beginners The…]]></description><link>https://aryansbtloe.hashnode.dev/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioengine</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioengine</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOSAVFoundation]]></category><category><![CDATA[audio]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:06:18 GMT</pubDate><content:encoded><![CDATA[<p><em>Samplers for pitched instruments, a player-node pool for drums, a multi-touch keyboard that handles chords and glissandos, and note names for beginners</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/drums-n-piano-icon-512.jpg" alt="Drums n Piano app icon" /></p>
<p>The test for a virtual instrument is brutal and immediate: tap a key, and the sound has to arrive <em>with</em> 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.</p>
<p>My app <strong>Drums n Piano</strong> 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 &amp; positions of notes on the piano".</p>
<p>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 <code>AVAudioUnitSampler</code> for pitched instruments, a pool of <code>AVAudioPlayerNode</code>s 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.</p>
<hr />
<h2>1. The three things a virtual instrument must get right</h2>
<ol>
<li><strong>Latency.</strong> 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.</li>
<li><strong>Polyphony.</strong> Several sounds at once: chords on the piano, overlapping cymbal and kick, a fast roll on the snare.</li>
<li><strong>Touch handling.</strong> Many fingers at once, sliding between keys, and no gesture delays.</li>
</ol>
<p>None of these are hard individually. They get hard when the first prototype plays a sound file with <code>AVAudioPlayer</code> 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.</p>
<hr />
<h2>2. Session setup for an instrument</h2>
<pre><code class="language-swift">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")
    }
}
</code></pre>
<p>Two decisions here deserve a sentence each:</p>
<ul>
<li><strong><code>.playback</code> vs <code>.ambient</code>.</strong> <code>.ambient</code> 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 <code>.playback</code>.</li>
<li><strong>A small preferred buffer</strong> reduces output latency. It's a <em>preference</em>: read back <code>ioBufferDuration</code> 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.</li>
</ul>
<hr />
<h2>3. Pitched instruments: AVAudioUnitSampler</h2>
<p>For piano and xylophone, you don't want one audio file per tap. You want a <strong>sampler</strong>: an instrument that receives MIDI-style "note on/off" messages and plays the right sample, pitch-shifting between recorded notes if needed. <code>AVAudioUnitSampler</code> (iOS 8+) is Apple's sampler, wrapped as an engine node.</p>
<pre><code class="language-swift">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)
    }
}
</code></pre>
<p>Why this is the right tool:</p>
<ul>
<li><strong>Polyphony is built in.</strong> Ten fingers, ten notes. The sampler manages voices for you.</li>
<li><strong>Note-off is real.</strong> Releasing a key triggers the sample's release, as on a real piano, rather than a hard cut.</li>
<li><strong>Loading happens once</strong>, at launch or when the user switches instrument, never on the tap.</li>
</ul>
<p>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 <code>loadAudioFiles(at:)</code>. 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.</p>
<hr />
<h2>4. Drums: preloaded buffers and a player-node pool</h2>
<p>Drums are different. There's no pitch, every hit is a one-shot, and the same pad is hit repeatedly and fast. You <em>could</em> map drums onto a sampler (General MIDI percussion is exactly that), but a simple, very controllable approach is:</p>
<ol>
<li>Decode each drum sample into an <code>AVAudioPCMBuffer</code> <strong>once</strong>, at launch.</li>
<li>Keep a small <strong>pool</strong> of <code>AVAudioPlayerNode</code>s per pad.</li>
<li>On each hit, schedule the buffer on the next node in the pool, round-robin.</li>
</ol>
<pre><code class="language-swift">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..&lt;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)
    }
}
</code></pre>
<p>The whole setup:</p>
<pre><code class="language-swift">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()
</code></pre>
<p>Why a pool? A single player node plays one schedule queue. Scheduling a new hit with <code>.interrupts</code> on a <em>single</em> 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 <code>.caf</code> or <code>.wav</code> so decoding at launch is quick and exact. They're short, so the size cost is small.</p>
<hr />
<h2>5. A multi-touch keyboard that feels like a keyboard</h2>
<p>This is where many SwiftUI prototypes fall short. A <code>Button</code> per key fires on touch-<em>up</em>, which is far too late for an instrument. A <code>DragGesture</code> per key doesn't hand off to the next key when the finger slides. And coordinating several simultaneous fingers through SwiftUI gestures is awkward.</p>
<p>The direct approach is one UIKit view for the whole keyboard that tracks every <code>UITouch</code> itself:</p>
<pre><code class="language-swift">import UIKit

final class KeyboardView: UIView {
    var firstMidi: UInt8 = 60           // C4
    var octaves = 2
    var onNoteOn: ((UInt8) -&gt; Void)?
    var onNoteOff: ((UInt8) -&gt; 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&lt;UITouch&gt;, with event: UIEvent?) {
        for t in touches { press(t) }
    }

    override func touchesMoved(_ touches: Set&lt;UITouch&gt;, 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&lt;UITouch&gt;, with event: UIEvent?) {
        for t in touches { release(t) }
    }

    override func touchesCancelled(_ touches: Set&lt;UITouch&gt;, 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) -&gt; UInt8? {
        guard bounds.contains(p) else { return nil }
        let w = whiteKeyWidth
        if p.y &lt; bounds.height * 0.6 {
            for octave in 0..&lt;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]
    }
}
</code></pre>
<p>The essential details:</p>
<ul>
<li><strong><code>isMultipleTouchEnabled = true</code>.</strong> It's <code>false</code> by default, and without it you'll only ever get one finger.</li>
<li><strong>Track notes per touch</strong> with <code>ObjectIdentifier(touch)</code>. Each finger owns its note, so chords and glissandos work independently.</li>
<li><strong>Handle <code>touchesCancelled</code>.</strong> A system gesture or an incoming call can cancel touches. If you don't release notes there, you get stuck notes.</li>
<li><strong>Black keys are hit-tested first</strong>, because they're drawn on top.</li>
</ul>
<p>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 <code>UIViewRepresentable</code>. Touch handling for an instrument is one of the places where UIKit is still simply the better tool.</p>
<p>Drum pads follow the same idea in simpler form: a grid of rectangles, <code>touchesBegan</code> triggers <code>hit(_:)</code>, and there's no note-off.</p>
<hr />
<h2>6. Teaching note names and positions</h2>
<p>The listing describes the app as "teaching beginners the names &amp; 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.</p>
<pre><code class="language-swift">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) -&gt; 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"
</code></pre>
<p>In <code>draw(_:)</code>, 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.</p>
<hr />
<h2>7. Real-world example: Drums n Piano</h2>
<p>This is the approach I'd use, and recommend, for <strong><a href="https://apps.apple.com/us/app/drums-n-piano/id1642061667">Drums n Piano</a></strong>.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/drums-n-piano-screenshot-1.jpg" alt="Drums n Piano App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Music</strong> and <strong>Entertainment</strong></li>
<li>First released in <strong>September 2022</strong></li>
<li><strong>9 ratings</strong> in the US store, averaging about <strong>4.4 stars</strong></li>
<li>Supports <strong>iOS 12.0 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Simply play and learn the piano, drums or xylophone wherever you are"</td>
<td>A sampler per pitched instrument; a player-node pool for drums (sections 3â€“4)</td>
</tr>
<tr>
<td>"This virtual app sounds real, just like the original instrument!" / "Premium sound quality"</td>
<td>Real samples loaded once, a low-latency session, natural note-off releases (sections 2â€“3)</td>
</tr>
<tr>
<td>"Teaching beginners the names &amp; positions of notes on the piano"</td>
<td>Note labels and pressed-key highlighting (section 6)</td>
</tr>
<tr>
<td>"Play or learn your music chords"</td>
<td>Per-touch tracking with multi-touch enabled (section 5)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/drums-n-piano-screenshot-2.jpg" alt="Drums n Piano App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>8. Before you ship a virtual instrument: a checklist</h2>
<ol>
<li><strong>Nothing loads on tap.</strong> Samples and sound banks load at launch or on instrument switch; taps only trigger notes.</li>
<li><strong>The session is <code>.playback</code></strong> (or a deliberate <code>.ambient</code>), with a small preferred buffer, and you've logged what you actually got.</li>
<li><strong>Polyphony is tested.</strong> Ten-finger chords on the piano; rapid repeated hits on every drum pad.</li>
<li><strong>There are no stuck notes.</strong> Test cancelled touches, a phone call mid-chord, backgrounding mid-note and Control Center swipes.</li>
<li><strong><code>isMultipleTouchEnabled</code> is on</strong>, and each touch owns its note.</li>
<li><strong>Glissando works</strong>: sliding across keys releases and presses notes as you go.</li>
<li><strong>Sample licences are cleared</strong> for everything you bundle.</li>
<li><strong>You've tested on real hardware with wired and Bluetooth audio</strong>, and the app tells users that wired is tighter.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Play it, or explore my other apps:</p>
<ul>
<li><strong>Drums n Piano (iOS):</strong> <a href="https://apps.apple.com/us/app/drums-n-piano/id1642061667">https://apps.apple.com/us/app/drums-n-piano/id1642061667</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>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.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/">https://bestlatestapps.com/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building a Real-Time Mic-to-Speaker App on iOS with AVAudioEngine]]></title><description><![CDATA[Live audio passthrough in Swift: session setup, permissions, feedback, interruptions and route changes, from shipping an app that does exactly this Most…]]></description><link>https://aryansbtloe.hashnode.dev/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[AVFoundation]]></category><category><![CDATA[audio]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:05:49 GMT</pubDate><content:encoded><![CDATA[<p><em>Live audio passthrough in Swift: session setup, permissions, feedback, interruptions and route changes, from shipping an app that does exactly this</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-icon-512.jpg" alt="Virtual Mic app icon" /></p>
<p>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.</p>
<p>That in-between case is exactly what my app <strong>Mic to Speaker â€“ Virtual Mic</strong> 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.</p>
<p>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.</p>
<p>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.</p>
<hr />
<h2>1. The problem: what "mic to speaker" really needs</h2>
<p>On paper the requirement is one sentence: <em>take audio from the microphone and play it through the speaker, right now.</em> In practice that sentence hides three hard requirements.</p>
<p><strong>Low latency.</strong> 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.</p>
<p><strong>The right output route.</strong> By default, when an iPhone records and plays at the same time, output goes to the <em>receiver</em>, 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.</p>
<p><strong>No howling feedback.</strong> 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.</p>
<p>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.</p>
<hr />
<h2>2. Microphone permission, the modern way</h2>
<p>Before anything else, you need the user's permission to use the microphone. Two things are required.</p>
<p><strong>First, a purpose string.</strong> Add <code>NSMicrophoneUsageDescription</code> to your <code>Info.plist</code>. 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: <em>"Virtual Mic uses the microphone to play your voice live through your speaker or headphones."</em></p>
<p><strong>Second, the request itself.</strong> In iOS 17, Apple moved record permission from <code>AVAudioSession</code> to a new class, <code>AVAudioApplication</code>. The old <code>AVAudioSession.requestRecordPermission(_:)</code> is deprecated as of iOS 17. The new API has both a completion-handler version and an <code>async</code> version:</p>
<pre><code class="language-swift">class func requestRecordPermission() async -&gt; Bool
</code></pre>
<p>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:</p>
<pre><code class="language-swift">import AVFoundation
import UIKit

@MainActor
enum MicPermissionManager {

    /// Prompts if needed. Returns true if we may record.
    static func request() async -&gt; 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)
    }
}
</code></pre>
<p>Three practical notes:</p>
<ul>
<li><strong>Ask at the moment of need</strong>, when the user first taps "start", not at launch.</li>
<li><strong>Denied is permanent from the app's side.</strong> The system won't prompt again, so show a short explanation and a button that calls <code>openSettings()</code>.</li>
<li><strong>Silence is not an error.</strong> 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.</li>
</ul>
<hr />
<h2>3. Configuring AVAudioSession</h2>
<p><code>AVAudioSession</code> is how your app tells iOS what kind of audio it does. Get this wrong and nothing else matters.</p>
<h3>Category and options</h3>
<p>For simultaneous input and output you need the <code>.playAndRecord</code> category. On top of that, the options do most of the heavy lifting:</p>
<ul>
<li><strong><code>.defaultToSpeaker</code></strong>: uses the built-in loudspeaker instead of the earpiece when nothing else is connected. This one option fixes "why is it so quiet?"</li>
<li><strong><code>.allowBluetoothA2DP</code></strong>: 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.</li>
<li><strong><code>.allowBluetoothHFP</code></strong> (formerly <code>.allowBluetooth</code>): lets Bluetooth <em>hands-free</em> devices act as input and output. Apple's current docs mark the old <code>.allowBluetooth</code> name deprecated in favour of <code>.allowBluetoothHFP</code>; on an older SDK, use <code>.allowBluetooth</code>. 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.</li>
<li><strong><code>.allowAirPlay</code></strong>: lets output go to AirPlay devices; for <code>.playAndRecord</code> you must set it explicitly. AirPlay buffers over the network, so expect extra latency you can't control.</li>
</ul>
<h3>Mode</h3>
<p>The mode tunes the session for a use case:</p>
<ul>
<li><strong><code>.default</code></strong>: no special processing; best for music and singing, and required for echo-cancelled input (below).</li>
<li><strong><code>.voiceChat</code></strong>: 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.</li>
<li><strong><code>.measurement</code></strong>: minimises system signal processing for the rawest input.</li>
</ul>
<p>I default to <code>.default</code> and offer voice processing (section 5) as a user choice.</p>
<h3>Buffer duration and sample rate</h3>
<p>You can <em>ask</em> for a smaller I/O buffer and a particular sample rate:</p>
<pre><code class="language-swift">try session.setPreferredIOBufferDuration(0.005)  // a request, not a guarantee
try session.setPreferredSampleRate(48_000)
</code></pre>
<p>These are <em>preferences</em>; the system decides based on hardware and route. After activating, read <code>ioBufferDuration</code> and <code>sampleRate</code> to see what you actually got, and log them. Bluetooth routes often differ from what you asked for.</p>
<h3>Echo-cancelled input (iOS 18.2+)</h3>
<p>Starting with <strong>iOS 18.2</strong>, <code>AVAudioSession</code> has three new members:</p>
<ul>
<li><code>isEchoCancelledInputAvailable</code> â€” whether the device and current setup support it</li>
<li><code>setPrefersEchoCancelledInput(_:)</code> â€” ask for it</li>
<li><code>isEchoCancelledInputEnabled</code> â€” whether the system actually turned it on</li>
</ul>
<p>Per Apple's docs, it works only with <code>.playAndRecord</code> and <code>.default</code> 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.</p>
<h3>Temporary speaker override</h3>
<p>For a "force loudspeaker" toggle, use <code>overrideOutputAudioPort(_:)</code>. It temporarily changes the current route, and the override clears when the route changes again.</p>
<h3>Putting it together</h3>
<pre><code class="language-swift">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)
    }
}
</code></pre>
<hr />
<h2>4. The AVAudioEngine graph</h2>
<p><code>AVAudioEngine</code> lets you connect audio nodes like guitar pedals. For mic-to-speaker the graph is short:</p>
<pre><code class="language-swift">inputNode â†’ [EQ] â†’ [Reverb] â†’ mainMixerNode â†’ outputNode
</code></pre>
<ul>
<li><strong><code>inputNode</code></strong> is the current microphone input.</li>
<li><strong><code>AVAudioUnitEQ</code></strong> gives bass/treble bands and a <code>globalGain</code> boost (documented range âˆ’96 to +24 dB).</li>
<li><strong><code>AVAudioUnitReverb</code></strong> adds room character; <code>wetDryMix</code> runs 0â€“100%, where 0 is fully dry.</li>
<li><strong><code>mainMixerNode</code></strong> is created and wired to <code>outputNode</code> the first time you access it. Its <code>outputVolume</code> (0.0â€“1.0) is a natural master volume.</li>
</ul>
<h3>Format matching</h3>
<p>The most common live-audio crash is a format mismatch. My rule: <strong>use the input node's own format for every connection carrying the mic signal</strong>, 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.</p>
<h3>The complete class</h3>
<p>Here's a minimal, complete engine class: one object that owns the engine, exposes a few controls and starts and stops cleanly.</p>
<pre><code class="language-swift">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 &gt; 0, format.channelCount &gt; 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 }
}
</code></pre>
<p>And using it from a view model:</p>
<pre><code class="language-swift">@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)"
        }
    }
}
</code></pre>
<p>Note <code>audio.volume = 0.3</code>. Starting quiet is a safety feature, not a nicety.</p>
<hr />
<h2>5. Taming feedback and latency</h2>
<p>No single switch removes feedback. There are trade-offs; pick sensible defaults and let the user adjust.</p>
<h3>Headphones vs. speaker</h3>
<p>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.</p>
<p>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.</p>
<h3>Voice processing</h3>
<p><code>setVoiceProcessingEnabled(_:)</code> (on <code>AVAudioIONode</code>, iOS 13+) turns on Apple's voice processing I/O: echo cancellation, automatic gain control and noise suppression, the same family used for calls. <code>isVoiceProcessingAGCEnabled</code> on the input node lets you keep echo cancellation but drop automatic gain.</p>
<ul>
<li><strong>Change it while the engine is stopped.</strong> It reconfigures the I/O unit and the input format can change, which is why <code>LiveAudioEngine</code> rebuilds connections on every <code>start()</code>.</li>
<li><strong>It changes the sound.</strong> Tuned for speech, it can make singing and instruments sound processed, so I make it a user choice.</li>
<li><strong>It's not magic.</strong> It reduces the loop; at high enough volume on the built-in speaker you can still get feedback.</li>
</ul>
<h3>Buffer size trade-offs</h3>
<p>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:</p>
<ul>
<li>Request a small buffer, then read back what you got.</li>
<li>Keep the chain light. Apple's built-in EQ and reverb are fine; heavy custom DSP is where trouble starts.</li>
<li>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.</li>
<li>Accept that Bluetooth and AirPlay add buffering you can't remove. I tell users plainly that wired gives the lowest delay.</li>
</ul>
<p>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.</p>
<hr />
<h2>6. Interruptions and route changes</h2>
<p>This separates a demo from a shipped app. Real phones get calls, alarms, Siri, other apps taking over audio, and headphones unplugged mid-sentence.</p>
<h3>Interruptions</h3>
<p>When another session (say, a phone call) takes over, the system posts <code>AVAudioSession.interruptionNotification</code>. On <code>.began</code>, the system has already stopped your audio; update state and UI. On <code>.ended</code>, the options may include <code>.shouldResume</code>, a hint that resuming without user input is appropriate.</p>
<p>What's new: Apple's latest docs mark <code>.shouldResume</code> deprecated as of iOS 27 in favour of a newer <code>resumptionRecommendationNotification</code>. 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.</p>
<p>For a live mic I'm conservative: auto-resume only if the user was live before <em>and</em> the system says resuming is fine. A mic that silently turns itself back on after a call is a privacy surprise.</p>
<h3>Route changes</h3>
<p><code>AVAudioSession.routeChangeNotification</code> carries a reason. The critical one is <strong><code>.oldDeviceUnavailable</code></strong>: 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 <code>.newDeviceAvailable</code>, keep going but rebuild the graph, since the input format may have changed.</p>
<h3>Media services reset</h3>
<p>Rarely, the media server restarts and the system posts <code>AVAudioSession.mediaServicesWereResetNotification</code>. Every audio object is now invalid: recreate the engine and reconfigure the session. Separately, <code>AVAudioEngine</code> posts <code>.AVAudioEngineConfigurationChange</code> when the hardware I/O format changes; the engine stops, and you reconnect and restart.</p>
<h3>Putting it together</h3>
<pre><code class="language-swift">import AVFoundation

@MainActor
final class AudioEventsHandler {

    private var engine: LiveAudioEngine
    private var wasLiveBeforeInterruption = false
    private var observers: [NSObjectProtocol] = []
    var onStateChange: ((Bool) -&gt; 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 &amp;&amp; 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)
        }
    }
}
</code></pre>
<p>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.</p>
<hr />
<h2>7. Background audio and App Store notes</h2>
<h3>Background audio</h3>
<p>To keep the mic live with the screen locked, add <code>audio</code> to <code>UIBackgroundModes</code> (Xcode: Background Modes â†’ "Audio, AirPlay, and Picture in Picture"). With an active <code>.playAndRecord</code> session and a running engine, iOS keeps the app alive and shows its microphone-in-use indicator, so users always know.</p>
<p>The flip side: <strong>when the user stops, really stop.</strong> Stop the engine and deactivate with <code>.notifyOthersOnDeactivation</code> so paused music and podcasts can resume. Holding the session forever drains battery and blocks other audio.</p>
<h3>App Store review considerations</h3>
<ul>
<li><strong>A specific purpose string</strong>, in the user's words.</li>
<li><strong>Background audio must be real.</strong> Declaring the <code>audio</code> mode means actually producing audible content in the background; an idle app holding the session doesn't qualify.</li>
<li><strong>An obvious "live" state</strong>: a big on/off control and a clear indicator.</li>
<li><strong>Honest privacy.</strong> If you record sessions, say so and say where they're stored, and keep your App Privacy details consistent.</li>
<li><strong>Don't over-promise.</strong> If the app can't act as a system-wide mic for a computer or other apps, don't imply it can.</li>
</ul>
<hr />
<h2>8. Real-world example: Virtual Mic, a.k.a. Mic to Speaker</h2>
<p>Everything above comes from building and maintaining my own apps. The main one is on the App Store as <strong><a href="https://apps.apple.com/us/app/mic-to-speaker-virtual-mic/id1514011963">Mic to Speaker â€“ Virtual Mic</a></strong>.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-screenshot-1.jpg" alt="Virtual Mic App Store screenshot 1" /></p>
<p>At the time of writing, its public App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in the <strong>Music</strong> and <strong>Utilities</strong> categories</li>
<li>First released in <strong>May 2020</strong></li>
<li>About <strong>3,400 ratings</strong> in the US store, averaging around <strong>4.5 stars</strong></li>
<li>Supports <strong>iOS 16.6 and later</strong>, on iPhone and iPad</li>
</ul>
<p>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:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>What makes it work</th>
</tr>
</thead>
<tbody><tr>
<td>"Open the app and tap the screen. That's it!" / "One tap to turn on or off"</td>
<td>Permission request on first tap (section 2), then session + engine start (sections 3â€“4)</td>
</tr>
<tr>
<td>"Connect via Bluetooth, AirPlay, or cable"</td>
<td><code>.playAndRecord</code> with <code>.defaultToSpeaker</code>, <code>.allowBluetoothA2DP</code>, <code>.allowAirPlay</code> (section 3)</td>
</tr>
<tr>
<td>"Works in the background" / "Runs even when screen is locked"</td>
<td><code>UIBackgroundModes</code> â†’ <code>audio</code>, active session, running engine (section 7)</td>
</tr>
<tr>
<td>"Add echo and reverbâ€¦ Adjust bass and treble. Make your voice louder or softer. Everything updates in real-time"</td>
<td><code>AVAudioUnitReverb</code>, <code>AVAudioUnitEQ</code> bands and gain, mixer volume, all live in the graph (section 4)</td>
</tr>
<tr>
<td>"Every session is saved automaticallyâ€¦ MP3, WAV, M4A"</td>
<td>A tap on the graph feeding a file writer, off the real-time thread (section 5)</td>
</tr>
<tr>
<td>"Safety tips: Start with low volumeâ€¦ Avoid feedback by keeping device away from speakers"</td>
<td>The feedback realities in section 5, and starting at a low volume by default</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-screenshot-2.jpg" alt="Virtual Mic App Store screenshot 2" /></p>
<p>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 <code>.oldDeviceUnavailable</code>". It's also why the app still supports iOS 16.6 and carries the pre-iOS 17 permission path from section 2.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-screenshot-3.jpg" alt="Virtual Mic App Store screenshot 3" /></p>
<h3>Android sidebar: Virtual Mic on Google Play</h3>
<p>The same idea exists on Android as <strong><a href="https://play.google.com/store/apps/details?id=com.aryansbtloe.microphone.android">Virtual Mic on Google Play</a></strong> (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.</p>
<p>On Android the building blocks are <code>AudioRecord</code> (capture) and <code>AudioTrack</code> (playback), or Google's <strong>Oboe</strong> 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.</p>
<hr />
<h2>9. Before you ship live audio: a 7-point checklist</h2>
<ol>
<li><strong>Permission flow is complete.</strong> <code>NSMicrophoneUsageDescription</code> is specific; you ask at the moment of need; iOS 17+ uses <code>AVAudioApplication</code>, older versions fall back to <code>AVAudioSession</code>; denied users get an "Open Settings" path.</li>
<li><strong>The session is configured on purpose.</strong> <code>.playAndRecord</code>, <code>.defaultToSpeaker</code>, 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.</li>
<li><strong>Formats come from the hardware.</strong> You connect using the input node's format, check it's valid, and rebuild connections whenever the route or configuration changes.</li>
<li><strong>Feedback has defaults.</strong> Start at low volume, recommend headphones or distance, offer voice processing as a user choice, and use echo-cancelled input only where <code>isEchoCancelledInputAvailable</code> says you can.</li>
<li><strong>Interruptions and route changes are tested on a real device.</strong> Phone call, alarm, Siri, unplugging headphones, Bluetooth disconnecting, AirPlay switching: the app ends up in a sensible, visible state every time.</li>
<li><strong>Background behaviour is honest.</strong> The <code>audio</code> background mode is declared only because you really play audio in the background, and you deactivate the session with <code>.notifyOthersOnDeactivation</code> when the user stops.</li>
<li><strong>The real-time thread stays clean.</strong> No allocations, locks, logging or file I/O inside render callbacks or taps; heavy work is handed off.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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. <code>AVAudioSession</code> plus <code>AVAudioEngine</code> 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.</p>
<p>If you want to see the result in action, try my apps:</p>
<ul>
<li><strong>Mic to Speaker â€“ Virtual Mic (iOS):</strong> <a href="https://apps.apple.com/us/app/mic-to-speaker-virtual-mic/id1514011963">https://apps.apple.com/us/app/mic-to-speaker-virtual-mic/id1514011963</a></li>
<li><strong>Virtual Mic (Android):</strong> <a href="https://play.google.com/store/apps/details?id=com.aryansbtloe.microphone.android">https://play.google.com/store/apps/details?id=com.aryansbtloe.microphone.android</a></li>
<li><strong>All 24 of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>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.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/">https://bestlatestapps.com/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building a Tap-to-Match Marble Game with SpriteKit and SwiftUI: Physics, Cluster Matching, Timers, Lives and Power-Ups]]></title><description><![CDATA[A complete walkthrough of the game loop: marbles that settle under real physics, finding touching same-colour clusters, a pause-safe countdown, lives and…]]></description><link>https://aryansbtloe.hashnode.dev/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui-physics-cluster-matching-timers-lives-and-power-ups</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui-physics-cluster-matching-timers-lives-and-power-ups</guid><category><![CDATA[Swift]]></category><category><![CDATA[SwiftUI]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Game Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:05:22 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/marbles-popperz-icon-512.jpg" alt="Marbles Popperz app icon" /></p>
<p>Every game developer has a "small" game that taught them how games actually work. Mine is <strong>Marbles Popperz</strong>, 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.</p>
<p>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.</p>
<p>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.</p>
<hr />
<h2>1. Why SpriteKit, and how it fits with SwiftUI</h2>
<p>For a 2D casual game on Apple platforms, SpriteKit is the shortest path. It gives you a scene graph (<code>SKScene</code>, <code>SKSpriteNode</code>), actions for animation (<code>SKAction</code>), a built-in physics engine (<code>SKPhysicsBody</code>) and particles (<code>SKEmitterNode</code>), all rendered on the GPU, with no third-party engine to update.</p>
<p>Since iOS 14, <code>SpriteView</code> lets a SwiftUI view host a scene directly. So menus, the HUD and settings can be ordinary SwiftUI, and only the playfield is SpriteKit:</p>
<pre><code class="language-swift">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
}
</code></pre>
<p>The rule I follow: <strong>the scene owns the simulation; SwiftUI owns the chrome.</strong> The scene writes to a shared <code>GameState</code>, 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.</p>
<hr />
<h2>2. A playfield of marbles that settle under physics</h2>
<p>Marbles should <em>behave</em> 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:</p>
<pre><code class="language-swift">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..&lt;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)
        }
    }
}
</code></pre>
<p>Two cheap wins: put every marble texture in one <strong>texture atlas</strong>, so all marbles are drawn in a few batched calls; and set <code>ignoresSiblingOrder</code> so SpriteKit is free to reorder draws for batching.</p>
<hr />
<h2>3. Tap-to-match: finding the touching cluster</h2>
<p>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 <strong>cluster matching</strong>. 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.</p>
<p>Hit-testing uses <code>nodes(at:)</code>. The cluster is a breadth-first search over "touching" neighbours:</p>
<pre><code class="language-swift">extension MarbleScene {
    override func touchesBegan(_ touches: Set&lt;UITouch&gt;, 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 &gt;= 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) -&gt; [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&lt;ObjectIdentifier&gt; = [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) &lt;= 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) -&gt; Int { n * n * 5 }
}
</code></pre>
<p>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.</p>
<hr />
<h2>4. Popping with juice</h2>
<p>A match should feel <em>good</em>: a quick scale-up, a fade, a particle burst and a sound. The physics then lets the marbles above fall into the gap.</p>
<pre><code class="language-swift">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()]))
    }
}
</code></pre>
<p>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.</p>
<hr />
<h2>5. A countdown that survives pausing</h2>
<p>"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 <code>Timer</code> or <code>Date</code> arithmetic gets this wrong. The robust pattern is to <strong>accumulate frame deltas in <code>update(_:)</code></strong>, and only while the game is actually running:</p>
<pre><code class="language-swift">extension MarbleScene {
    override func update(_ currentTime: TimeInterval) {
        defer { lastUpdate = currentTime }
        guard lastUpdate &gt; 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 &lt;= 0 { timeUp() }
        if children.compactMap({ $0 as? Marble }).isEmpty { levelCleared() }
    }
}
</code></pre>
<p>(<code>remaining</code> and <code>lastUpdate</code> are the stored properties declared on the scene in section 2.) Three details carry the weight:</p>
<ul>
<li><strong>Clamp <code>dt</code>.</strong> After a pause, <code>currentTime</code> jumps forward. Without the clamp, the first frame back would subtract all of the paused time.</li>
<li><strong>Guard on <code>isPaused</code>.</strong> Pausing the <code>SKView</code> 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.</li>
<li><strong>Throttle UI updates.</strong> Writing to <code>@Published</code> state 60 times a second makes SwiftUI re-render the HUD 60 times a second. Write only when the displayed value changes.</li>
</ul>
<p>Pausing itself is one line, <code>scene.isPaused = true</code>. Wire it to the scene phase going to <code>.inactive</code> or <code>.background</code> in SwiftUI.</p>
<hr />
<h2>6. Lives and levels</h2>
<p>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:</p>
<pre><code class="language-swift">struct LevelConfig {
    let marbleCount: Int
    let colorCount: Int
    let seconds: TimeInterval
    let powerUpChance: Double

    static func forLevel(_ level: Int) -&gt; 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 &lt;= 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)
    }
}
</code></pre>
<p>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.</p>
<hr />
<h2>7. Power-ups: Lightning, Heart and Bomb</h2>
<p>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:</p>
<pre><code class="language-swift">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 &lt; $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) &lt; 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))
            }
        }
    }
}
</code></pre>
<p>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.</p>
<hr />
<h2>8. Real-world example: Marbles Popperz</h2>
<p>This walkthrough is based on building <strong><a href="https://apps.apple.com/us/app/marbles-popperz/id1601511276">Marbles Popperz</a></strong>. The code here is the approach I'd use, not the shipped source.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/marbles-popperz-screenshot-1.jpg" alt="Marbles Popperz App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Games</strong> (Puzzle, Board)</li>
<li>First released in <strong>December 2021</strong></li>
<li><strong>3 ratings</strong> in the US store, averaging about <strong>3.7 stars</strong></li>
<li>Supports <strong>iOS 16.0 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Simple tap-to-match gameplay" / "Match marbles by color to remove them"</td>
<td>Hit-testing and cluster matching (section 3)</td>
</tr>
<tr>
<td>"Beautiful visual effects and animations"</td>
<td>Staggered pops and particle bursts (section 4)</td>
</tr>
<tr>
<td>"Watch the timer - you need to match quickly!"</td>
<td>Pause-safe countdown in <code>update(_:)</code> (section 5)</td>
</tr>
<tr>
<td>"Lives system" / "Level progression - difficulty increases as you advance"</td>
<td>Lives and a level table (section 6)</td>
</tr>
<tr>
<td>"Lightning Flash", "Heart", "Bomb"</td>
<td>Power-ups (section 7)</td>
</tr>
<tr>
<td>"Global leaderboards - compete with players worldwide"</td>
<td>Game Center, covered in my Speck Match article</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/marbles-popperz-screenshot-2.jpg" alt="Marbles Popperz App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>9. Before you ship a casual game: a checklist</h2>
<ol>
<li><strong>The timer pauses</strong> on pause, on interruptions and in the background, and <code>dt</code> is clamped on resume.</li>
<li><strong>The UI isn't updated every frame</strong>, only when displayed values change.</li>
<li><strong>Textures are in an atlas</strong> and <code>ignoresSiblingOrder</code> is on. Check the draw count with <code>showsDrawCount</code> during development.</li>
<li><strong>Finished emitters and actions are removed.</strong> No invisible nodes pile up.</li>
<li><strong>The difficulty table is data</strong>, and each level has been played by someone other than you.</li>
<li><strong>Taps feel fair.</strong> The hit area isn't smaller than the visible marble, and a mis-tap gets clear feedback.</li>
<li><strong>Colour isn't the only cue.</strong> Add patterns or symbols to marbles for colour-blind players.</li>
<li><strong>Sound respects the silent switch</strong>, and there's a mute option.</li>
<li><strong>Test on the oldest supported device</strong> at 60 fps with the maximum marble count.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Play the game, or explore my other work:</p>
<ul>
<li><strong>Marbles Popperz (iOS):</strong> <a href="https://apps.apple.com/us/app/marbles-popperz/id1601511276">https://apps.apple.com/us/app/marbles-popperz/id1601511276</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>What's your favourite bit of game "juice"? Share it in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/">https://bestlatestapps.com/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building an AdMob Earnings Dashboard for iOS: Google OAuth with PKCE, the AdMob Reporting API, Pacific-Time 'Today', and Offline Caching]]></title><description><![CDATA[Signing in with ASWebAuthenticationSession and PKCE, storing tokens safely in the Keychain, calling networkReport:generate, decoding a streamed report…]]></description><link>https://aryansbtloe.hashnode.dev/building-an-admob-earnings-dashboard-for-ios-google-oauth-with-pkce-the-admob-reporting-api-pacific-time-today-and-offline-caching</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-an-admob-earnings-dashboard-for-ios-google-oauth-with-pkce-the-admob-reporting-api-pacific-time-today-and-offline-caching</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[oauth]]></category><category><![CDATA[admob]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:04:54 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-icon-512.jpg" alt="AdEarnings Pro app icon" /></p>
<p>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 <strong>AdEarnings Pro</strong>. 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."</p>
<p>That promise shapes the whole architecture. There's no server of mine in the middle. The app talks <strong>directly</strong> to Google's AdMob API using the user's own OAuth token, and everything is cached on the device.</p>
<p>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 <code>America/Los_Angeles</code>, 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.</p>
<hr />
<h2>1. The shape of the app: no middleman</h2>
<p>The data flow is deliberately short:</p>
<ol>
<li>The user signs in with Google and grants a <strong>read-only reporting scope</strong>.</li>
<li>The app stores the refresh token in the <strong>Keychain</strong>, on the device.</li>
<li>The app calls <code>admob.googleapis.com</code> <strong>directly</strong> with a short-lived access token.</li>
<li>Results are cached locally for offline viewing.</li>
</ol>
<p>Google's AdMob API getting-started guide lists two scopes. <code>admob.readonly</code> lets an app "See all AdMob data", and Google notes that this "doesn't include sensitive data, such as payments or campaign details". <code>admob.report</code> lets it "See ad performance and earnings reports. See publisher ID, timezone, and default currency code." For an earnings dashboard, <strong><code>admob.report</code> is the narrowest scope that works</strong>, 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.</p>
<p>The listing also notes "This app requires access to your existing Google AdMob account". The API reads accounts; it can't create one.</p>
<hr />
<h2>2. OAuth for a native app: system browser, PKCE, no embedded web views</h2>
<p>Google's OAuth documentation is blunt about embedded web views. Opening the authorization page in <code>WKWebView</code> produces a <code>disallowed_useragent</code> error, and Google recommends its Google Sign-In for iOS library or AppAuth instead. Both are built on the same standard flow: the <strong>system browser</strong> plus <strong>PKCE</strong> (Proof Key for Code Exchange). Here's that flow with Apple's own <code>ASWebAuthenticationSession</code> (iOS 12+), so you can see every step:</p>
<pre><code class="language-swift">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() -&gt; PKCE {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &amp;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() -&gt; 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 -&gt; (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) -&gt; ASPresentationAnchor {
        UIApplication.shared.connectedScenes.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first ?? ASPresentationAnchor()
    }
}
</code></pre>
<p>Every parameter here comes from Google's OAuth documentation for installed apps:</p>
<ul>
<li><strong>The authorization endpoint</strong> is <code>accounts.google.com/o/oauth2/v2/auth</code>.</li>
<li><strong><code>response_type=code</code></strong> is required.</li>
<li><strong>The challenge is S256</strong>, which Google recommends.</li>
<li><strong>The verifier is 43 to 128 characters.</strong> Thirty-two random bytes, base64url-encoded, gives exactly 43.</li>
<li><strong><code>state</code></strong> protects against cross-site request forgery. Google calls it out as important, and the code checks it.</li>
</ul>
<p>On iOS 17.4 and later, <code>ASWebAuthenticationSession</code> also accepts a <code>Callback</code> object (<code>ASWebAuthenticationSession.Callback</code>). SwiftUI has a <code>webAuthenticationSession</code> environment value from iOS 16.4. Either is a tidier wrapper around the same flow.</p>
<hr />
<h2>3. Exchanging the code and keeping tokens safe</h2>
<p>Next, swap the code for tokens at <code>oauth2.googleapis.com/token</code>, sending the PKCE <strong>verifier</strong> (not the challenge). Google's docs note that <code>client_secret</code> doesn't apply to iOS clients:</p>
<pre><code class="language-swift">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 -&gt; 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 -&gt; TokenResponse {
        try await tokenRequest(["client_id": clientID, "grant_type": "refresh_token", "refresh_token": refreshToken])
    }

    private func tokenRequest(_ form: [String: String]) async throws -&gt; 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)
    }
}
</code></pre>
<p>Then store the <strong>refresh token</strong> in the Keychain, never in <code>UserDefaults</code>, and mark it device-only:</p>
<pre><code class="language-swift">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)
    }
}
</code></pre>
<p><code>AfterFirstUnlockThisDeviceOnly</code> 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 <code>scope</code>: 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 <strong>keep and reuse</strong> the refresh token rather than signing in again each time.</p>
<p>"Revoke consent anytime" maps to two things. The user can remove access from their Google Account settings, and the app can call <code>https://oauth2.googleapis.com/revoke?token=â€¦</code> on sign-out and then delete the Keychain item.</p>
<hr />
<h2>4. Asking AdMob for a report</h2>
<p>The AdMob API's core endpoint is <code>accounts.networkReport.generate</code>, a <code>POST</code> to <code>https://admob.googleapis.com/v1/accounts/{publisherId}/networkReport:generate</code> with a <code>reportSpec</code>. Here's a request for daily earnings and clicks over the last seven days:</p>
<pre><code class="language-swift">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) -&gt; 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"
    ))
}
</code></pre>
<p>Three facts from Google's reference page shape this request:</p>
<ul>
<li><strong><code>ESTIMATED_EARNINGS</code> is in micros.</strong> "$6.50 would be represented as 6500000." Never put that number in a <code>Double</code> and divide casually; use <code>Decimal</code>.</li>
<li><strong>Currency comes from <code>localizationSettings.currencyCode</code>.</strong> 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.</li>
<li><strong>The time zone currently has only one supported value.</strong> Google: "Warning: The 'America/Los_Angeles' is the only supported value at the moment." Section 6 is about what that means.</li>
</ul>
<hr />
<h2>5. Decoding a streamed report</h2>
<p>The response isn't a simple object. It's "the result of a server-side streaming RPC": a JSON <strong>array</strong> whose first element has a <code>header</code>, then one <code>row</code> per result, then a <code>footer</code>. Each element has exactly one of those keys. So decode each element as an enum-like struct:</p>
<pre><code class="language-swift">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 -&gt; [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)
    }
}
</code></pre>
<p>Google's example response shows <code>"microsValue": 6500000</code> 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 <code>init(from:)</code>). The <code>DATE</code> dimension value is <code>YYYYMMDD</code>, as Google's reference describes.</p>
<p>Displaying money is then <code>earnings.formatted(.currency(code: currency))</code>. Don't hand-roll formats, because symbol placement and grouping vary by locale.</p>
<hr />
<h2>6. Why "today" is a Los Angeles day</h2>
<p>Here's the subtle part. If reports can only be in <code>America/Los_Angeles</code>, then AdMob's "today" starts at <strong>midnight Pacific time</strong>. 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.</p>
<p>The approach I'd use is to compute "today" in the report's calendar and say so in the UI:</p>
<pre><code class="language-swift">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) -&gt; 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) -&gt; TimeInterval {
        now.timeIntervalSince(calendar.startOfDay(for: now))
    }
}
</code></pre>
<p>The <strong>live earning rates</strong> 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:</p>
<pre><code class="language-swift">func rates(todayEarnings: Decimal, now: Date = .now) -&gt; (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)
}
</code></pre>
<p>Label it honestly, as "average so far today". Ad revenue isn't evenly spread over a day, and AdMob's figures are <em>estimated</em> earnings that can be revised.</p>
<hr />
<h2>7. Pull to refresh, offline cache and the weekly view</h2>
<p>"Pull-to-refresh for fresh data" is one modifier, <code>refreshable</code> (iOS 15+). "Offline-ready with cached data" means saving the last good response, with a timestamp, and showing it whenever the network fails:</p>
<pre><code class="language-swift">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) }
    }
}
</code></pre>
<p>Putting the cache in <strong>Caches</strong> 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. (<code>AdMobAPI</code> here is the small wrapper around section 4's request, with a valid access token.)</p>
<hr />
<h2>8. Real-world example: AdEarnings Pro</h2>
<p>This walkthrough comes from building <strong><a href="https://apps.apple.com/us/app/adearnings-pro/id6754509445">AdEarnings Pro</a></strong>. The code above is the approach I'd use, written for this article, not a copy of the app's source.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-screenshot-1.jpg" alt="AdEarnings Pro App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Developer Tools</strong></li>
<li>First released in <strong>November 2025</strong></li>
<li>No ratings yet in the US store</li>
<li>Supports <strong>iOS 17.6 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Secure Google Sign-In authentication" / "Google OAuth authentication is required to access AdMob API services"</td>
<td>System-browser OAuth with PKCE, tokens in the Keychain (sections 2â€“3)</td>
</tr>
<tr>
<td>"Your AdMob data is not saved, shared, or accessible to us"</td>
<td>Direct device-to-Google calls, no middleman (section 1)</td>
</tr>
<tr>
<td>"View today's earnings with live updates. Compare yesterday's performance."</td>
<td><code>networkReport:generate</code> with the <code>DATE</code> dimension (sections 4â€“5)</td>
</tr>
<tr>
<td>"Live earning rates per second, minute, and hour"</td>
<td>Rates over the Pacific report day (section 6)</td>
</tr>
<tr>
<td>"Weekly average trends for earnings and clicks"</td>
<td><code>ESTIMATED_EARNINGS</code> and <code>CLICKS</code> (sections 4 and 7)</td>
</tr>
<tr>
<td>"Convert earnings to any currencyâ€¦"</td>
<td><code>localizationSettings.currencyCode</code> (section 4)</td>
</tr>
<tr>
<td>"Pull-to-refresh for fresh data" / "Offline-ready with cached data"</td>
<td><code>refreshable</code> and a timestamped cache (section 7)</td>
</tr>
<tr>
<td>"Revoke consent anytime"</td>
<td>Google Account settings and the revoke endpoint (section 3)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-screenshot-2.jpg" alt="AdEarnings Pro App Store screenshot 2" /></p>
<p>The listing also mentions unpaid earnings, last payment history and GDPR consent management. Those are outside this article's scope.</p>
<hr />
<h2>9. Before you ship an OAuth-powered dashboard: a checklist</h2>
<ol>
<li><strong>Use the narrowest scope that works</strong>, and check the granted <code>scope</code> in the token response.</li>
<li><strong>Use the system browser, PKCE (S256) and a <code>state</code> check.</strong> No <code>WKWebView</code> for sign-in.</li>
<li><strong>Keep the refresh token in the Keychain</strong>, device-only, and reuse it.</li>
<li><strong>Sign-out revokes the token</strong> and clears the Keychain and caches.</li>
<li><strong>Handle money as <code>Decimal</code> from micros</strong>, formatted with the currency's own format.</li>
<li><strong>Make the report time zone explicit.</strong> "Today" is a Pacific day, and the UI says so.</li>
<li><strong>Label rates and estimates honestly</strong>: "average so far" and "estimated".</li>
<li><strong>Show cached data instantly</strong>, with a visible "Updatedâ€¦" time.</li>
<li><strong>Complete Google's OAuth app verification</strong> if your scopes need it before public release. Google's docs describe an "unverified app" screen otherwise.</li>
<li><strong>No third-party analytics touches the earnings data.</strong></li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Try the app, or explore my other work:</p>
<ul>
<li><strong>AdEarnings Pro (iOS):</strong> <a href="https://apps.apple.com/us/app/adearnings-pro/id6754509445">https://apps.apple.com/us/app/adearnings-pro/id6754509445</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>What's the one metric you check most in your ad dashboard? Tell me in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/">https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building an Offline Quotes App in SwiftUI: a 50,000-Quote Library, a Daily Quote Without a Server, Honest Streaks and Share-as-Image]]></title><description><![CDATA[Bundling and importing a large library into SwiftData, picking a "quote of the day" deterministically, search with recent queries, collections…]]></description><link>https://aryansbtloe.hashnode.dev/building-an-offline-quotes-app-in-swiftui-a-50-000-quote-library-a-daily-quote-without-a-server-honest-streaks-and-share-as-image</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-an-offline-quotes-app-in-swiftui-a-50-000-quote-library-a-daily-quote-without-a-server-honest-streaks-and-share-as-image</guid><category><![CDATA[Swift]]></category><category><![CDATA[SwiftUI]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:04:27 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-offline-quotes-app-in-swiftui/facts-and-quotes-icon-512.jpg" alt="Facts and Quotes app icon" /></p>
<p>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?</p>
<p>I've worked through those questions building <strong>Facts and Quotes</strong>, 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.</p>
<p>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.</p>
<hr />
<h2>1. Ship the library in the bundle, import it once</h2>
<p>"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 <strong>querying</strong> them is better done from a real store, so I'd import into SwiftData on first launch:</p>
<pre><code class="language-swift">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 }
</code></pre>
<p>The import runs once, in the background, in batches, so memory stays flat and the UI never blocks:</p>
<pre><code class="language-swift">@ModelActor
actor LibraryImporter {
    func importIfNeeded(bundleVersion: Int) throws {
        let key = "library.importedVersion"
        guard UserDefaults.standard.integer(forKey: key) &lt; 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&lt;Quote&gt;()).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)
    }
}
</code></pre>
<p>Two design points:</p>
<ul>
<li><strong>A library version number.</strong> 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.</li>
<li><strong>Stable IDs.</strong> 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.</li>
</ul>
<p>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.</p>
<hr />
<h2>2. A quote of the day with no server</h2>
<p>A "daily quote" should be the same all day, change at midnight and need no network. The trick is a <strong>deterministic pick from the local date</strong>:</p>
<pre><code class="language-swift">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) -&gt; Int {
        guard count &gt; 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) -&gt; UInt64 {
        var hash: UInt64 = 0xcbf2_9ce4_8422_2325
        for byte in string.utf8 {
            hash ^= UInt64(byte)
            hash &amp;*= 0x0000_0100_0000_01B3
        }
        return hash
    }
}
</code></pre>
<p>The gotcha worth a paragraph: <strong>don't use <code>String.hashValue</code></strong>. 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.</p>
<p>Then fetch by a sorted offset rather than loading everything:</p>
<pre><code class="language-swift">func quoteOfTheDay(in context: ModelContext) throws -&gt; Quote? {
    let total = try context.fetchCount(FetchDescriptor&lt;Quote&gt;())          // iOS 17+
    var descriptor = FetchDescriptor&lt;Quote&gt;(sortBy: [SortDescriptor(\.id)])
    descriptor.fetchOffset = DailyPick.index(count: total)
    descriptor.fetchLimit = 1
    return try context.fetch(descriptor).first
}
</code></pre>
<p>Sorting by the stable ID matters too. Without a sort order, "the 1,234th quote" isn't guaranteed to be the same row tomorrow.</p>
<hr />
<h2>3. Search across content, author and category, with recent searches</h2>
<p>SwiftUI's <code>searchable</code> (iOS 16+) and SwiftData's <code>#Predicate</code> (iOS 17+) do most of the work. <code>localizedStandardContains</code> gives the case- and diacritic-insensitive matching people expect from a search box:</p>
<pre><code class="language-swift">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&lt;Quote&gt; {
            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)
            }
        }
    }
}
</code></pre>
<p>Rebuilding the <code>Query</code> 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 <code>@AppStorage</code> is the right home for them.</p>
<hr />
<h2>4. Collections and favourites</h2>
<p>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 <em>virtual</em> collection, a query, rather than a stored one that can drift out of sync:</p>
<pre><code class="language-swift">struct FavoritesView: View {
    @Query(filter: #Predicate&lt;Quote&gt; { $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)
}
</code></pre>
<p>Deleting a collection shouldn't delete its quotes. The default delete rule for a relationship, nullify, does exactly that.</p>
<hr />
<h2>5. Streaks that survive time zones</h2>
<p>A streak is "the number of consecutive days, ending today, on which you read". The bugs come from storing <em>timestamps</em> and doing arithmetic in seconds. Then 23:50 and 00:10 count as the same day, or a flight makes a day disappear.</p>
<p>The fix is to store <strong>calendar days as the user experienced them</strong>, as plain date strings, and count backwards:</p>
<pre><code class="language-swift">import Foundation

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

    private func key(_ date: Date) -&gt; 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) -&gt; 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
    }
}
</code></pre>
<p>Why this is honest:</p>
<ul>
<li><strong>Each day is recorded in the local calendar at the moment it happens.</strong> Reading at 23:55 in Delhi is "the 22nd", whatever happens afterwards.</li>
<li><strong>Today doesn't break the streak until it's over.</strong> Opening the app at 8 a.m. shouldn't show zero because you haven't read yet.</li>
<li><strong>Calendar arithmetic, not 86,400-second arithmetic.</strong> <code>date(byAdding: .dayâ€¦)</code> handles daylight-saving days of 23 or 25 hours.</li>
</ul>
<p>Achievements, like "7-day streak" or "read 100 quotes", are then pure functions of this data. Compute them; don't store them separately.</p>
<hr />
<h2>6. Share as a beautiful image</h2>
<p>"Share quotes as beautiful images or text" is two lines of SwiftUI once you have the pieces: <code>ImageRenderer</code> (iOS 16+) to rasterise a SwiftUI view, and <code>ShareLink</code> with a <code>Transferable</code> item (iOS 16+):</p>
<pre><code class="language-swift">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")
        }
    }
}
</code></pre>
<p>Details that matter:</p>
<ul>
<li><strong>Design the card at output pixel size</strong> and render with <code>scale = 1</code>. Sizes stay predictable, and text is sharp at the size social apps actually use.</li>
<li><strong>Offer text too.</strong> The <code>ProxyRepresentation</code> gives the share sheet a plain-text version, so Messages or Notes receive words, not a picture of words.</li>
<li><strong>Rendering happens on the main actor.</strong> <code>ImageRenderer</code> is main-actor bound, hence the <code>MainActor.run</code>.</li>
</ul>
<hr />
<h2>7. Light, Dark and Auto, with colour tokens</h2>
<p>"Light, Dark, and Auto" is a three-way setting where Auto means "follow the system". In SwiftUI, <code>preferredColorScheme(nil)</code> is exactly that:</p>
<pre><code class="language-swift">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])
    }
}
</code></pre>
<p>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 (<code>Color("surfaceElevated")</code>, <code>Color("textSecondary")</code>) rather than by hue. Then a theme change is free, and no screen hard-codes <code>.black</code>.</p>
<hr />
<h2>8. Real-world example: Facts and Quotes</h2>
<p>All of this comes from building <strong><a href="https://apps.apple.com/us/app/facts-and-quotes/id1607878544">Facts and Quotes</a></strong>. The code above is the approach I'd use, written for this article, not a copy of the app's source.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-offline-quotes-app-in-swiftui/facts-and-quotes-screenshot-1.jpg" alt="Facts and Quotes App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Health &amp; Fitness</strong> and <strong>Productivity</strong></li>
<li>First released in <strong>August 2022</strong></li>
<li><strong>3 ratings</strong> in the US store, all <strong>5 stars</strong></li>
<li>Supports <strong>iOS 17.6 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"50,000+ curated quotes", "Offline access - read anywhere, anytime"</td>
<td>Bundled library imported into SwiftData (section 1)</td>
</tr>
<tr>
<td>"Daily new quotes automatically updated"</td>
<td>Versioned importer plus a deterministic daily pick (sections 1â€“2)</td>
</tr>
<tr>
<td>"Search quotes by content, author, or category" / "Recent searches"</td>
<td><code>searchable</code> + <code>#Predicate</code> + <code>searchSuggestions</code> (section 3)</td>
</tr>
<tr>
<td>"Create custom collectionsâ€¦" / "Favorites collection automatically syncs your liked quotes"</td>
<td>SwiftData relationships and a query-based Favorites (section 4)</td>
</tr>
<tr>
<td>"Track your reading streak and daily progress" / "Unlock achievements"</td>
<td>Calendar-day streaks (section 5)</td>
</tr>
<tr>
<td>"Share quotes as beautiful images or text"</td>
<td><code>ImageRenderer</code> + <code>Transferable</code> + <code>ShareLink</code> (section 6)</td>
</tr>
<tr>
<td>"Light, Dark, and Auto theme support" / "50+ color tokens"</td>
<td><code>preferredColorScheme</code> and semantic colours (section 7)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-offline-quotes-app-in-swiftui/facts-and-quotes-screenshot-2.jpg" alt="Facts and Quotes App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>9. Before you ship a content app: a checklist</h2>
<ol>
<li><strong>The first launch is fast.</strong> The import runs in the background, in batches, and the UI shows today's quote immediately.</li>
<li><strong>Every item has a stable ID.</strong> Updates never break favourites or collections.</li>
<li><strong>The daily pick uses a stable hash.</strong> No <code>hashValue</code>, and the fetch has a fixed sort order.</li>
<li><strong>Search is case- and accent-insensitive</strong>, and recent searches are capped.</li>
<li><strong>Streaks are tested across midnight, daylight saving and a time-zone change.</strong></li>
<li><strong>Shared images are rendered at their target pixel size</strong>, with a text fallback.</li>
<li><strong>Auto theme follows the system</strong>; no colour is hard-coded.</li>
<li><strong>Attribution is correct.</strong> Every quote has an author, or "Unknown". Misattributed quotes are the most common complaint in this category.</li>
<li><strong>Accessibility.</strong> Quote text scales with Dynamic Type, and VoiceOver reads the author after the quote.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>The app is simple to <em>use</em> 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.</p>
<p>Try the app, or explore my other work:</p>
<ul>
<li><strong>Facts and Quotes (iOS):</strong> <a href="https://apps.apple.com/us/app/facts-and-quotes/id1607878544">https://apps.apple.com/us/app/facts-and-quotes/id1607878544</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>Got a favourite quote? Drop it in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-an-offline-quotes-app-in-swiftui/">https://bestlatestapps.com/blog/building-an-offline-quotes-app-in-swiftui/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building an Offline eBook Reader for iOS: Downloads, Typography Settings, Reading Progress That Survives Font Changes, and Highlights in the iOS 26 Te]]></title><description><![CDATA[Downloading and storing public-domain books, previewing before you download, a UITextView-based reader with font, line height, margin and theme settings…]]></description><link>https://aryansbtloe.hashnode.dev/building-an-offline-ebook-reader-for-ios-downloads-typography-settings-reading-progress-that-survives-font-changes-and-highlights-in-the-ios-26-te</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-an-offline-ebook-reader-for-ios-downloads-typography-settings-reading-progress-that-survives-font-changes-and-highlights-in-the-ios-26-te</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[ebook]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:03:59 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-offline-ebook-reader-for-ios/novels-club-icon-512.jpg" alt="Novels Club app icon" /></p>
<p>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. <strong>Novels Club</strong> 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.</p>
<p>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: <strong>how do you remember someone's place when they change the font size and every "page" moves?</strong></p>
<p>This article walks through how I'd build each part. The APIs are checked against Apple's documentation, including two iOS 27 deprecations in <code>UITextView</code> that matter for highlighting.</p>
<hr />
<h2>1. The library model</h2>
<p>A book has metadata you can browse before downloading, and an optional local file once it's downloaded. In SwiftData:</p>
<pre><code class="language-swift">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 }
}
</code></pre>
<p>Two decisions shape everything later. <strong>Positions are character offsets, not page numbers</strong> (section 5). And <strong>marks cascade-delete with their book</strong>, so removing a book never leaves orphaned highlights behind.</p>
<p>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".</p>
<hr />
<h2>2. Downloading books, and keeping them out of iCloud backup</h2>
<p>"Download books for offline reading" with URLSession's async API (iOS 15+) is short. The details that matter are <em>where</em> the file goes and <em>whether it's backed up</em>:</p>
<pre><code class="language-swift">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
    }
}
</code></pre>
<ul>
<li><strong>Application Support, not Caches.</strong> 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.</li>
<li><strong><code>isExcludedFromBackup</code>.</strong> The books can be downloaded again, so they shouldn't eat the user's iCloud quota. Apple's storage guidance asks for exactly this.</li>
<li><strong>Move the temp file immediately.</strong> The async <code>download(from:)</code> gives you a temporary file that's deleted once you're done with it, so move it before doing anything else.</li>
</ul>
<p>For a progress bar, pass a <code>URLSessionTaskDelegate</code> to <code>download(from:delegate:)</code>, or use a <code>URLSessionDownloadTask</code> with a <code>URLSessionDownloadDelegate</code> (iOS 7+) and observe the task's <code>progress</code>.</p>
<hr />
<h2>3. Preview before downloading</h2>
<p>"Preview books before downloading" doesn't need a separate preview file. If the server supports range requests, ask for just the first few kilobytes:</p>
<pre><code class="language-swift">extension BookStore {
    static func preview(_ book: Book, bytes: Int = 16_384) async throws -&gt; 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..&lt;4 {
            if let text = String(data: slice.dropLast(trim), encoding: .utf8) { return text }
        }
        return ""
    }
}
</code></pre>
<p>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 <code>String(data:encoding:)</code> returns nil for the whole preview.</p>
<hr />
<h2>4. A reader with real typography settings</h2>
<p>The listing promises "font size, font family, line height, margins" and "light, dark, and custom color schemes". <code>UITextView</code> (read-only) gives you excellent text layout, selection and accessibility for free. Since iOS 16 it can be created explicitly with TextKit 2 via <code>init(usingTextLayoutManager:)</code>:</p>
<pre><code class="language-swift">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) -&gt; 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() -&gt; Coordinator { Coordinator(offset: $offset) }
}
</code></pre>
<p>The <code>applied != settings</code> 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.</p>
<hr />
<h2>5. Reading progress that survives font changes</h2>
<p>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 <strong>the character the reader is looking at</strong>, a UTF-16 offset into the text, which no layout change can move:</p>
<pre><code class="language-swift">final class Coordinator: NSObject, UITextViewDelegate {
    var offset: Binding&lt;Int&gt;
    var applied: ReaderSettings?
    var onHighlight: (([NSRange]) -&gt; Void)?      // set by the reader screen to save Marks
    init(offset: Binding&lt;Int&gt;) { self.offset = offset }

    /// The first character at the top-left of the visible area.
    func currentOffset(in view: UITextView) -&gt; 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)
        }
    }
}
</code></pre>
<p><code>closestPosition(to:)</code> and <code>offset(from:to:)</code> come from <code>UITextInput</code> (iOS 3.2+), which <code>UITextView</code> adopts. They do exactly what we need: convert between points on screen and positions in the text. With this:</p>
<ul>
<li><strong>Changing the font, line height or margins</strong> keeps the same sentence at the top of the screen.</li>
<li><strong>"Continue reading where you left off"</strong> is just opening the book and restoring <code>progressOffset</code>.</li>
<li><strong>Percentage progress</strong> is <code>offset / text.utf16.count</code>, which is meaningful whatever the layout.</li>
</ul>
<p>Save on scroll <em>end</em>, not on every scroll event. Writing to SwiftData 60 times a second while flicking through a chapter is wasted work.</p>
<hr />
<h2>6. Bookmarks and highlights, and the iOS 26 text-selection change</h2>
<p>Since positions are character offsets, a highlight is just <code>(location, length)</code> and a bookmark is <code>(location, 0)</code>. Both are stable because a public-domain text never changes. Rendering highlights is an attribute pass:</p>
<pre><code class="language-swift">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) &lt;= storage.length else { continue }
        storage.addAttribute(.backgroundColor, value: color.withAlphaComponent(0.35), range: range)
    }
}
</code></pre>
<p>To <em>create</em> highlights, add an item to the text view's edit menu. This is where the APIs are changing:</p>
<ul>
<li><strong>iOS 16</strong> introduced <code>textView(_:editMenuForTextIn:suggestedActions:)</code>, which gives you a single <code>NSRange</code>. Apple's documentation now marks it <strong>deprecated in iOS 27</strong>.</li>
<li><strong>iOS 26</strong> added <code>textView(_:editMenuForTextInRanges:suggestedActions:)</code>, which gives you an <em>array</em> of ranges. Apple's documentation explains that a selection can be several discontiguous ranges, for example when bidirectional text is selected. Correspondingly, <code>UITextView.selectedRange</code> is marked <strong>deprecated in iOS 27</strong>, alongside the newer <code>selectedRanges</code>.</li>
</ul>
<p>Since the app supports iOS 16.6 and later, I'd implement both and funnel them into one function:</p>
<pre><code class="language-swift">extension Coordinator {
    private func highlightAction(for ranges: [NSRange]) -&gt; UIAction {
        UIAction(title: "Highlight", image: UIImage(systemName: "highlighter")) { [weak self] _ in
            self?.onHighlight?(ranges.filter { $0.length &gt; 0 })
        }
    }

    // iOS 26+: one or more selected ranges.
    @available(iOS 26, *)
    func textView(_ textView: UITextView, editMenuForTextInRanges ranges: [NSValue],
                  suggestedActions: [UIMenuElement]) -&gt; 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]) -&gt; UIMenu? {
        UIMenu(children: [highlightAction(for: [range])] + suggestedActions)
    }
}
</code></pre>
<p>The iOS 26 method's <code>[NSValue]</code> parameter type is taken from Apple's published declaration. Each value wraps an <code>NSRange</code>.</p>
<hr />
<h2>7. Reading statistics without surveillance</h2>
<p>"Track your reading statistics: books read, pages read, reading time" and "reading streaks". The honest way to measure reading time is to <strong>count only while the reader is on screen and the app is active</strong>:</p>
<pre><code class="language-swift">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.
</code></pre>
<p>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.</p>
<hr />
<h2>8. Real-world example: Novels Club</h2>
<p>Everything here comes from building <strong><a href="https://apps.apple.com/us/app/novels-club/id6755921578">Novels Club</a></strong>. The code is the approach I'd use, written for this article, not the shipped source.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-offline-ebook-reader-for-ios/novels-club-screenshot-1.jpg" alt="Novels Club App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Magazines &amp; Newspapers</strong> and <strong>Entertainment</strong></li>
<li>First released in <strong>December 2025</strong></li>
<li>No ratings yet in the US store</li>
<li>Supports <strong>iOS 16.6 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"All books are in the public domain, so you can read them completely"</td>
<td>Why offsets and highlights can be stable (sections 1 and 6)</td>
</tr>
<tr>
<td>"Download books for offline reading" / "All your downloaded books are stored locally"</td>
<td>Downloads in Application Support, excluded from backup (section 2)</td>
</tr>
<tr>
<td>"Preview books before downloading"</td>
<td>Range-request previews (section 3)</td>
</tr>
<tr>
<td>"Customizable reading settings: font size, font family, line height, margins" / "light, dark, and custom color schemes"</td>
<td>The TextKit 2 reader (section 4)</td>
</tr>
<tr>
<td>"Reading progress tracking" / "Continue reading where you left off"</td>
<td>Character-offset progress (section 5)</td>
</tr>
<tr>
<td>"Bookmarks and highlights for your favorite passages"</td>
<td>Marks and the edit menu (section 6)</td>
</tr>
<tr>
<td>"See book ratings, reading time estimates, and difficulty levels"</td>
<td>Reading-time estimates (section 1)</td>
</tr>
<tr>
<td>"Track your reading statisticsâ€¦" / "View reading streaks"</td>
<td>Honest reading time (section 7)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-offline-ebook-reader-for-ios/novels-club-screenshot-2.jpg" alt="Novels Club App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>9. Before you ship a reading app: a checklist</h2>
<ol>
<li><strong>Books live in Application Support</strong>, excluded from iCloud backup.</li>
<li><strong>Previews handle partial UTF-8</strong> and servers that ignore <code>Range</code>.</li>
<li><strong>Re-layout happens only when settings change</strong>; long books load by chapter.</li>
<li><strong>Progress is stored as a character offset</strong> and restored after every settings change.</li>
<li><strong>Progress is saved on scroll end</strong>, not continuously.</li>
<li><strong>Highlights are offset ranges</strong>, with both edit-menu delegate methods (iOS 16 and iOS 26+).</li>
<li><strong>Dynamic Type works</strong>, and the custom font size is a multiplier on top of it.</li>
<li><strong>Reading time only counts while the reader is active</strong>, with an idle cap.</li>
<li><strong>Themes are tested in a dark room.</strong> Pure white on black is harsh; slightly grey text reads better.</li>
<li><strong>Copyright is respected.</strong> Check the public-domain status of every title for the regions you distribute in, because it varies by country.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Try the app, or explore my other work:</p>
<ul>
<li><strong>Novels Club (iOS):</strong> <a href="https://apps.apple.com/us/app/novels-club/id6755921578">https://apps.apple.com/us/app/novels-club/id6755921578</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>Which classic should everyone read at least once? Tell me in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-an-offline-ebook-reader-for-ios/">https://bestlatestapps.com/blog/building-an-offline-ebook-reader-for-ios/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Building an Offline-First Bible Reader on iOS: Bundled SQLite, Full-Text Search and iCloud-Synced Notes]]></title><description><![CDATA[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…]]></description><link>https://aryansbtloe.hashnode.dev/building-an-offline-first-bible-reader-on-ios-bundled-sqlite-full-text-search-and-icloud-synced-notes</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/building-an-offline-first-bible-reader-on-ios-bundled-sqlite-full-text-search-and-icloud-synced-notes</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[SQLite]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:03:28 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-offline-first-bible-reader-on-ios/bible-study-ai-icon-512.jpg" alt="Bible Study AI app icon" /></p>
<p>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 <strong>Bible Study AI â€“ Read Offline</strong>. 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. <em>It works offline.</em></p>
<p>"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.</p>
<p>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 <em>the approach I'd use</em> rather than the app's exact internals, I say so.</p>
<hr />
<h2>1. The problem: two kinds of data with opposite needs</h2>
<p>A Bible app holds two very different kinds of data.</p>
<p><strong>The text itself.</strong> 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 <em>you</em>, the developer, not the user.</p>
<p><strong>What the user creates.</strong> 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 <em>them</em>, and they expect it on their iPad as well as their iPhone.</p>
<p>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.</p>
<p>So split them:</p>
<pre><code class="language-swift">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)       â”‚           â”‚                              â”‚
â””â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”˜           â””â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”˜
</code></pre>
<p>The only thing that links them is a stable <strong>verse identifier</strong>. Get that right first.</p>
<hr />
<h2>2. A verse ID that will never change</h2>
<p>A verse needs an identifier that's identical across translations, app versions and devices. I use a simple integer: <code>book * 1_000_000 + chapter * 1_000 + verse</code>. John 3:16 is book 43, so its ID is <code>43_003_016</code>.</p>
<pre><code class="language-swift">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 &lt; (lhs: VerseRef, rhs: VerseRef) -&gt; Bool { lhs.id &lt; rhs.id }
}
</code></pre>
<p>Why an integer and not a string like <code>"JHN.3.16"</code>? It sorts correctly, ranges are cheap (<code>BETWEEN 43003001 AND 43003999</code> is "all of John 3"), and it's a compact key in both SQLite and Core Data. A highlight saved against <code>43003016</code> shows up in KJV and in World English alike, because the ID describes the <em>position</em>, not the wording.</p>
<p>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 <em>build</em> the database, never at runtime.</p>
<hr />
<h2>3. Shipping the text as a bundled, read-only SQLite database</h2>
<p>I build the text database offline with a script, add <code>bible.sqlite</code> to the app target and open it <strong>read-only straight from the bundle</strong>. No copying to Documents, no migrations, no risk of corruption.</p>
<p>The schema is tiny:</p>
<pre><code class="language-swift">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'
);
</code></pre>
<p>Opening it from Swift with the system SQLite library:</p>
<pre><code class="language-swift">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, &amp;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 -&gt; [(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, &amp;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)
</code></pre>
<p>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.</p>
<hr />
<h2>4. Search that feels instant</h2>
<p>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:</p>
<pre><code class="language-swift">extension BibleTextStore {
    struct Hit { let ref: VerseRef; let snippet: String }

    func search(_ query: String, translation: String, limit: Int = 100) throws -&gt; [Hit] {
        // Turn "love one another" into "love* one* another*": prefix match on every word.
        let terms = query
            .split(whereSeparator: { !$0.isLetter &amp;&amp; !$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, &amp;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
    }
}
</code></pre>
<p>A few things that matter more than the SQL:</p>
<ul>
<li><strong>Sanitise the query.</strong> 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.</li>
<li><strong>Debounce in the UI.</strong> Search on every keystroke, but cancel the previous task. A <code>.task(id: query)</code> modifier in SwiftUI with a short <code>Task.sleep</code> at the top gives you that for free.</li>
<li><strong>Run it off the main thread.</strong> It's fast, but "fast" on an older iPhone in Low Power Mode isn't the same as on your desk.</li>
<li><strong>Search history is user data</strong>, so it goes in the user store (next section), not the read-only file.</li>
</ul>
<hr />
<h2>5. User data: Core Data with CloudKit mirroring</h2>
<p>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 <strong><code>NSPersistentCloudKitContainer</code></strong> (iOS 13+), which keeps a normal local Core Data store and mirrors it to the user's private CloudKit database in the background.</p>
<p>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.</p>
<pre><code class="language-swift">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
    }
}
</code></pre>
<p>The <code>Highlight</code> entity is deliberately boring: <code>verseID</code> (Integer 64), <code>translation</code> (String, optional), <code>colorName</code> (String), <code>createdAt</code> (Date) and a UUID <code>id</code>. A few CloudKit rules shape that model, and they catch everyone once:</p>
<ul>
<li><strong>Every attribute must be optional or have a default value.</strong> CloudKit can deliver records in any order and partially.</li>
<li><strong>No unique constraints.</strong> 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 <code>verseID</code>).</li>
<li><strong>Relationships must be optional</strong> and need inverses.</li>
<li><strong>Enable the capabilities:</strong> iCloud with CloudKit and your container, plus Background Modes â†’ Remote notifications, so pushes from CloudKit can wake the app to sync.</li>
<li><strong>Deploy the schema to production</strong> in the CloudKit Console before you ship. A development schema works on your device and silently fails for users. During development, <code>initializeCloudKitSchema(options:)</code> can create the schema from your model for you.</li>
</ul>
<p>Saving a highlight is then plain Core Data:</p>
<pre><code class="language-swift">extension UserStore {
    func toggleHighlight(_ ref: VerseRef, color: String) throws {
        let context = container.viewContext
        let request = NSFetchRequest&lt;NSManagedObject&gt;(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()
    }
}
</code></pre>
<p>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.</p>
<hr />
<h2>6. Listen mode with AVSpeechSynthesizer</h2>
<p>The listing's "Listen &amp; Read" section mentions text-to-speech and auto-scroll. On iOS that's <code>AVSpeechSynthesizer</code> (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.</p>
<pre><code class="language-swift">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]
        }
    }
}
</code></pre>
<p>The view observes <code>currentVerse</code> and calls <code>ScrollViewReader.scrollTo(_:anchor:)</code> with the verse ID. For word-level highlighting, implement <code>speechSynthesizer(_:willSpeakRangeOfSpeechString:utterance:)</code> as well.</p>
<p>Two practical notes. If you want speech to continue with the screen locked ("perfect for listening while commuting"), set the audio session category to <code>.playback</code> and add the <code>audio</code> 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 <code>AVSpeechSynthesisVoice(language:)</code> returns <code>nil</code>.</p>
<hr />
<h2>7. Verse of the Day on the Home Screen with WidgetKit</h2>
<p>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:</p>
<ol>
<li><strong>The text database is read-only and in the bundle</strong>, so the widget extension can ship its own copy (or you can share it through an App Group container). No sync needed.</li>
<li><strong>The verse of the day is a deterministic function of the date.</strong> Every device shows the same verse without a server.</li>
</ol>
<pre><code class="language-swift">import WidgetKit
import SwiftUI

struct VerseEntry: TimelineEntry {
    let date: Date
    let reference: String
    let text: String
}

struct VerseProvider: TimelineProvider {
    func placeholder(in context: Context) -&gt; VerseEntry {
        VerseEntry(date: .now, reference: "John 3:16", text: "For God so loved the worldâ€¦")
    }

    func getSnapshot(in context: Context, completion: @escaping (VerseEntry) -&gt; Void) {
        completion(entry(for: .now))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline&lt;VerseEntry&gt;) -&gt; 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) -&gt; 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])
    }
}
</code></pre>
<p><code>DailyVerses.list</code> is a small curated array compiled into the extension. With an iOS 16 deployment target, wrap <code>containerBackground</code> in an availability check, since that modifier is iOS 17+. The <code>.after(tomorrow)</code> 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.</p>
<hr />
<h2>8. Real-world example: Bible Study AI â€“ Read Offline</h2>
<p>All of this comes from building and maintaining my own app, <strong><a href="https://apps.apple.com/us/app/bible-study-ai-read-offline/id1508795537">Bible Study AI â€“ Read Offline</a></strong>. 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.</p>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-offline-first-bible-reader-on-ios/bible-study-ai-screenshot-1.jpg" alt="Bible Study AI App Store screenshot 1" /></p>
<p>At the time of writing, its public App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in the <strong>Books</strong> and <strong>Education</strong> categories</li>
<li>First released in <strong>April 2020</strong>: my first app on the store</li>
<li><strong>113 ratings</strong> in the US store, averaging about <strong>4.6 stars</strong></li>
<li>Supports <strong>iOS 16.0 and later</strong>, on iPhone and iPad</li>
</ul>
<p>Here's how the listing's features map to the sections above:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Read all 66 books of the Bible offline" / "Multiple translations: KJV, ASV, Webster, World English, and more"</td>
<td>Bundled read-only SQLite, one table keyed by translation + verse ID (sections 2â€“3)</td>
</tr>
<tr>
<td>"Search across all books, chapters, and verses" / "Advanced search with history"</td>
<td>FTS5 index shipped in the file, sanitised prefix queries (section 4)</td>
</tr>
<tr>
<td>"Highlight verses with multiple colors" / "Add personal notes" / "Bookmark favorite passages"</td>
<td>Small Core Data entities keyed by verse ID (section 5)</td>
</tr>
<tr>
<td>"iCloud sync across all your devices"</td>
<td><code>NSPersistentCloudKitContainer</code> mirroring to the private database (section 5)</td>
</tr>
<tr>
<td>"Text-to-speech support" / "Auto-scroll for hands-free reading"</td>
<td><code>AVSpeechSynthesizer</code> and its delegate driving the scroll position (section 6)</td>
</tr>
<tr>
<td>"Home screen widgets: quick access to verse of the day"</td>
<td>WidgetKit <code>StaticConfiguration</code>, date-derived entry (section 7)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/building-an-offline-first-bible-reader-on-ios/bible-study-ai-screenshot-2.jpg" alt="Bible Study AI App Store screenshot 2" /></p>
<p>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 <em>isn't</em> offline, and the most important design rule there is simply to make sure the rest of the app never waits on it.</p>
<hr />
<h2>9. Before you ship an offline-first reader: a checklist</h2>
<ol>
<li><strong>Airplane-mode test from a fresh install.</strong> 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.</li>
<li><strong>Text and user data are separate.</strong> 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.</li>
<li><strong>Stable IDs everywhere.</strong> Every highlight, note and bookmark points at a canonical verse ID, never at a row number or a translation-specific string.</li>
<li><strong>The CloudKit model follows the rules.</strong> Optional attributes or defaults, no unique constraints, optional relationships with inverses, and the schema is deployed to production.</li>
<li><strong>Duplicates are handled.</strong> Two offline devices editing the same verse produce two records; your code merges them predictably.</li>
<li><strong>Search can't crash on user input.</strong> Quotes, hyphens, emoji and other scripts are all tested against your FTS query builder.</li>
<li><strong>Background audio is honest.</strong> The <code>audio</code> mode is used only while read-aloud is playing, and the session is deactivated when it stops.</li>
<li><strong>The widget works with no app launch.</strong> Install, add the widget, never open the app: it should still show a verse.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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 <code>AVSpeechSynthesizer</code> and WidgetKit and you get most of what people want from a daily reading app, without a single required network call.</p>
<p>If you want to see where this ended up, try the app:</p>
<ul>
<li><strong>Bible Study AI â€“ Read Offline (iOS):</strong> <a href="https://apps.apple.com/us/app/bible-study-ai-read-offline/id1508795537">https://apps.apple.com/us/app/bible-study-ai-read-offline/id1508795537</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>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.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/building-an-offline-first-bible-reader-on-ios/">https://bestlatestapps.com/blog/building-an-offline-first-bible-reader-on-ios/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Convert-As-You-Type Currency Conversion in Swift: Decimal Maths, Cached Rates and Locale-Aware Formatting]]></title><description><![CDATA[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+…]]></description><link>https://aryansbtloe.hashnode.dev/convert-as-you-type-currency-conversion-in-swift-decimal-maths-cached-rates-and-locale-aware-formatting</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/convert-as-you-type-currency-conversion-in-swift-decimal-maths-cached-rates-and-locale-aware-formatting</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[currency]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:02:58 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/convert-as-you-type-currency-conversion-in-swift/currency-converter-icon-512.jpg" alt="Currency Converter : Fast app icon" /></p>
<p>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 <code>0.30000000000000004</code> on the screen.</p>
<p><strong>Currency Converter : Fast</strong> 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".</p>
<p>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.</p>
<hr />
<h2>1. Money is not a Double</h2>
<p>Binary floating point can't represent most decimal fractions exactly. <code>0.1 + 0.2</code> isn't <code>0.3</code>, and after a few multiplications by exchange rates, the error shows up in exactly the decimal places you're displaying.</p>
<p>Foundation's <code>Decimal</code> is a base-10 number type, so <code>0.1</code> really is 0.1:</p>
<pre><code class="language-swift">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
</code></pre>
<p>The rule I follow: <strong>amounts and rates are <code>Decimal</code> from the moment they enter the app</strong>, whether that's the user's typing or the JSON from the rates API, until the moment they're formatted for display. Two gotchas:</p>
<ul>
<li><code>Decimal(0.1)</code> converts <em>from a Double</em>, so the binary error comes with it. Initialise from strings: <code>Decimal(string:locale:)</code>.</li>
<li><code>JSONDecoder</code> decodes <code>Decimal</code> fields via <code>Double</code> internally on some systems, which can reintroduce error in the last digits. If exactness matters, decode rates as <code>String</code>s and convert them yourself, as below.</li>
</ul>
<hr />
<h2>2. One base, every cross rate</h2>
<p>You don't need a 150 Ã— 150 table. Fetch every currency's rate against <strong>one base</strong> (say USD), and derive any pair:</p>
<pre><code class="language-swift">amount_in_B = amount_in_A Ã— (rate_B / rate_A)       where rate_X = units of X per 1 base
</code></pre>
<pre><code class="language-swift">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) -&gt; 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) -&gt; 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")) }
    }
}
</code></pre>
<p><code>RateTable.rate(from:to:)</code> is also how you show "the current exchange rate of what you're converting", one of the listing's features: <code>1 EUR = 1.087 USD</code>, computed from the same table.</p>
<p>Note the <code>en_US_POSIX</code> locale when parsing API data. API numbers always use <code>.</code> as the decimal separator, whatever the user's device language is.</p>
<hr />
<h2>3. Convert as you type</h2>
<p>With <code>Decimal</code> maths and a local rate table, conversion is instant and synchronous. There's no reason for a button. In SwiftUI:</p>
<pre><code class="language-swift">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)
        }
    }
}
</code></pre>
<p>Because <code>result</code> is a computed property, every keystroke recomputes it: there's no debounce and no task. <code>CurrencyPicker</code> is a searchable list of codes (section 5).</p>
<p>The decimal-pad keyboard shows the user's locale separator, and the <code>NumberFormatter</code> uses <code>Locale.current</code> by default, so "12,50" in Germany and "12.50" in the US both parse.</p>
<hr />
<h2>4. Rates that update themselves, and work offline</h2>
<p>"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.</p>
<pre><code class="language-swift">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() -&gt; 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 -&gt; RateTable? {
        if !force, let c = cached(), Date().timeIntervalSince(c.fetchedAt) &lt; 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
        }
    }
}
</code></pre>
<p>Key behaviours:</p>
<ul>
<li><strong>Show the cache immediately</strong>, then refresh. The UI never waits on the network.</li>
<li><strong>On failure, keep the last good table.</strong> A slightly old rate is far more useful than an error screen at a market stall.</li>
<li><strong>Show the age.</strong> "Rates updated 3 hours ago" in small text is honest, and it's the one piece of UI that explains a surprising number.</li>
<li><strong>Bundle a seed table</strong> in the app, so a first launch in Airplane Mode still converts. Label it with its date.</li>
</ul>
<p>For background refresh, SwiftUI's <code>backgroundTask(_:action:)</code> scene modifier (iOS 16+) with <code>.appRefresh</code> pairs with <code>BGAppRefreshTaskRequest</code> (iOS 13+):</p>
<pre><code class="language-swift">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)
}
</code></pre>
<p>Add <code>rates.refresh</code> to <code>BGTaskSchedulerPermittedIdentifiers</code> in Info.plist and enable Background Modes â†’ Background fetch. Remember that <code>earliestBeginDate</code> 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.</p>
<hr />
<h2>5. 150+ currencies: names, symbols and decimals</h2>
<p>Foundation knows a great deal about currencies, so you don't have to maintain that list by hand:</p>
<pre><code class="language-swift">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) -&gt; [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) -&gt; 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
</code></pre>
<p><code>localizedString(forCurrencyCode:)</code> gives "Colombian Peso" in English and "Peso colombiano" in Spanish for free. <code>Decimal.FormatStyle.Currency</code> (iOS 15+) handles the symbol, its position, grouping and the decimal separator for the user's locale.</p>
<p>About <strong>three decimal places</strong>: 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 <code>.precision(.fractionLength(0...3))</code> with rounding, or read the currency's default fraction digits from a <code>NumberFormatter</code> set to <code>.currency</code> style.</p>
<p>Make the picker <strong>searchable by code and by name</strong> (<code>.searchable(text:)</code>), and put recently used currencies at the top. Most people convert between the same two or three currencies over and over.</p>
<hr />
<h2>6. Real-world example: Currency Converter : Fast</h2>
<p>This is the approach I'd use, and recommend, for <strong><a href="https://apps.apple.com/us/app/currency-converter-fast/id1509291290">Currency Converter : Fast</a></strong>.</p>
<p><img src="https://bestlatestapps.com/assets/blog/convert-as-you-type-currency-conversion-in-swift/currency-converter-screenshot-1.jpg" alt="Currency Converter App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Finance</strong> and <strong>Productivity</strong></li>
<li>First released in <strong>April 2020</strong></li>
<li><strong>7 ratings</strong> in the US store, averaging about <strong>4.7 stars</strong></li>
<li>Supports <strong>iOS 17.6 and later</strong></li>
</ul>
<p>How the listing's features map to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Over 150 currencies supported" / "from the US Dollar to the Colombian Peso"</td>
<td>One-base rate table plus Foundation's currency names (sections 2, 5)</td>
</tr>
<tr>
<td>"Converts as you type (no need for a 'Convert' button!)"</td>
<td>Synchronous <code>Decimal</code> conversion as a computed property (section 3)</td>
</tr>
<tr>
<td>"Exchange rates are updated automatically â€“ you don't have to do anything!"</td>
<td>Cache-first store, foreground refresh, background app refresh (section 4)</td>
</tr>
<tr>
<td>"Uses 3 decimal places for greater accuracy!"</td>
<td><code>Decimal</code> maths and <code>.precision(.fractionLength(3))</code> (sections 1, 5)</td>
</tr>
<tr>
<td>"Find the current exchange rate of what you're converting"</td>
<td><code>RateTable.rate(from:to:)</code> (section 2)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/convert-as-you-type-currency-conversion-in-swift/currency-converter-screenshot-2.jpg" alt="Currency Converter App Store screenshot 2" /></p>
<p>Because the app targets iOS 17.6 and later, everything above, including <code>contentTransition(.numericText())</code>, the two-parameter <code>onChange</code> and the SwiftUI background task, is available without availability checks.</p>
<hr />
<h2>7. Before you ship a currency converter: a checklist</h2>
<ol>
<li><strong><code>Decimal</code> end to end.</strong> No <code>Double</code> between parsing and formatting, and no <code>Decimal(someDouble)</code>.</li>
<li><strong>API numbers are parsed with <code>en_US_POSIX</code></strong>, and user input with the user's locale.</li>
<li><strong>Cross rates come from one base.</strong> Test that A â†’ B â†’ A returns the original amount, within rounding.</li>
<li><strong>The cache is shown first.</strong> Launch in Airplane Mode: you see the last rates and their age, not an error.</li>
<li><strong>A seed table ships in the bundle</strong> for first-launch offline use.</li>
<li><strong>Background refresh is configured</strong> (permitted identifiers, background mode), and foreground refresh does the real work.</li>
<li><strong>Formatting is tested in several locales</strong>: en_US, de_DE, fr_FR, ja_JP and ar, at least.</li>
<li><strong>Zero-decimal and three-decimal currencies look right</strong>, according to your product decision.</li>
<li><strong>Rates are labelled honestly.</strong> These are indicative mid-market-style rates, not what a bank or card will charge.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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 <code>Decimal</code>, 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.</p>
<p>Try it, or explore my other apps:</p>
<ul>
<li><strong>Currency Converter : Fast (iOS):</strong> <a href="https://apps.apple.com/us/app/currency-converter-fast/id1509291290">https://apps.apple.com/us/app/currency-converter-fast/id1509291290</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>How do you handle money in Swift: <code>Decimal</code>, integer minor units, or a library? I'd love to hear the trade-offs you've found in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/convert-as-you-type-currency-conversion-in-swift/">https://bestlatestapps.com/blog/convert-as-you-type-currency-conversion-in-swift/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Drag-to-Connect and Game Center Leaderboards in a SwiftUI Puzzle Game, Updated for iOS 26]]></title><description><![CDATA[A dot-matching board drawn with Canvas and TimelineView, drag gestures that chain same-colour dots, speed-based scoring and rising difficulty, pause and…]]></description><link>https://aryansbtloe.hashnode.dev/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game-updated-for-ios-26</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game-updated-for-ios-26</guid><category><![CDATA[Swift]]></category><category><![CDATA[SwiftUI]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Game Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:02:30 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game/speck-match-icon-512.jpg" alt="Speck Match app icon" /></p>
<p><strong>Speck Match</strong> 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.</p>
<p>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 <strong>drag-to-connect mechanic built entirely in SwiftUI</strong>, with <code>Canvas</code> and <code>TimelineView</code> instead of a game engine. The second is <strong>Game Center leaderboards in depth</strong>, which have changed more in the last few iOS releases than most developers realise. The latest change: Apple's documentation now marks <code>GKGameCenterViewController</code> as <strong>deprecated in iOS 26</strong>.</p>
<p>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.</p>
<hr />
<h2>1. The board as plain data</h2>
<p>Keep the game state free of SwiftUI, so you can unit-test it:</p>
<pre><code class="language-swift">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 &gt;= 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)
}
</code></pre>
<p>"The game gets faster and more challenging as you progress" becomes one computed property, <code>spawnInterval</code>, and "Multiple difficulty levels" becomes three rows of numbers. The values are illustrative; tune them by playing.</p>
<hr />
<h2>2. Drawing and ticking with Canvas and TimelineView</h2>
<p>For a few dozen circles, SwiftUI's <code>Canvas</code> (iOS 15+) is fast and simple. It's immediate-mode drawing, so you're not creating one view per dot. <code>TimelineView(.animation(minimumInterval:paused:))</code> (iOS 15+) gives you a frame clock that <strong>can be paused</strong>, which matters later:</p>
<pre><code class="language-swift">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 &gt; 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() } }
    }
}
</code></pre>
<p>This code uses iOS 17 conveniences (<code>@Observable</code> and the two-parameter <code>onChange</code>). For the app's iOS 15.6 minimum, the same model works as an <code>ObservableObject</code> with the single-parameter <code>onChange(of:perform:)</code>. Two more notes. Keep <code>tick</code> cheap: spawn when due, check the overfull condition, and nothing else. And auto-pause when the scene leaves <code>.active</code>. A phone call shouldn't cost a player their best run, and the listing promises "Pause and resume anytime".</p>
<hr />
<h2>3. Drag to connect</h2>
<p>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.</p>
<pre><code class="language-swift">@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) -&gt; Speck? {
        board.specks.first { hypot($0.position.x - point.x, $0.position.y - point.y) &lt;= 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 &gt;= 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 &gt;= 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 &gt;= 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() }
    }
}
</code></pre>
<p><code>DragGesture(minimumDistance: 0)</code> 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.</p>
<hr />
<h2>4. Scoring speed</h2>
<p>"Speed matters â€“ faster matches = higher scores." There are many ways to express that. The approach I'd use rewards <strong>how fresh the dots were</strong> when you cleared them, plus a bonus for longer chains:</p>
<pre><code class="language-swift">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) -&gt; Int {
        let perDot = specks.map { speck -&gt; 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())
    }
}
</code></pre>
<p>The <code>bornAt</code> timestamp uses <strong>game time</strong> (<code>board.elapsed</code>), not wall-clock time. Otherwise pausing would make every dot on screen "old", and the player would be punished for taking a break.</p>
<hr />
<h2>5. Signing in to Game Center</h2>
<p>Leaderboards start with authentication. On iOS, you set <code>GKLocalPlayer.local.authenticateHandler</code> once, early. GameKit calls it, possibly more than once, with either a view controller to present or the result:</p>
<pre><code class="language-swift">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)") }
        }
    }
}
</code></pre>
<p>Rules I follow:</p>
<ul>
<li><strong>Never block the game on Game Center.</strong> If the player declines or is offline, the game plays normally and leaderboards just aren't offered.</li>
<li><strong>Present the sign-in view controller from a real, visible controller.</strong> Presenting from a controller that isn't in the window is a classic "Game Center never shows" bug.</li>
<li><strong>Show the access point only on menus.</strong> <code>GKAccessPoint</code> (iOS 14+) is a small Game Center badge that the system draws. Set <code>isActive = false</code> during play so it doesn't overlap the board.</li>
</ul>
<hr />
<h2>6. Submitting scores the modern way</h2>
<p>The old <code>GKScore</code> class is <strong>deprecated since iOS 14</strong>. The current API is a single class method on <code>GKLeaderboard</code>:</p>
<pre><code class="language-swift">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)") }
        }
    }
}
</code></pre>
<p>Design choices:</p>
<ul>
<li><strong>One leaderboard per difficulty.</strong> 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.</li>
<li><strong>Submit at game over, not per match.</strong> One call per run is plenty. Game Center keeps each player's best score according to the leaderboard's settings.</li>
<li><strong>Configure the sort order and format in App Store Connect</strong> (high to low, integer). The client just sends numbers.</li>
</ul>
<hr />
<h2>7. Showing leaderboards in iOS 18 and 26</h2>
<p>This is where things have moved. For years the answer was "present a <code>GKGameCenterViewController</code>". Apple's documentation now marks <strong><code>GKGameCenterViewController</code> deprecated in iOS 26</strong>. Meanwhile, <code>GKAccessPoint</code> gained <code>trigger(leaderboardID:playerScope:timeScope:handler:)</code> in <strong>iOS 18</strong>, which opens the system Game Center UI straight to one leaderboard.</p>
<p>Since Speck Match supports iOS 15.6 and later, the approach I'd use branches:</p>
<pre><code class="language-swift">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 -&gt; [(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) }
    }
}
</code></pre>
<p><code>loadEntries(for:timeScope:range:)</code> returns the local player's entry, the requested range and the total count. Use <code>formattedScore</code> rather than formatting the raw value yourself, so the display matches what Game Center shows everywhere else.</p>
<p>The app's most recent release notes include "Fixed Game Center leaderboard display issue". That's a reminder that the leaderboard <em>display</em> path is the fragile part. Test it on every iOS version you support, and especially on both sides of the iOS 18 branch.</p>
<hr />
<h2>8. Real-world example: Speck Match</h2>
<p>This walkthrough is grounded in building <strong><a href="https://apps.apple.com/us/app/speck-match/id1600688336">Speck Match</a></strong>. The code is the approach I'd use, written for this article, not the shipped source.</p>
<p><img src="https://bestlatestapps.com/assets/blog/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game/speck-match-screenshot-1.jpg" alt="Speck Match App Store screenshot" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free to play</strong> ("with optional ads that can be removed"), in <strong>Games</strong> (Board, Strategy)</li>
<li>First released in <strong>December 2021</strong></li>
<li><strong>1 rating</strong> in the US store, <strong>5 stars</strong></li>
<li>Supports <strong>iOS 15.6 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Tap and drag to connect matching dots"</td>
<td><code>DragGesture</code> chains (section 3)</td>
</tr>
<tr>
<td>"Game gets faster and more challenging as you progress" / "Multiple difficulty levels"</td>
<td><code>spawnInterval</code> and difficulty presets (section 1)</td>
</tr>
<tr>
<td>"Speed matters - faster matches = higher scores"</td>
<td>Freshness-based scoring (section 4)</td>
</tr>
<tr>
<td>"Pause and resume anytime"</td>
<td>A pausable <code>TimelineView</code> and game time (sections 2 and 4)</td>
</tr>
<tr>
<td>"Game Center integration" / "Global leaderboards"</td>
<td>Authentication, submitting and display (sections 5â€“7)</td>
</tr>
<tr>
<td>Release notes: "Fixed Game Center leaderboard display issue"</td>
<td>Version-branching the display path (section 7)</td>
</tr>
</tbody></table>
<hr />
<h2>9. Before you ship a Game Center game: a checklist</h2>
<ol>
<li><strong>The game plays fully without Game Center.</strong> Declined sign-in and offline are both tested.</li>
<li><strong><code>authenticateHandler</code> is set once, early</strong>, and presents from a visible view controller.</li>
<li><strong>There's no <code>GKScore</code>.</strong> Use <code>GKLeaderboard.submitScore</code>, once per run.</li>
<li><strong>There's one leaderboard per difficulty</strong>, with IDs matching App Store Connect exactly.</li>
<li><strong>The display path is branched.</strong> <code>GKAccessPoint.trigger(leaderboardID:â€¦)</code> on iOS 18+, the view controller on older systems, and both tested.</li>
<li><strong>The access point is hidden during play.</strong></li>
<li><strong>Scores use game time.</strong> Pausing never changes the outcome.</li>
<li><strong>Hit areas are generous</strong>, and <code>minimumDistance: 0</code> is set for instant drags.</li>
<li><strong>Colour isn't the only cue.</strong> Consider shapes for colour-blind players.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>A drag-to-connect game shows how far plain SwiftUI goes. <code>Canvas</code> draws the board, <code>TimelineView</code> ticks it, <code>DragGesture</code> drives it, and nothing needs a game engine. Game Center is the part that keeps evolving. Use the iOS 14 <code>GKLeaderboard</code> APIs, adopt the iOS 18 access-point trigger, and plan for the iOS 26 deprecation of the old dashboard controller.</p>
<p>Play the game, or explore my other work:</p>
<ul>
<li><strong>Speck Match (iOS):</strong> <a href="https://apps.apple.com/us/app/speck-match/id1600688336">https://apps.apple.com/us/app/speck-match/id1600688336</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>Think you can top the leaderboard? Let me know your score in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game/">https://bestlatestapps.com/blog/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[From Barcode to PDF: Building a Private, Offline Medication Tracker with VisionKit, WidgetKit and PDFKit]]></title><description><![CDATA[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…]]></description><link>https://aryansbtloe.hashnode.dev/from-barcode-to-pdf-building-a-private-offline-medication-tracker-with-visionkit-widgetkit-and-pdfkit</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/from-barcode-to-pdf-building-a-private-offline-medication-tracker-with-visionkit-widgetkit-and-pdfkit</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[widgetkit]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:02:03 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/from-barcode-to-pdf/track-your-pills-icon-512.jpg" alt="Track Your Pills app icon" /></p>
<p>I have two medication apps on the App Store. The first, <strong>Daily Pills | Reminder</strong>, does one thing: remind you on time and count your supply. I've written about its notification engineering separately. The second, <strong>Track Your Pills</strong>, 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?"</p>
<p>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."</p>
<p>This article covers the parts that are <em>different</em> 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 <code>UIGraphicsPDFRenderer</code>, 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.</p>
<hr />
<h2>1. The model, shared by the app and its widgets</h2>
<p>The app targets iOS 17.6 and later, so SwiftData is available. The model separates <em>what</em> the medication is from <em>what happened</em>:</p>
<pre><code class="language-swift">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
    }
}
</code></pre>
<p><code>@Attribute(.externalStorage)</code> keeps photos out of the main database file. Each intake records both the <em>scheduled</em> time and the <em>logged</em> time, which lets the report distinguish "taken on time" from "taken two hours late".</p>
<p>Widgets run in a separate process, so the store has to live somewhere both processes can reach. That's an <strong>App Group</strong> container:</p>
<pre><code class="language-swift">enum SharedStore {
    static let appGroup = "group.com.example.trackyourpills"

    static func makeContainer() throws -&gt; 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)
    }
}
</code></pre>
<p>Add the App Groups capability to both the app and widget targets with the same identifier. <code>cloudKitDatabase: .none</code> is the explicit, privacy-preserving choice. SwiftData otherwise looks for a CloudKit container if the iCloud capability is present.</p>
<hr />
<h2>2. Adding a medication by scanning its barcode</h2>
<p><code>DataScannerViewController</code> (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.</p>
<pre><code class="language-swift">import SwiftUI
import VisionKit

struct BarcodeScannerView: UIViewControllerRepresentable {
    var onCode: (String) -&gt; Void

    static var canScan: Bool {
        DataScannerViewController.isSupported &amp;&amp; DataScannerViewController.isAvailable
    }

    func makeUIViewController(context: Context) -&gt; 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() -&gt; Coordinator { Coordinator(onCode: onCode) }

    final class Coordinator: NSObject, DataScannerViewControllerDelegate {
        let onCode: (String) -&gt; Void
        private var delivered = false
        init(onCode: @escaping (String) -&gt; 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
                }
            }
        }
    }
}
</code></pre>
<p>Practical notes:</p>
<ul>
<li><strong>Check both flags.</strong> Apple's docs say <code>isSupported</code> requires a device with the <strong>A12 Bionic chip or later</strong>. <code>isAvailable</code> additionally reflects camera permission and restrictions. When either is false, fall back to manual entry; never show a dead button.</li>
<li><strong>Add <code>NSCameraUsageDescription</code></strong>, for example: "Scan the barcode on your medication box to fill in its details."</li>
<li><strong>Pick the symbologies you need.</strong> Retail packaging is mostly EAN/UPC, and many pharmaceutical packs carry a GS1 DataMatrix. A narrower list means faster, more reliable recognition.</li>
</ul>
<h3>From code to name: an on-device lookup</h3>
<p>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 <strong>bundled, read-only lookup table</strong>: 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.</p>
<pre><code class="language-swift">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) -&gt; Entry? { byBarcode[code] }
}
</code></pre>
<p>Always show the result as an editable form, never a silent auto-save. A wrong strength is a dangerous error.</p>
<hr />
<h2>3. Adherence: the numbers behind the calendar</h2>
<p>"Visual adherence calendar", "weekly and overall adherence statistics" and "per-medication adherence metrics" all come from one calculation: of the doses that were <strong>due</strong>, how many were <strong>taken</strong>?</p>
<pre><code class="language-swift">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) -&gt; AdherenceStats {
        let due = intakes.filter { interval.contains($0.scheduledAt) &amp;&amp; $0.status != .skipped }
        let taken = due.filter { $0.status == .taken }
        let late = taken.filter { ($0.loggedAt ?? $0.scheduledAt)
            .timeIntervalSince($0.scheduledAt) &gt; 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)
        -&gt; [(day: Date, rate: Double?)] {
        let today = calendar.startOfDay(for: .now)
        return (0..&lt;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
        }
    }
}
</code></pre>
<p>Two definitions you have to choose deliberately, and should explain on screen:</p>
<ul>
<li><strong>Skipped is not missed.</strong> If a doctor said "skip today", that dose shouldn't hurt the adherence score. Exclude skipped doses from <em>due</em>.</li>
<li><strong>No dose scheduled â‰  100%.</strong> A day with nothing due should render as empty, not as a perfect green square.</li>
</ul>
<p>Swift Charts' <code>RectangleMark</code> makes the calendar heat map straightforward, with <code>foregroundStyle(by:)</code> binned into three or four colour bands.</p>
<hr />
<h2>4. A doctor-ready PDF with UIGraphicsPDFRenderer</h2>
<p>"Generate detailed medication reportsâ€¦ Share reports with healthcare providersâ€¦ Printable format for medical records." <code>UIGraphicsPDFRenderer</code> (iOS 10+) turns drawing code into PDF data. A report is just text and table rows drawn page by page:</p>
<pre><code class="language-swift">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]) -&gt; 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 &gt; 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 &lt; 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)])
        }
    }
}
</code></pre>
<p>To preview it, wrap <code>PDFView</code> (PDFKit, iOS 11+) in a <code>UIViewRepresentable</code>, set <code>document = PDFDocument(data: data)</code> and <code>autoScales = true</code>. 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 <code>ShareLink</code>. The system share sheet includes Print, Mail and Files, so you don't need any extra UI for "printable".</p>
<hr />
<h2>5. Widgets that read the shared store</h2>
<p>"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:</p>
<pre><code class="language-swift">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) -&gt; NextDoseEntry {
        NextDoseEntry(date: .now, upcoming: [("Vitamin D", .now)], todayRate: 1)
    }

    func getSnapshot(in context: Context, completion: @escaping (NextDoseEntry) -&gt; Void) {
        completion(makeEntry(at: .now))
    }

    func getTimeline(in context: Context, completion: @escaping (Timeline&lt;NextDoseEntry&gt;) -&gt; 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) -&gt; 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&lt;Medication&gt;())) ?? []
        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 &lt; $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)
    }
}
</code></pre>
<p>When the app logs a dose, call <code>WidgetCenter.shared.reloadTimelines(ofKind:)</code>, so the widget updates now rather than at its next scheduled refresh.</p>
<p>Keep widget work small. Fetch, compute three items and return. Widgets have tight time and memory budgets.</p>
<hr />
<h2>6. Export, backup and restore without a server</h2>
<p>"Export data in CSV or JSON formatsâ€¦ Complete backup and restoreâ€¦ Privacy-focused: all data stored locally." With no account and no cloud, export <em>is</em> the backup strategy, so treat it as a first-class feature.</p>
<pre><code class="language-swift">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]) -&gt; String {
        func esc(_ s: String) -&gt; 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 &lt; $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")
    }
}
</code></pre>
<p><code>ShareLink(item: backup, preview: SharePreview("Backup"))</code> (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, <code>.fileImporter(isPresented:allowedContentTypes:onCompletion:)</code> with <code>[.json]</code> picks the file. Decode it, check <code>version</code>, and import inside a single <code>ModelContext</code> transaction, so a bad file can't leave half a database.</p>
<p>A design rule I'd hold to: <strong>JSON is the backup, CSV is the report.</strong> CSV is for spreadsheets and doctors and loses structure; only JSON round-trips. Label them that way in the UI.</p>
<p>If you use <code>fileExporter</code> instead of <code>ShareLink</code>, note that Apple's current docs mark the original <code>fileExporter(isPresented:document:contentType:defaultFilename:onCompletion:)</code> as deprecated in iOS 27.2, in favour of the overload that adds <code>onCancellation:</code>.</p>
<hr />
<h2>7. Interaction warnings: handle with great care</h2>
<p>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:</p>
<ul>
<li><strong>Offline data ages.</strong> Show the data's version date in the warning.</li>
<li><strong>Absence of a warning isn't safety.</strong> Word it as "No interactions found <em>in this app's list</em>", never "safe to combine".</li>
<li><strong>Never block, always refer.</strong> Warn, show the source and recommend checking with a pharmacist or doctor.</li>
</ul>
<p>I won't publish a table here; the content is the hard part, and it must come from a reliable, licensed source.</p>
<hr />
<h2>8. Real-world example: Track Your Pills</h2>
<p>All of this describes the approach I'd use, and recommend, for <strong><a href="https://apps.apple.com/us/app/track-your-pills/id1660782134">Track Your Pills</a></strong>.</p>
<p><img src="https://bestlatestapps.com/assets/blog/from-barcode-to-pdf/track-your-pills-screenshot-1.jpg" alt="Track Your Pills App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Medical</strong> and <strong>Health &amp; Fitness</strong></li>
<li>First released in <strong>February 2023</strong></li>
<li><strong>7 ratings</strong> in the US store, averaging about <strong>4.7 stars</strong></li>
<li>Supports <strong>iOS 17.6 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Add medications with photos, barcodes, or manual entry" / "Barcode scanning for instant medication lookup"</td>
<td><code>DataScannerViewController</code> plus a bundled lookup, with manual fallback (section 2)</td>
</tr>
<tr>
<td>"Support for tablets, capsules, syrups, and injections"</td>
<td><code>form</code> and a <code>Double</code> quantity in the model (section 1)</td>
</tr>
<tr>
<td>"Visual adherence calendarâ€¦ Weekly and overall adherence statistics"</td>
<td>Due vs taken, skipped excluded (section 3)</td>
</tr>
<tr>
<td>"Generate detailed medication reportsâ€¦ Share reports with healthcare providers"</td>
<td><code>UIGraphicsPDFRenderer</code>, <code>PDFView</code> preview, <code>ShareLink</code> (section 4)</td>
</tr>
<tr>
<td>"Home screen widgetsâ€¦ Upcoming medication remindersâ€¦ Adherence at a glance"</td>
<td>App Group SwiftData store read by a <code>TimelineProvider</code> (sections 1, 5)</td>
</tr>
<tr>
<td>"Export data in CSV or JSON formatsâ€¦ Complete backup and restore"</td>
<td><code>Transferable</code> JSON backup, CSV report, versioned import (section 6)</td>
</tr>
<tr>
<td>"All data stored locallyâ€¦ No cloud sync requiredâ€¦ No account creation needed"</td>
<td><code>cloudKitDatabase: .none</code>, no server, user-controlled export (sections 1, 6)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/from-barcode-to-pdf/track-your-pills-screenshot-2.jpg" alt="Track Your Pills App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>9. Before you ship a medication tracker: a checklist</h2>
<ol>
<li><strong>Scanning degrades gracefully.</strong> Unsupported devices and denied camera access both lead to manual entry.</li>
<li><strong>Scanned data is always reviewed by the user</strong> before it's saved.</li>
<li><strong>The store lives in an App Group</strong>, and widgets read it without crashing on first launch, before any data exists.</li>
<li><strong>Adherence definitions are explicit</strong>: skipped vs missed, late threshold, and days with nothing due.</li>
<li><strong>The PDF paginates</strong>, has a title, a date range and a disclaimer, and opens cleanly in Files and Mail.</li>
<li><strong>The JSON backup round-trips.</strong> Export, delete the app, reinstall, import: every record comes back.</li>
<li><strong>Backups are versioned</strong>, and import is all-or-nothing.</li>
<li><strong>Privacy claims are literally true</strong>: no CloudKit, no analytics surprises, and accurate App Privacy details.</li>
<li><strong>Interaction warnings are worded carefully</strong> and dated, and always refer the user to a professional.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Try it, or explore my other apps:</p>
<ul>
<li><strong>Track Your Pills (iOS):</strong> <a href="https://apps.apple.com/us/app/track-your-pills/id1660782134">https://apps.apple.com/us/app/track-your-pills/id1660782134</a></li>
<li><strong>Daily Pills | Reminder (iOS):</strong> <a href="https://apps.apple.com/us/app/daily-pills-reminder/id1510203960">https://apps.apple.com/us/app/daily-pills-reminder/id1510203960</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>If you've built health features with no backend, how did you handle backups? Tell me in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/from-barcode-to-pdf/">https://bestlatestapps.com/blog/from-barcode-to-pdf/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Medication Reminders That Actually Fire: Local Notifications, the 64-Notification Limit and Supply Math]]></title><description><![CDATA[Repeating calendar triggers, a notification budget, "Taken" and "Snooze" actions, run-out dates and on-device privacy, from building a pill reminder app…]]></description><link>https://aryansbtloe.hashnode.dev/medication-reminders-that-actually-fire-local-notifications-the-64-notification-limit-and-supply-math</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/medication-reminders-that-actually-fire-local-notifications-the-64-notification-limit-and-supply-math</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[notifications]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:01:36 GMT</pubDate><content:encoded><![CDATA[<p><em>Repeating calendar triggers, a notification budget, "Taken" and "Snooze" actions, run-out dates and on-device privacy, from building a pill reminder app</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/medication-reminders-that-actually-fire/daily-pills-icon-512.jpg" alt="Daily Pills | Reminder app icon" /></p>
<p>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.</p>
<p><strong>Daily Pills | Reminder</strong> 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."</p>
<p>That combination, reliable reminders with no server, rests entirely on <strong>local notifications</strong>, 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.</p>
<hr />
<h2>1. The model: keep it boring</h2>
<pre><code class="language-swift">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
}
</code></pre>
<p>Two choices to notice:</p>
<ul>
<li><strong>Reminder times are an hour and a minute, not a <code>Date</code>.</strong> "8:00 every morning" is a wall-clock intention. Storing it as a <code>Date</code> bakes in a time zone, and the reminder drifts when the user travels or when daylight saving changes.</li>
<li><strong>The log is append-only.</strong> Missed and taken doses are facts. Supply and adherence are computed from them, never stored separately where they could disagree.</li>
</ul>
<hr />
<h2>2. Repeating reminders with UNCalendarNotificationTrigger</h2>
<p>For "every day at 08:00", a single repeating calendar trigger is exactly right:</p>
<pre><code class="language-swift">import UserNotifications

enum ReminderScheduler {
    static let doseCategory = "DOSE_REMINDER"

    static func identifier(for med: Medication, time: ReminderTime) -&gt; 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)
    }
}
</code></pre>
<p>What makes this reliable:</p>
<ul>
<li><strong>Deterministic identifiers.</strong> <code>dose.&lt;medID&gt;.&lt;HHmm&gt;</code> means rescheduling replaces rather than duplicates, and cancelling one medication can't touch another's reminders.</li>
<li><strong>Date components without a time zone</strong> 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 <em>intervals</em> regardless of time zone, that's a different design: talk to the user, and consider fixed-interval triggers.)</li>
<li><strong><code>.timeSensitive</code> (iOS 15+)</strong> 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.</li>
</ul>
<p>And of course, ask permission first, at the moment the user adds their first reminder, with <code>requestAuthorization(options: [.alert, .sound, .badge])</code>.</p>
<hr />
<h2>3. The 64-notification limit, and a budget</h2>
<p>Here's the trap. iOS keeps only a limited number of pending local notifications per app. Apple's documentation for the older <code>UILocalNotification</code> API states it plainly: the system keeps the soonest-firing <strong>64</strong> notifications and <strong>discards the rest</strong>. Automatically repeating notifications count as a single notification. The same cap is widely observed with <code>UserNotifications</code>, and it fails <em>silently</em>: <code>add(_:)</code> succeeds and the 65th reminder just never fires.</p>
<p>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 <strong>one-off</strong> 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.</p>
<p>So treat the pending queue as a budget, and check it:</p>
<pre><code class="language-swift">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) -&gt; Int {
        meds.reduce(0) { $0 + $1.times.count } + extraOneOffs
    }

    static func canSchedule(_ meds: [Medication], extraOneOffs: Int = 0) -&gt; Bool {
        projected(for: meds, extraOneOffs: extraOneOffs) &lt;= 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:)) ?? "-") }
    }
}
</code></pre>
<p>If you genuinely need more than the budget allows, for example non-repeating schedules, schedule a <strong>rolling window</strong> (the next N days) and top it up whenever the app becomes active. Never assume "scheduled once" means "scheduled forever".</p>
<hr />
<h2>4. "Taken" and "Snooze" right from the notification</h2>
<p>A reminder you have to unlock the phone and open the app to acknowledge is a reminder people ignore. Register a category with actions:</p>
<pre><code class="language-swift">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
        }
    }
}
</code></pre>
<p>Register the categories and set the delegate early, in <code>application(_:didFinishLaunchingWithOptions:)</code> or your <code>App</code> initialiser, because the system may launch your app in the background specifically to handle an action.</p>
<p>Notice the action options are empty: no <code>.foreground</code> (which would open the app) and no <code>.authenticationRequired</code> (which forces an unlock). "Taken" works straight from the Lock Screen. That has a consequence for storage, which section 6 covers.</p>
<p>Using one fixed identifier per medication for snoozes means repeated snoozes <em>replace</em> each other instead of filling your 64-slot budget.</p>
<hr />
<h2>5. Supply maths: pills left, run-out date and a warning</h2>
<p>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:</p>
<pre><code class="language-swift">import Foundation

extension Medication {
    var pillsPerDay: Int { pillsPerDose * times.count }

    var daysOfSupplyLeft: Int {
        guard pillsPerDay &gt; 0 else { return .max }
        return pillsRemaining / pillsPerDay
    }

    func runOutDate(from now: Date = Date(), calendar: Calendar = .current) -&gt; Date? {
        guard pillsPerDay &gt; 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 &gt; 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)))
    }
}
</code></pre>
<p>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.</p>
<p><strong>Missed doses.</strong> The system can't tell you a reminder was <em>ignored</em>. When the app becomes active, compare each scheduled time since the last check against the log, and record <code>.missed</code> for any slot without a <code>.taken</code> 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").</p>
<hr />
<h2>6. Private, on-device storage that works on a locked phone</h2>
<p>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 <code>Codable</code> file in Application Support.</p>
<p>There's one subtlety. The "Taken" action from section 4 can run <strong>while the device is locked</strong>. If you save with the strongest file protection (<code>.completeFileProtection</code>), the file is unreadable while locked, and the write fails exactly when the user taps "Taken" on the Lock Screen.</p>
<pre><code class="language-swift">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()
    }
}
</code></pre>
<p><code>.completeFileProtectionUntilFirstUserAuthentication</code> (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.</p>
<hr />
<h2>7. Real-world example: Daily Pills | Reminder</h2>
<p>These are the techniques I'd use, and recommend, for <strong><a href="https://apps.apple.com/us/app/daily-pills-reminder/id1510203960">Daily Pills | Reminder</a></strong>.</p>
<p><img src="https://bestlatestapps.com/assets/blog/medication-reminders-that-actually-fire/daily-pills-screenshot-1.jpg" alt="Daily Pills App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Health &amp; Fitness</strong> and <strong>Medical</strong></li>
<li>First released in <strong>May 2020</strong></li>
<li><strong>8 ratings</strong> in the US store, averaging about <strong>4.4 stars</strong></li>
<li>Supports <strong>iOS 16.0 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Set daily reminder times that work for you" / "Multiple reminders per day"</td>
<td>One repeating calendar trigger per time, deterministic IDs (section 2)</td>
</tr>
<tr>
<td>"Never miss a dose"</td>
<td>Time-sensitive delivery, a notification budget, a rolling window if needed (sections 2â€“3)</td>
</tr>
<tr>
<td>"Add pills with one tap" / "Mark missed pills instantly"</td>
<td>An append-only dose log; Lock Screen actions (sections 1, 4)</td>
</tr>
<tr>
<td>"See how many pills you have left" / "Know when your supply will run out" / "Get notified before you run out"</td>
<td>Supply maths and a one-off low-supply warning (section 5)</td>
</tr>
<tr>
<td>"Your medication data stays private on your device"</td>
<td>Local <code>Codable</code> storage with the right file protection, no server (section 6)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/medication-reminders-that-actually-fire/daily-pills-screenshot-2.jpg" alt="Daily Pills App Store screenshot 2" /></p>
<p>I also have a second, more feature-rich medication app, <strong>Track Your Pills</strong>, with barcode scanning, PDF reports for doctors and widgets. I'm writing that one up separately, because its technical story is quite different.</p>
<p>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.</p>
<hr />
<h2>8. Before you ship a reminder app: a checklist</h2>
<ol>
<li><strong>Permission is requested at the moment of need</strong>, and a denied state shows how to enable notifications in Settings.</li>
<li><strong>Identifiers are deterministic</strong>, and edits cancel and reschedule only what they own.</li>
<li><strong>The pending count is audited.</strong> You know how many of the 64 slots you use, with a safety margin.</li>
<li><strong>Times are wall-clock components</strong>, tested across a time-zone change and a daylight-saving change.</li>
<li><strong>Actions work from the Lock Screen</strong>, including right after a reboot, once the phone has been unlocked once.</li>
<li><strong>Supply and run-out dates are recomputed</strong> on every change, and the warning is rescheduled.</li>
<li><strong>Missed doses are detected on launch</strong> and are easy to correct.</li>
<li><strong>Privacy claims match reality.</strong> No hidden analytics, and the App Privacy details are accurate.</li>
<li><strong>You've tested on a real device over several days.</strong> The Simulator won't show you what happens after a week of real use.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Try it, or explore my other apps:</p>
<ul>
<li><strong>Daily Pills | Reminder (iOS):</strong> <a href="https://apps.apple.com/us/app/daily-pills-reminder/id1510203960">https://apps.apple.com/us/app/daily-pills-reminder/id1510203960</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>Have you hit the 64-notification limit in production? I'd love to hear how you discovered it. Tell me in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/medication-reminders-that-actually-fire/">https://bestlatestapps.com/blog/medication-reminders-that-actually-fire/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Shipping a 4K Wallpaper App Without Running Out of Memory: ImageIO Downsampling, Core Image Blur and Add-Only Photos Access]]></title><description><![CDATA[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…]]></description><link>https://aryansbtloe.hashnode.dev/shipping-a-4k-wallpaper-app-without-running-out-of-memory-imageio-downsampling-core-image-blur-and-add-only-photos-access</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/shipping-a-4k-wallpaper-app-without-running-out-of-memory-imageio-downsampling-core-image-blur-and-add-only-photos-access</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Core Image]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:01:08 GMT</pubDate><content:encoded><![CDATA[<p><em>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</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/shipping-a-4k-wallpaper-app-without-running-out-of-memory/wallpapers-hd-icon-512.jpg" alt="Wallpapers HD: Exotic 4K app icon" /></p>
<p>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.</p>
<p>My app <strong>Wallpapers HD: Exotic 4K</strong> 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.</p>
<p>Every one of those features runs into the same physical fact: <strong>a decoded image costs width Ã— height Ã— 4 bytes, no matter how small the file is.</strong> 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.</p>
<hr />
<h2>1. The memory maths everyone forgets</h2>
<p>A 4K image (3840 Ã— 2160) might be a 2 MB JPEG on disk. Decoded for display, it's:</p>
<pre><code class="language-swift">3840 Ã— 2160 pixels Ã— 4 bytes (RGBA) â‰ˆ 33 MB
</code></pre>
<p>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 <strong>1.1 MB</strong> decoded.</p>
<p>So the rule is: <strong>never decode a bigger image than you're going to display.</strong> <code>UIImage(data:)</code> decodes at full size. ImageIO can decode straight to the size you need.</p>
<hr />
<h2>2. Downsampling with ImageIO</h2>
<p><code>CGImageSourceCreateThumbnailAtIndex</code> (iOS 4+) creates a smaller image directly from the compressed data, without ever holding the full-size bitmap in memory:</p>
<pre><code class="language-swift">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) -&gt; 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) -&gt; 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) }
    }
}
</code></pre>
<p>Two options do the important work:</p>
<ul>
<li><strong><code>kCGImageSourceShouldCache: false</code> on the source</strong> stops ImageIO from caching a full-size decode as a side effect.</li>
<li><strong><code>kCGImageSourceShouldCacheImmediately: true</code> on the thumbnail</strong> forces decoding <em>now</em>, which you'll do on a background task, instead of lazily on the main thread during the first draw, where it causes scroll hitches.</li>
</ul>
<p>On iOS 15+, <code>UIImage.preparingThumbnail(of:)</code> is a convenient alternative when you already have a <code>UIImage</code>. But a <code>UIImage</code> made from data hasn't necessarily been decoded yet, and ImageIO gives you the most control.</p>
<hr />
<h2>3. A grid that stays smooth</h2>
<p>Better still: <strong>don't download 4K images for the grid at all.</strong> 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.</p>
<pre><code class="language-swift">import SwiftUI

actor ThumbnailCache {
    static let shared = ThumbnailCache()
    private let cache = NSCache&lt;NSURL, UIImage&gt;()
    init() { cache.totalCostLimit = 80 * 1024 * 1024 }    // ~80 MB of decoded thumbnails

    func image(for url: URL, pixelSize: CGFloat, scale: CGFloat) async -&gt; 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))
    }
}
</code></pre>
<p>The details that matter:</p>
<ul>
<li><strong><code>NSCache</code> with a cost limit</strong> sized in <em>decoded bytes</em>, not image count, so the system can also evict under memory pressure.</li>
<li><strong><code>.task(id:)</code></strong> cancels the load when a cell scrolls off-screen before finishing.</li>
<li><strong><code>@Environment(\.displayScale)</code></strong>, not <code>UIScreen.main.scale</code>. Apple's docs now mark <code>UIScreen.main</code> as deprecated in iOS 26. Read the scale and screen size from the view's environment or window scene instead.</li>
</ul>
<p>Why not just <code>AsyncImage</code> (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.</p>
<hr />
<h2>4. The detail screen: full resolution, exactly once</h2>
<p>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 <strong>device's native screen size</strong> rather than the source's 4K:</p>
<pre><code class="language-swift">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 -&gt; (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
    }
}
</code></pre>
<p><code>UIScreen.nativeBounds</code> (iOS 8+) gives the screen's size in physical pixels. Get the screen from the current window scene (<code>view.window?.windowScene?.screen</code>) rather than <code>UIScreen.main</code>.</p>
<p><strong>What about "parallax-ready"?</strong> 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 <em>least</em> 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 <em>save</em> the original, not the preview.</p>
<hr />
<h2>5. Create mode: blur that keeps clean edges</h2>
<p>"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:</p>
<pre><code class="language-swift">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) -&gt; 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 &gt; 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)
    }
}
</code></pre>
<p>Performance tips for a live slider:</p>
<ul>
<li><strong>Preview on the downsampled image</strong> and render the full-resolution output only when the user taps Save. Blur cost grows with the pixel count <em>and</em> the radius.</li>
<li><strong>Scale the radius</strong> 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.</li>
<li><strong>Reuse one <code>CIContext</code>.</strong> Creating a context per slider tick is the slowest possible way to use Core Image.</li>
</ul>
<p>The optional <code>dim</code> parameter is a small design touch: a slightly darker, blurred background keeps the Lock Screen clock and widgets readable.</p>
<hr />
<h2>6. Saving with the minimum permission</h2>
<p>Saving an image to Photos doesn't require read access to the user's library. iOS has an <strong>add-only</strong> access level for exactly this:</p>
<pre><code class="language-swift">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)
        }
    }
}
</code></pre>
<ul>
<li><strong>Declare <code>NSPhotoLibraryAddUsageDescription</code></strong> (iOS 11+), not the full-library key. For example: "Save wallpapers you choose to your photo library."</li>
<li><strong><code>requestAuthorization(for: .addOnly)</code></strong> (iOS 14+) asks only for what you need. Users are far more likely to allow it, and you never see their photos.</li>
<li><strong>Save the original file</strong> with <code>PHAssetCreationRequest.addResource(with:fileURL:options:)</code> (iOS 9+). Re-encoding a <code>UIImage</code> can cost quality and strip metadata.</li>
</ul>
<hr />
<h2>7. The honest part: you can't set the wallpaper for the user</h2>
<p>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.</p>
<p>So make that last step effortless:</p>
<ul>
<li>After saving, show a short, illustrated guide: <strong>Photos â†’ open the image â†’ Share â†’ Use as Wallpaper</strong>.</li>
<li>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.</li>
<li>Mention that the system lets you pinch and position the image, and that depth and motion options depend on the device and iOS version.</li>
</ul>
<p>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.</p>
<hr />
<h2>8. Real-world example: Wallpapers HD: Exotic 4K</h2>
<p>These are the techniques I'd use, and recommend, for <strong><a href="https://apps.apple.com/us/app/wallpapers-hd-exotic-4k/id6443850234">Wallpapers HD: Exotic 4K</a></strong>.</p>
<p><img src="https://bestlatestapps.com/assets/blog/shipping-a-4k-wallpaper-app-without-running-out-of-memory/wallpapers-hd-screenshot-1.jpg" alt="Wallpapers HD App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong> with an optional Premium tier, in <strong>Photo &amp; Video</strong> and <strong>Graphics &amp; Design</strong></li>
<li>First released in <strong>December 2022</strong></li>
<li><strong>6 ratings</strong> in the US store, all <strong>5 stars</strong></li>
<li>Supports <strong>iOS 16.0 and later</strong></li>
</ul>
<p>How the listing maps to this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Fresh 4K HD wallpapers added regularly" / "Browse by categoryâ€¦ Quick search"</td>
<td>A small rendition for the grid, downsampled and cost-cached (sections 2â€“3)</td>
</tr>
<tr>
<td>"Parallax-ready backgrounds that look sharp on every iPhone display"</td>
<td>Native-size preview, original saved, room for system cropping (section 4)</td>
</tr>
<tr>
<td>"Create your own wallpapers â€” edit photos and add blur effects"</td>
<td>Clamped Gaussian blur with optional dimming (section 5)</td>
</tr>
<tr>
<td>"Save the imageâ€¦ Set it as your Lock Screen or Home Screen wallpaper"</td>
<td>Add-only Photos saving, then a guided user step (sections 6â€“7)</td>
</tr>
<tr>
<td>"Free: Browse and download HD wallpapers with ads / Premium: â€¦ advanced blur effectsâ€¦ ad-free"</td>
<td>Gate the content and editor features by entitlement; the pipeline is the same</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/shipping-a-4k-wallpaper-app-without-running-out-of-memory/wallpapers-hd-screenshot-2.jpg" alt="Wallpapers HD App Store screenshot 2" /></p>
<hr />
<h2>9. Before you ship an image-heavy app: a checklist</h2>
<ol>
<li><strong>Nothing is decoded larger than it's displayed.</strong> Grid cells use downsampled thumbnails sized in pixels.</li>
<li><strong>The cache is costed in decoded bytes</strong> with a sensible limit, and it responds to memory pressure.</li>
<li><strong>Scrolling is profiled on your oldest supported device</strong> with Instruments' Allocations and Animation Hitches tools.</li>
<li><strong>Full-resolution images are downloaded to disk</strong>, previewed at screen size, and saved as original bytes.</li>
<li><strong>Blur previews run on the small image</strong> and export at full size with a scaled radius.</li>
<li><strong>Photos access is add-only</strong>, with <code>NSPhotoLibraryAddUsageDescription</code> and graceful handling of denial.</li>
<li><strong>You don't promise "set wallpaper" automation.</strong> Guide the user through Photos instead.</li>
<li><strong><code>UIScreen.main</code> is gone from new code.</strong> Use the environment's <code>displayScale</code> and the window scene's screen.</li>
<li><strong>Image licensing is clear</strong> for every image you serve.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>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.</p>
<p>Try it, or explore my other apps:</p>
<ul>
<li><strong>Wallpapers HD: Exotic 4K (iOS):</strong> <a href="https://apps.apple.com/us/app/wallpapers-hd-exotic-4k/id6443850234">https://apps.apple.com/us/app/wallpapers-hd-exotic-4k/id6443850234</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>What's the largest memory spike an image grid has ever given you? Tell me in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/shipping-a-4k-wallpaper-app-without-running-out-of-memory/">https://bestlatestapps.com/blog/shipping-a-4k-wallpaper-app-without-running-out-of-memory/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Stop Hand-Rolling Unit Conversions: Foundation's Measurement, Dimension and MeasurementFormatter in Practice]]></title><description><![CDATA[Built-in units, custom dimensions, non-linear converters, locale-aware input and output, and the temperature trap that catches everyone Every unit…]]></description><link>https://aryansbtloe.hashnode.dev/stop-hand-rolling-unit-conversions-foundation-s-measurement-dimension-and-measurementformatter-in-practice</link><guid isPermaLink="true">https://aryansbtloe.hashnode.dev/stop-hand-rolling-unit-conversions-foundation-s-measurement-dimension-and-measurementformatter-in-practice</guid><category><![CDATA[Swift]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Foundation]]></category><category><![CDATA[Mobile Development]]></category><dc:creator><![CDATA[Alok Singh]]></dc:creator><pubDate>Thu, 22 Oct 2026 11:00:37 GMT</pubDate><content:encoded><![CDATA[<p><em>Built-in units, custom dimensions, non-linear converters, locale-aware input and output, and the temperature trap that catches everyone</em></p>
<p><img src="https://bestlatestapps.com/assets/blog/stop-hand-rolling-unit-conversions/units-converter-icon-512.jpg" alt="Units Converter &amp; Calculator app icon" /></p>
<p>Every unit converter I've seen in a code review starts the same way: a dictionary of conversion factors, a <code>switch</code> 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.</p>
<p>My app <strong>Units Converter &amp; Calculator</strong> 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.</p>
<p>Apple has shipped a proper units system since iOS 10: <code>Measurement</code>, <code>Unit</code>, <code>Dimension</code> and <code>UnitConverter</code>, plus <code>MeasurementFormatter</code> and the newer <code>Measurement.FormatStyle</code> for output. Most developers I talk to have never used more than <code>UnitLength.meters</code>. 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.</p>
<hr />
<h2>1. Why conversion tables rot</h2>
<p>A hand-written table has three problems:</p>
<ol>
<li><strong>NÂ² relationships.</strong> 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.</li>
<li><strong>Hidden non-linearity.</strong> Temperature has an offset; fuel economy is reciprocal (L/100 km vs mpg). These get special-cased, and special cases breed bugs.</li>
<li><strong>Formatting is an afterthought.</strong> "1.0000000000002 km" shows up in the UI, and users in Germany type "1,5" and get an error.</li>
</ol>
<p>Foundation's model fixes the first two by design: every unit in a <strong>dimension</strong> knows how to convert <em>to and from that dimension's base unit</em>. 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.</p>
<hr />
<h2>2. The basics: Measurement and built-in dimensions</h2>
<pre><code class="language-swift">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)
      &gt; Measurement(value: 2, unit: UnitMass.pounds))  // true
</code></pre>
<p>Foundation ships a little over twenty <code>Dimension</code> 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 (<code>UnitInformationStorage</code>, iOS 13+). Each has many units: <code>UnitEnergy.kilowattHours</code>, <code>UnitSpeed.knots</code>, <code>UnitPressure.inchesOfMercury</code> and so on.</p>
<p>For a converter UI, wrap the dimension in a small description type so a category screen can list its units generically:</p>
<pre><code class="language-swift">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) -&gt; Double {
    Measurement(value: value, unit: from).converted(to: to).value
}
</code></pre>
<p><code>Measurement&lt;Dimension&gt;</code> works here because <code>converted(to:)</code> is available whenever <code>UnitType</code> is a <code>Dimension</code>. Just make sure <code>from</code> and <code>to</code> 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.</p>
<hr />
<h2>3. Custom dimensions for what Foundation doesn't cover</h2>
<p>Getting to 30+ categories means going beyond the built-ins: density, torque, data transfer rate, radiation dose, flow rate. A custom dimension is a <code>Dimension</code> subclass with a base unit and static units defined by <code>UnitConverterLinear</code>:</p>
<pre><code class="language-swift">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() -&gt; UnitDensity { kilogramsPerCubicMeter }
}

let water = Measurement(value: 1, unit: UnitDensity.gramsPerCubicCentimeter)
print(water.converted(to: .poundsPerCubicFoot).value)   // â‰ˆ 62.43
</code></pre>
<p>The <strong>coefficient is "how many base units in one of this unit"</strong>. 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.</p>
<p><code>UnitConverterLinear(coefficient:constant:)</code> also takes a <code>constant</code>, applied as <code>base = value Ã— coefficient + constant</code>. That's how Foundation models Celsius and Fahrenheit against kelvin. You'll rarely need it for custom units, but it's there.</p>
<p><strong>Write a round-trip test for every unit.</strong> It catches typos instantly:</p>
<pre><code class="language-swift">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)")
            }
        }
    }
}
</code></pre>
<p>Round-trips catch arithmetic mistakes, not wrong factors, so add one "known value" assertion per unit from a trusted reference too.</p>
<hr />
<h2>4. Non-linear conversions: write your own UnitConverter</h2>
<p>Some relationships aren't <code>a Ã— x + b</code>. The classic one is fuel economy: litres per 100 km and miles per gallon are <em>reciprocals</em>. Foundation's <code>UnitFuelEfficiency</code> already handles that internally, but you'll hit the same shape in your own categories. Runners, for example, want <strong>pace</strong> (minutes per kilometre) and <strong>speed</strong> (km/h) to convert into each other.</p>
<p><code>UnitConverter</code> is an open class with two methods to override:</p>
<pre><code class="language-swift">/// 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) -&gt; Double {
        value == 0 ? .infinity : k / value
    }
    override func value(fromBaseUnitValue baseUnitValue: Double) -&gt; 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() -&gt; 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
</code></pre>
<p>Guard against zero. A pace of 0 is a meaningless input, and <code>.infinity</code> displays far better than a crash.</p>
<hr />
<h2>5. Input and output that respect the user's locale</h2>
<h3>Parsing what the user typed</h3>
<p><code>Double("1,5")</code> is <code>nil</code>. In much of Europe, that's how you write one and a half. Parse with the user's locale:</p>
<pre><code class="language-swift">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) -&gt; Double? {
        let trimmed = text.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else { return nil }
        return formatter.number(from: trimmed)?.doubleValue
    }
}
</code></pre>
<p>Pair it with <code>.keyboardType(.decimalPad)</code>, which shows the locale's decimal separator automatically.</p>
<h3>Formatting the result</h3>
<p>Raw doubles leak floating-point noise. <code>Measurement.FormatStyle</code> (iOS 15+) formats value and unit together, localised, with the precision you choose:</p>
<pre><code class="language-swift">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
</code></pre>
<p>Two options matter for a converter:</p>
<ul>
<li><strong><code>usage: .asProvided</code></strong>. Without it, formatters may "helpfully" switch to the locale's preferred unit. That's great in a weather app and wrong in a converter.</li>
<li><strong>Significant digits, not fixed decimals.</strong> 0.000 012 3 and 12,300,000 both deserve a readable result.</li>
</ul>
<p>On iOS 10â€“14, <code>MeasurementFormatter</code> with <code>unitOptions = .providedUnit</code> gives the same "don't switch my unit" behaviour.</p>
<p>Custom units format fine too. Their <code>symbol</code> is used, so choose symbols you're happy to show on screen.</p>
<hr />
<h2>6. The temperature trap: points vs differences</h2>
<p>Here's a bug that ships in real apps:</p>
<pre><code class="language-swift">let delta = Measurement(value: 10, unit: UnitTemperature.celsius)
print(delta.converted(to: .fahrenheit).value)   // 50.0
</code></pre>
<p>If the user meant "it's 10 Â°C outside", 50 Â°F is right. If they meant "the temperature <em>rose by</em> 10 Â°C", the answer is <strong>18 Â°F</strong>: a difference doesn't carry the 32Â° offset.</p>
<p>Foundation's <code>UnitTemperature</code> models <em>points</em> on a scale. For differences, convert with the coefficient only:</p>
<pre><code class="language-swift">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) -&gt; Double {
        func coefficient(_ u: UnitTemperature) -&gt; Double {
            (u.converter as? UnitConverterLinear)?.coefficient ?? 1
        }
        return value * coefficient(from) / coefficient(to)
    }
}

print(TemperatureDelta.convert(10, from: .celsius, to: .fahrenheit))  // 18.0
</code></pre>
<p>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.</p>
<hr />
<h2>7. History, constants and challenges</h2>
<h3>Persisting history with stable keys</h3>
<p><code>Measurement</code> is <code>Codable</code>, but I don't persist units by trusting the encoded symbol: symbols can change, and custom units need to come back as <em>your</em> static instances. Store your own stable IDs instead:</p>
<pre><code class="language-swift">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
    ]
}
</code></pre>
<p>Recompute the output on display, rather than storing it. If you ever fix a factor, old history entries silently become correct.</p>
<h3>Constants are data, not code</h3>
<p>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 <em>exact by definition</em>, which is a nice fact to surface in a "why it's important" field.</p>
<h3>Challenge mode: scoring an estimate</h3>
<p>For estimation games ("how many feet in a kilometre?"), score by <strong>ratio</strong>, not absolute difference, so being off by 10% feels the same at every scale:</p>
<pre><code class="language-swift">/// 100 for a perfect answer, falling to 0 at a factor-of-two error.
func estimationScore(guess: Double, truth: Double) -&gt; Int {
    guard guess &gt; 0, truth &gt; 0 else { return 0 }
    let error = abs(log2(guess / truth))    // 0 = exact, 1 = off by 2Ã—
    return Int((max(0, 1 - error) * 100).rounded())
}
</code></pre>
<hr />
<h2>8. Real-world example: Units Converter &amp; Calculator</h2>
<p>These are the techniques I'd use, and recommend, for a converter like <strong><a href="https://apps.apple.com/us/app/units-converter-calculator/id1515811220">Units Converter &amp; Calculator</a></strong>.</p>
<p><img src="https://bestlatestapps.com/assets/blog/stop-hand-rolling-unit-conversions/units-converter-screenshot-1.jpg" alt="Units Converter App Store screenshot 1" /></p>
<p>At the time of writing, its App Store listing shows:</p>
<ul>
<li><strong>Free</strong>, in <strong>Productivity</strong> and <strong>Education</strong></li>
<li>First released in <strong>May 2020</strong></li>
<li><strong>32 ratings</strong> in the US store, averaging about <strong>4.7 stars</strong></li>
<li>Supports <strong>iOS 16.0 and later</strong></li>
</ul>
<p>How the listing lines up with this article:</p>
<table>
<thead>
<tr>
<th>What the listing says</th>
<th>Where it's covered</th>
</tr>
</thead>
<tbody><tr>
<td>"Convert 200+ units across 30+ categoriesâ€¦ completely offline"</td>
<td>Built-in dimensions plus custom <code>Dimension</code> subclasses, all on-device (sections 2â€“4)</td>
</tr>
<tr>
<td>"Real-time conversions with instant results"</td>
<td>Locale-aware parsing on every keystroke, formatted output (section 5)</td>
</tr>
<tr>
<td>"180+ physical constants with detailed explanations"</td>
<td>Constants as bundled data (section 7)</td>
</tr>
<tr>
<td>"Challenge Mode: Test your estimation skills"</td>
<td>Ratio-based scoring (section 7)</td>
</tr>
<tr>
<td>"Automatic saving of all conversionsâ€¦ Organized by category"</td>
<td>History with stable unit IDs, output recomputed on display (section 7)</td>
</tr>
</tbody></table>
<p><img src="https://bestlatestapps.com/assets/blog/stop-hand-rolling-unit-conversions/units-converter-screenshot-2.jpg" alt="Units Converter App Store screenshot 2" /></p>
<p>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.</p>
<hr />
<h2>9. Before you ship a unit converter: a checklist</h2>
<ol>
<li><strong>Every unit has a round-trip test</strong> and at least one known-value test from a trusted reference.</li>
<li><strong>The coefficient means "base units per one of this unit"</strong>, and it's written that way everywhere.</li>
<li><strong>Non-linear units use a real <code>UnitConverter</code> subclass</strong>, and zero and negative inputs are handled.</li>
<li><strong>Input parsing uses the user's locale</strong>, and the decimal-pad keyboard matches it.</li>
<li><strong>Output uses significant digits</strong> and <code>usage: .asProvided</code> (or <code>.providedUnit</code>), so the unit the user picked is the unit they see.</li>
<li><strong>Temperature differences are a separate, explicit mode.</strong></li>
<li><strong>History stores IDs and inputs, not outputs</strong>, so fixes apply retroactively.</li>
<li><strong>Everything works in Airplane Mode</strong>, including constants and learning content.</li>
</ol>
<hr />
<h2>Wrapping up</h2>
<p>Unit conversion is one of those problems that looks like a lookup table and is really a type system. Foundation's <code>Measurement</code> and <code>Dimension</code> 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.</p>
<p>Try the app, or browse the rest of my work:</p>
<ul>
<li><strong>Units Converter &amp; Calculator (iOS):</strong> <a href="https://apps.apple.com/us/app/units-converter-calculator/id1515811220">https://apps.apple.com/us/app/units-converter-calculator/id1515811220</a></li>
<li><strong>All of my free iOS apps:</strong> <a href="https://apps.apple.com/us/developer/alok-singh/id1508795536">https://apps.apple.com/us/developer/alok-singh/id1508795536</a></li>
<li><strong>My portfolio:</strong> <a href="https://bestlatestapps.com/alok/">https://bestlatestapps.com/alok/</a></li>
</ul>
<p>Which unit has bitten you in production? For me, temperature differences top the list. Let me know in the comments.</p>
<p><em>â€” Alok Kumar Singh</em></p>
<p><em>Originally published at <a href="https://bestlatestapps.com/blog/stop-hand-rolling-unit-conversions/">https://bestlatestapps.com/blog/stop-hand-rolling-unit-conversions/</a></em></p>
]]></content:encoded></item></channel></rss>