<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged ios</title>
<link>https://coderlegion.com/tag/ios</link>
<description>Powered by Question2Answer</description>
<item>
<title>How to give your AI Agent access to a Real iPhone</title>
<link>https://coderlegion.com/32988/how-to-give-your-ai-agent-access-to-a-realiphone</link>
<description>&lt;p&gt;For a long time, mobile automation has been much easier to demonstrate on Android than on iOS. Android has ADB, emulators, and relatively accessible automation tooling. iOS has a different set of constraints, especially when you want to interact with a physical device and the apps installed on it.&lt;/p&gt;
&lt;p&gt;But what if you could give an AI agent access to your actual iPhone, describe a task in plain English, and let it carry out the steps?&lt;/p&gt;
&lt;p&gt;That's what we've been working on at Mobilerun.&lt;/p&gt;
&lt;h3 id=&quot;why-iphone-automation-is-difficult&quot;&gt;Why iPhone automation is difficult&lt;/h3&gt;
&lt;p&gt;AI agents have become good at reasoning, writing code, and interacting with websites. Mobile apps introduce another layer of complexity.&lt;/p&gt;
&lt;p&gt;An agent might need to navigate multiple screens, locate a button, enter information, handle a dialog, and verify that an action actually succeeded.&lt;/p&gt;
&lt;p&gt;On iOS, automation is harder because of Apple’s restrictions, connection issues, and the setup needed to control a real iPhone. &lt;/p&gt;
&lt;p&gt;Traditional automation often relies on predefined scripts or app-specific integrations. These work well when the workflow is known in advance, but they can be restrictive when you want an agent to handle different tasks dynamically.&lt;/p&gt;
&lt;p&gt;An AI agent that can interact with a phone's UI has another option: it can observe the screen, decide what to do next, and perform the necessary interactions.&lt;/p&gt;
&lt;p&gt;Instead of building a separate integration for every task, you can describe the outcome you want and let the agent work through the interface.&lt;/p&gt;
&lt;h3 id=&quot;from-a-prompt-to-a-real-iphone&quot;&gt;From a prompt to a real iPhone&lt;/h3&gt;
&lt;p&gt;With Mobilerun Cloud, you can connect your own iPhone through a Mac or Linux or Linux and make it available for automation.&lt;/p&gt;
&lt;p&gt;The workflow is straightforward:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Connect your iPhone to your Mac or Linux or Linux using a USB cable.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Follow the setup instructions to connect it to Mobilerun Cloud.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Open your connected device and give your agent a task.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Watch the agent interact with the phone.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once connected, you can issue a command and let the agent navigate the UI, tap controls, type text, and move between screens.&lt;/p&gt;
&lt;p&gt;You don't need to build a dedicated automation script for every prompt.&lt;/p&gt;
&lt;p&gt;Setup takes under two minutes in the supported configuration. You'll need an iPhone running iOS 27 or later and a Mac or Linux.&lt;/p&gt;
&lt;p&gt;Follow the &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.mobilerun.ai/guides/connect-iphone&quot;&gt;iPhone connection guide&lt;/a&gt; to get started.&lt;/p&gt;
&lt;h3 id=&quot;what-can-you-automate-&quot;&gt;What can you automate?&lt;/h3&gt;
&lt;p&gt;The interesting part isn't simply getting an AI to tap a button. It's being able to describe a task and have the agent work through the steps on a real device.&lt;/p&gt;
&lt;p&gt;Here are a few examples worth exploring.&lt;/p&gt;
&lt;h4 id=&quot;1-messaging-workflows&quot;&gt;1. Messaging workflows&lt;/h4&gt;
&lt;p&gt;You could ask an agent to open Messages, find a conversation, and draft or send a message.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;p&gt;Open Messages, find my conversation with Maa, and send her a message saying I'll call her this evening.&lt;/p&gt;
&lt;p&gt;This is a simple example, but it demonstrates the core idea: the task is described in natural language, and the agent translates it into UI interactions.&lt;/p&gt;
&lt;p&gt;Always review messages and confirm the intended recipient before allowing an agent to send anything important.&lt;/p&gt;
&lt;h4 id=&quot;2-navigating-apps&quot;&gt;2. Navigating apps&lt;/h4&gt;
&lt;p&gt;An agent could be asked to open an app, find a particular section, search for information, or navigate through a multi-step workflow.&lt;/p&gt;
&lt;p&gt;Rather than specifying every tap in advance, you describe what you want it to accomplish.&lt;/p&gt;
&lt;h4 id=&quot;3-repetitive-mobile-tasks&quot;&gt;3. Repetitive mobile tasks&lt;/h4&gt;
&lt;p&gt;Many mobile workflows involve repeating the same sequence of actions: opening an app, finding a record, updating information, or navigating through several screens.&lt;/p&gt;
&lt;p&gt;These are useful places to experiment with agent-driven automation, particularly when the process is tedious to perform manually.&lt;/p&gt;
&lt;p&gt;The exact tasks an agent can complete will depend on the app, the UI, permissions, and the reliability of the underlying automation.&lt;/p&gt;
&lt;h3 id=&quot;why-use-a-real-iphone-instead-of-a-simulator-&quot;&gt;Why use a real iPhone instead of a simulator?&lt;/h3&gt;
&lt;p&gt;Simulators are valuable for development and testing. They provide a controlled environment where you can reproduce scenarios and inspect application behavior.&lt;/p&gt;
&lt;p&gt;Physical devices matter when you need to work with the actual hardware and software environment.&lt;/p&gt;
&lt;p&gt;A real iPhone gives you access to the device you already own, including its installed apps and existing setup, subject to the permissions and capabilities available to the automation system.&lt;/p&gt;
&lt;p&gt;This is useful for developers experimenting with mobile agents, teams testing real-device workflows, and anyone exploring what AI can do beyond a browser.&lt;/p&gt;
&lt;p&gt;It doesn't mean simulators are obsolete. Both approaches have their place, depending on what you're trying to build or test.&lt;/p&gt;
&lt;h3 id=&quot;try-it-yourself&quot;&gt;Try it yourself&lt;/h3&gt;
&lt;p&gt;If you have an iPhone and a Mac or Linux, you can connect your device to Mobilerun Cloud and start experimenting.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Connect your iPhone: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.mobilerun.ai/guides/connect-iphone&quot;&gt;Read the setup guide&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Try Mobilerun Cloud: &lt;a rel=&quot;nofollow&quot; href=&quot;https://cloud.mobilerun.ai/&quot;&gt;cloud.mobilerun.ai&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Explore Mobilerun: &lt;a rel=&quot;nofollow&quot; href=&quot;https://mobilerun.ai/&quot;&gt;mobilerun.ai&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Inspect the benchmark: &lt;a rel=&quot;nofollow&quot; href=&quot;https://mobilerun.ai/benchmark/&quot;&gt;iOSWorld results&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We're curious to see what developers build with this.&lt;/p&gt;
&lt;p&gt;What would you want an AI agent to do on your iPhone that you can't easily automate today?&lt;/p&gt;
&lt;p&gt;That's probably the most interesting question to explore next.&lt;/p&gt;
&lt;p&gt;**&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32988/how-to-give-your-ai-agent-access-to-a-realiphone</guid>
<pubDate>Thu, 29 Oct 2026 16:16:58 +0000</pubDate>
</item>
<item>
<title>MarcX Studio</title>
<link>https://coderlegion.com/32752/marcx-studio</link>
<description>&lt;p&gt;MarcX Studio is an independent studio making simple education and language apps for iPhone. None of the apps are AI products: each does one job clearly, most work without an account, and your data stays on your device unless you choose to back it up or share it.&lt;/p&gt;
&lt;h3 id=&quot;apps&quot;&gt;Apps&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RooCards&lt;/strong&gt;: keep your loyalty cards ready on your iPhone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Snap Spell&lt;/strong&gt;: spelling practice from the word lists students bring home. Scan or paste a list and practise it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NumListen&lt;/strong&gt;: number listening practice for language learners.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;成语拾光 (Idiom Shiguang)&lt;/strong&gt;: a Chinese idiom game.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The apps are free to download, with optional upgrades for people who want more. The studio site, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.marcx.app&quot;&gt;https://www.marcx.app&lt;/a&gt;, is available in many languages, including English, Chinese, Japanese, Korean, Spanish, French and German.&lt;/p&gt;
&lt;p&gt;Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.marcx.app&quot;&gt;https://www.marcx.app&lt;/a&gt;&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/32752/marcx-studio</guid>
<pubDate>Tue, 27 Oct 2026 23:05:38 +0000</pubDate>
</item>
<item>
<title>Building a Low-Latency Virtual Piano and Drum Kit on iOS with AVAudioEngine</title>
<link>https://coderlegion.com/32679/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioengine</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/&quot;&gt;https://bestlatestapps.com/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Samplers for pitched instruments, a player-node pool for drums, a multi-touch keyboard that handles chords and glissandos, and note names for beginners&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/drums-n-piano-icon-512.jpg&quot; alt=&quot;Drums n Piano app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;The test for a virtual instrument is brutal and immediate: tap a key, and the sound has to arrive &lt;em&gt;with&lt;/em&gt; 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.&lt;/p&gt;
&lt;p&gt;My app &lt;strong&gt;Drums n Piano&lt;/strong&gt; puts a piano, a drum kit and a xylophone on your phone. The listing promises an instrument that &quot;sounds real, just like the original instrument!&quot;, and it's also meant for learning: it teaches beginners &quot;the names &amp;amp; positions of notes on the piano&quot;.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;AVAudioUnitSampler&lt;/code&gt; for pitched instruments, a pool of &lt;code&gt;AVAudioPlayerNode&lt;/code&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-three-things-a-virtual-instrument-must-get-right&quot;&gt;1. The three things a virtual instrument must get right&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Latency.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Polyphony.&lt;/strong&gt; Several sounds at once: chords on the piano, overlapping cymbal and kick, a fast roll on the snare.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Touch handling.&lt;/strong&gt; Many fingers at once, sliding between keys, and no gesture delays.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;None of these are hard individually. They get hard when the first prototype plays a sound file with &lt;code&gt;AVAudioPlayer&lt;/code&gt; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-session-setup-for-an-instrument&quot;&gt;2. Session setup for an instrument&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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(&quot;IO buffer: \(session.ioBufferDuration * 1000) ms, rate: \(session.sampleRate) Hz&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two decisions here deserve a sentence each:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.playback&lt;/code&gt; vs &lt;code&gt;.ambient&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;.ambient&lt;/code&gt; 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 &lt;code&gt;.playback&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A small preferred buffer&lt;/strong&gt; reduces output latency. It's a &lt;em&gt;preference&lt;/em&gt;: read back &lt;code&gt;ioBufferDuration&lt;/code&gt; 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.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-pitched-instruments-avaudiounitsampler&quot;&gt;3. Pitched instruments: AVAudioUnitSampler&lt;/h3&gt;
&lt;p&gt;For piano and xylophone, you don't want one audio file per tap. You want a &lt;strong&gt;sampler&lt;/strong&gt;: an instrument that receives MIDI-style &quot;note on/off&quot; messages and plays the right sample, pitch-shifting between recorded notes if needed. &lt;code&gt;AVAudioUnitSampler&lt;/code&gt; (iOS 8+) is Apple's sampler, wrapped as an engine node.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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: &quot;sf2&quot;) 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)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why this is the right tool:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Polyphony is built in.&lt;/strong&gt; Ten fingers, ten notes. The sampler manages voices for you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Note-off is real.&lt;/strong&gt; Releasing a key triggers the sample's release, as on a real piano, rather than a hard cut.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loading happens once&lt;/strong&gt;, at launch or when the user switches instrument, never on the tap.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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 &lt;code&gt;loadAudioFiles(at:)&lt;/code&gt;. Sample quality is the single biggest factor in &quot;sounds real&quot;, and it's a licensing question as much as a technical one: check the licence of any sound bank before you bundle it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-drums-preloaded-buffers-and-a-player-node-pool&quot;&gt;4. Drums: preloaded buffers and a player-node pool&lt;/h3&gt;
&lt;p&gt;Drums are different. There's no pitch, every hit is a one-shot, and the same pad is hit repeatedly and fast. You &lt;em&gt;could&lt;/em&gt; map drums onto a sampler (General MIDI percussion is exactly that), but a simple, very controllable approach is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Decode each drum sample into an &lt;code&gt;AVAudioPCMBuffer&lt;/code&gt; &lt;strong&gt;once&lt;/strong&gt;, at launch.&lt;/li&gt;
&lt;li&gt;Keep a small &lt;strong&gt;pool&lt;/strong&gt; of &lt;code&gt;AVAudioPlayerNode&lt;/code&gt;s per pad.&lt;/li&gt;
&lt;li&gt;On each hit, schedule the buffer on the next node in the pool, round-robin.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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: &quot;caf&quot;) 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..&amp;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)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The whole setup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;let engine = AVAudioEngine()
let piano = PitchedInstrument(engine: engine)
let drums = DrumKit(engine: engine)

try InstrumentSession.activate()
try piano.loadSoundBank(named: &quot;Piano&quot;)
try drums.load(pads: [&quot;kick&quot;, &quot;snare&quot;, &quot;hihat-closed&quot;, &quot;hihat-open&quot;, &quot;crash&quot;, &quot;tom-high&quot;, &quot;tom-low&quot;])
engine.prepare()
try engine.start()
drums.startNodes()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why a pool? A single player node plays one schedule queue. Scheduling a new hit with &lt;code&gt;.interrupts&lt;/code&gt; on a &lt;em&gt;single&lt;/em&gt; 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 &lt;code&gt;.caf&lt;/code&gt; or &lt;code&gt;.wav&lt;/code&gt; so decoding at launch is quick and exact. They're short, so the size cost is small.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-a-multi-touch-keyboard-that-feels-like-a-keyboard&quot;&gt;5. A multi-touch keyboard that feels like a keyboard&lt;/h3&gt;
&lt;p&gt;This is where many SwiftUI prototypes fall short. A &lt;code&gt;Button&lt;/code&gt; per key fires on touch-&lt;em&gt;up&lt;/em&gt;, which is far too late for an instrument. A &lt;code&gt;DragGesture&lt;/code&gt; per key doesn't hand off to the next key when the finger slides. And coordinating several simultaneous fingers through SwiftUI gestures is awkward.&lt;/p&gt;
&lt;p&gt;The direct approach is one UIKit view for the whole keyboard that tracks every &lt;code&gt;UITouch&lt;/code&gt; itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import UIKit

final class KeyboardView: UIView {
    var firstMidi: UInt8 = 60           // C4
    var octaves = 2
    var onNoteOn: ((UInt8) -&amp;gt; Void)?
    var onNoteOff: ((UInt8) -&amp;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(&quot;init(coder:) is not supported&quot;) }

    // MARK: Touch tracking

    override func touchesBegan(_ touches: Set&amp;lt;UITouch&amp;gt;, with event: UIEvent?) {
        for t in touches { press(t) }
    }

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

    override func touchesCancelled(_ touches: Set&amp;lt;UITouch&amp;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) -&amp;gt; UInt8? {
        guard bounds.contains(p) else { return nil }
        let w = whiteKeyWidth
        if p.y &amp;lt; bounds.height * 0.6 {
            for octave in 0..&amp;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]
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The essential details:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;isMultipleTouchEnabled = true&lt;/code&gt;.&lt;/strong&gt; It's &lt;code&gt;false&lt;/code&gt; by default, and without it you'll only ever get one finger.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Track notes per touch&lt;/strong&gt; with &lt;code&gt;ObjectIdentifier(touch)&lt;/code&gt;. Each finger owns its note, so chords and glissandos work independently.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Handle &lt;code&gt;touchesCancelled&lt;/code&gt;.&lt;/strong&gt; A system gesture or an incoming call can cancel touches. If you don't release notes there, you get stuck notes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Black keys are hit-tested first&lt;/strong&gt;, because they're drawn on top.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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 &lt;code&gt;UIViewRepresentable&lt;/code&gt;. Touch handling for an instrument is one of the places where UIKit is still simply the better tool.&lt;/p&gt;
&lt;p&gt;Drum pads follow the same idea in simpler form: a grid of rectangles, &lt;code&gt;touchesBegan&lt;/code&gt; triggers &lt;code&gt;hit(_:)&lt;/code&gt;, and there's no note-off.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-teaching-note-names-and-positions&quot;&gt;6. Teaching note names and positions&lt;/h3&gt;
&lt;p&gt;The listing describes the app as &quot;teaching beginners the names &amp;amp; positions of notes on the piano&quot;. On the keyboard view, that's two small things: draw a label on each key, and let learners switch between naming systems.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 = [&quot;C&quot;, &quot;C♯&quot;, &quot;D&quot;, &quot;D♯&quot;, &quot;E&quot;, &quot;F&quot;, &quot;F♯&quot;, &quot;G&quot;, &quot;G♯&quot;, &quot;A&quot;, &quot;A♯&quot;, &quot;B&quot;]
    static let solfegeNames = [&quot;Do&quot;, &quot;Do♯&quot;, &quot;Re&quot;, &quot;Re♯&quot;, &quot;Mi&quot;, &quot;Fa&quot;, &quot;Fa♯&quot;, &quot;Sol&quot;, &quot;Sol♯&quot;, &quot;La&quot;, &quot;La♯&quot;, &quot;Si&quot;]

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

// NoteNaming.letters.name(for: 60) == &quot;C4&quot;; NoteNaming.solfege.name(for: 64) == &quot;Mi4&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In &lt;code&gt;draw(_:)&lt;/code&gt;, draw white-key labels near the bottom of each key and black-key labels only when a &quot;show sharps&quot; toggle is on; for beginners, too many labels is noise. Highlighting the pressed key matters more than any label. The immediate visual &quot;this is the key you just played&quot; is how position is learned.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-real-world-example-drums-n-piano&quot;&gt;7. Real-world example: Drums n Piano&lt;/h3&gt;
&lt;p&gt;This is the approach I'd use, and recommend, for &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/drums-n-piano/id1642061667&quot;&gt;Drums n Piano&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/drums-n-piano-screenshot-1.jpg&quot; alt=&quot;Drums n Piano App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Music&lt;/strong&gt; and &lt;strong&gt;Entertainment&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;September 2022&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;9 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;4.4 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 12.0 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Simply play and learn the piano, drums or xylophone wherever you are&quot; &lt;/td&gt;&lt;td&gt;A sampler per pitched instrument; a player-node pool for drums (sections 3–4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;This virtual app sounds real, just like the original instrument!&quot; / &quot;Premium sound quality&quot; &lt;/td&gt;&lt;td&gt;Real samples loaded once, a low-latency session, natural note-off releases (sections 2–3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Teaching beginners the names &amp;amp; positions of notes on the piano&quot; &lt;/td&gt;&lt;td&gt;Note labels and pressed-key highlighting (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Play or learn your music chords&quot; &lt;/td&gt;&lt;td&gt;Per-touch tracking with multi-touch enabled (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/drums-n-piano-screenshot-2.jpg&quot; alt=&quot;Drums n Piano App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;The listing's audience is broad: &quot;a music teacher, singer, songwriter, or beginner or joyful kid&quot;. 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-before-you-ship-a-virtual-instrument-a-checklist&quot;&gt;8. Before you ship a virtual instrument: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Nothing loads on tap.&lt;/strong&gt; Samples and sound banks load at launch or on instrument switch; taps only trigger notes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The session is &lt;code&gt;.playback&lt;/code&gt;&lt;/strong&gt; (or a deliberate &lt;code&gt;.ambient&lt;/code&gt;), with a small preferred buffer, and you've logged what you actually got.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Polyphony is tested.&lt;/strong&gt; Ten-finger chords on the piano; rapid repeated hits on every drum pad.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There are no stuck notes.&lt;/strong&gt; Test cancelled touches, a phone call mid-chord, backgrounding mid-note and Control Center swipes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;isMultipleTouchEnabled&lt;/code&gt; is on&lt;/strong&gt;, and each touch owns its note.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Glissando works&lt;/strong&gt;: sliding across keys releases and presses notes as you go.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sample licences are cleared&lt;/strong&gt; for everything you bundle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You've tested on real hardware with wired and Bluetooth audio&lt;/strong&gt;, and the app tells users that wired is tighter.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Play it, or explore my other apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Drums n Piano (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/drums-n-piano/id1642061667&quot;&gt;https://apps.apple.com/us/app/drums-n-piano/id1642061667&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/&quot;&gt;https://bestlatestapps.com/blog/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioe/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32679/building-a-low-latency-virtual-piano-and-drum-kit-on-ios-with-avaudioengine</guid>
<pubDate>Tue, 27 Oct 2026 12:40:37 +0000</pubDate>
</item>
<item>
<title>Building a Real-Time Mic-to-Speaker App on iOS with AVAudioEngine</title>
<link>https://coderlegion.com/32678/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/&quot;&gt;https://bestlatestapps.com/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Live audio passthrough in Swift: session setup, permissions, feedback, interruptions and route changes, from shipping an app that does exactly this&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-icon-512.jpg&quot; alt=&quot;Virtual Mic app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;That in-between case is exactly what my app &lt;strong&gt;Mic to Speaker – Virtual Mic&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;It looks like a weekend project, and the first working version really is only a few dozen lines of Swift. But the gap between &quot;it works on my desk&quot; and &quot;it works for thousands of people on random speakers, in cars, during phone calls&quot; is where the real work lives.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-problem-what-mic-to-speaker-really-needs&quot;&gt;1. The problem: what &quot;mic to speaker&quot; really needs&lt;/h3&gt;
&lt;p&gt;On paper the requirement is one sentence: &lt;em&gt;take audio from the microphone and play it through the speaker, right now.&lt;/em&gt; In practice that sentence hides three hard requirements.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Low latency.&lt;/strong&gt; When you hear your own voice back, your brain notices delay quickly. A little sounds &quot;roomy&quot;; 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The right output route.&lt;/strong&gt; By default, when an iPhone records and plays at the same time, output goes to the &lt;em&gt;receiver&lt;/em&gt;, 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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No howling feedback.&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-microphone-permission-the-modern-way&quot;&gt;2. Microphone permission, the modern way&lt;/h3&gt;
&lt;p&gt;Before anything else, you need the user's permission to use the microphone. Two things are required.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;First, a purpose string.&lt;/strong&gt; Add &lt;code&gt;NSMicrophoneUsageDescription&lt;/code&gt; to your &lt;code&gt;Info.plist&lt;/code&gt;. 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: &lt;em&gt;&quot;Virtual Mic uses the microphone to play your voice live through your speaker or headphones.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second, the request itself.&lt;/strong&gt; In iOS 17, Apple moved record permission from &lt;code&gt;AVAudioSession&lt;/code&gt; to a new class, &lt;code&gt;AVAudioApplication&lt;/code&gt;. The old &lt;code&gt;AVAudioSession.requestRecordPermission(_:)&lt;/code&gt; is deprecated as of iOS 17. The new API has both a completion-handler version and an &lt;code&gt;async&lt;/code&gt; version:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class func requestRecordPermission() async -&amp;gt; Bool
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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 &quot;denied&quot; case:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation
import UIKit

@MainActor
enum MicPermissionManager {

    /// Prompts if needed. Returns true if we may record.
    static func request() async -&amp;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)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three practical notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ask at the moment of need&lt;/strong&gt;, when the user first taps &quot;start&quot;, not at launch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Denied is permanent from the app's side.&lt;/strong&gt; The system won't prompt again, so show a short explanation and a button that calls &lt;code&gt;openSettings()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Silence is not an error.&lt;/strong&gt; Apple's docs note that without permission the app captures only silence. The engine starts &quot;fine&quot; and plays nothing, so always check permission first or you'll get confusing &quot;no sound&quot; bug reports.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-configuring-avaudiosession&quot;&gt;3. Configuring AVAudioSession&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;AVAudioSession&lt;/code&gt; is how your app tells iOS what kind of audio it does. Get this wrong and nothing else matters.&lt;/p&gt;
&lt;h4 id=&quot;category-and-options&quot;&gt;Category and options&lt;/h4&gt;
&lt;p&gt;For simultaneous input and output you need the &lt;code&gt;.playAndRecord&lt;/code&gt; category. On top of that, the options do most of the heavy lifting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.defaultToSpeaker&lt;/code&gt;&lt;/strong&gt;: uses the built-in loudspeaker instead of the earpiece when nothing else is connected. This one option fixes &quot;why is it so quiet?&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.allowBluetoothA2DP&lt;/code&gt;&lt;/strong&gt;: 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.allowBluetoothHFP&lt;/code&gt;&lt;/strong&gt; (formerly &lt;code&gt;.allowBluetooth&lt;/code&gt;): lets Bluetooth &lt;em&gt;hands-free&lt;/em&gt; devices act as input and output. Apple's current docs mark the old &lt;code&gt;.allowBluetooth&lt;/code&gt; name deprecated in favour of &lt;code&gt;.allowBluetoothHFP&lt;/code&gt;; on an older SDK, use &lt;code&gt;.allowBluetooth&lt;/code&gt;. HFP is the &quot;phone call&quot; profile with noticeably lower quality than A2DP, so I enable it only when the user wants a headset's own microphone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.allowAirPlay&lt;/code&gt;&lt;/strong&gt;: lets output go to AirPlay devices; for &lt;code&gt;.playAndRecord&lt;/code&gt; you must set it explicitly. AirPlay buffers over the network, so expect extra latency you can't control.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;mode&quot;&gt;Mode&lt;/h4&gt;
&lt;p&gt;The mode tunes the session for a use case:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.default&lt;/code&gt;&lt;/strong&gt;: no special processing; best for music and singing, and required for echo-cancelled input (below).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.voiceChat&lt;/code&gt;&lt;/strong&gt;: 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.measurement&lt;/code&gt;&lt;/strong&gt;: minimises system signal processing for the rawest input.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I default to &lt;code&gt;.default&lt;/code&gt; and offer voice processing (section 5) as a user choice.&lt;/p&gt;
&lt;h4 id=&quot;buffer-duration-and-sample-rate&quot;&gt;Buffer duration and sample rate&lt;/h4&gt;
&lt;p&gt;You can &lt;em&gt;ask&lt;/em&gt; for a smaller I/O buffer and a particular sample rate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;try session.setPreferredIOBufferDuration(0.005)  // a request, not a guarantee
try session.setPreferredSampleRate(48_000)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are &lt;em&gt;preferences&lt;/em&gt;; the system decides based on hardware and route. After activating, read &lt;code&gt;ioBufferDuration&lt;/code&gt; and &lt;code&gt;sampleRate&lt;/code&gt; to see what you actually got, and log them. Bluetooth routes often differ from what you asked for.&lt;/p&gt;
&lt;h4 id=&quot;echo-cancelled-input-ios-18-2-&quot;&gt;Echo-cancelled input (iOS 18.2+)&lt;/h4&gt;
&lt;p&gt;Starting with &lt;strong&gt;iOS 18.2&lt;/strong&gt;, &lt;code&gt;AVAudioSession&lt;/code&gt; has three new members:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;isEchoCancelledInputAvailable&lt;/code&gt; — whether the device and current setup support it&lt;/li&gt;
&lt;li&gt;&lt;code&gt;setPrefersEchoCancelledInput(_:)&lt;/code&gt; — ask for it&lt;/li&gt;
&lt;li&gt;&lt;code&gt;isEchoCancelledInputEnabled&lt;/code&gt; — whether the system actually turned it on&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Per Apple's docs, it works only with &lt;code&gt;.playAndRecord&lt;/code&gt; and &lt;code&gt;.default&lt;/code&gt; 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.&lt;/p&gt;
&lt;h4 id=&quot;temporary-speaker-override&quot;&gt;Temporary speaker override&lt;/h4&gt;
&lt;p&gt;For a &quot;force loudspeaker&quot; toggle, use &lt;code&gt;overrideOutputAudioPort(_:)&lt;/code&gt;. It temporarily changes the current route, and the override clears when the route changes again.&lt;/p&gt;
&lt;h4 id=&quot;putting-it-together&quot;&gt;Putting it together&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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(&quot;Sample rate: \(session.sampleRate) Hz, &quot;
            + &quot;IO buffer: \(session.ioBufferDuration * 1000) ms, &quot;
            + &quot;route: \(session.currentRoute.outputs.map(\.portType.rawValue))&quot;)
    }

    /// 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)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-the-avaudioengine-graph&quot;&gt;4. The AVAudioEngine graph&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;AVAudioEngine&lt;/code&gt; lets you connect audio nodes like guitar pedals. For mic-to-speaker the graph is short:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;inputNode → [EQ] → [Reverb] → mainMixerNode → outputNode
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;inputNode&lt;/code&gt;&lt;/strong&gt; is the current microphone input.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;AVAudioUnitEQ&lt;/code&gt;&lt;/strong&gt; gives bass/treble bands and a &lt;code&gt;globalGain&lt;/code&gt; boost (documented range −96 to +24 dB).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;AVAudioUnitReverb&lt;/code&gt;&lt;/strong&gt; adds room character; &lt;code&gt;wetDryMix&lt;/code&gt; runs 0–100%, where 0 is fully dry.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;mainMixerNode&lt;/code&gt;&lt;/strong&gt; is created and wired to &lt;code&gt;outputNode&lt;/code&gt; the first time you access it. Its &lt;code&gt;outputVolume&lt;/code&gt; (0.0–1.0) is a natural master volume.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;format-matching&quot;&gt;Format matching&lt;/h4&gt;
&lt;p&gt;The most common live-audio crash is a format mismatch. My rule: &lt;strong&gt;use the input node's own format for every connection carrying the mic signal&lt;/strong&gt;, 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.&lt;/p&gt;
&lt;h4 id=&quot;the-complete-class&quot;&gt;The complete class&lt;/h4&gt;
&lt;p&gt;Here's a minimal, complete engine class: one object that owns the engine, exposes a few controls and starts and stops cleanly.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 &amp;gt; 0, format.channelCount &amp;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 }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And using it from a view model:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;@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 = &quot;Microphone access is off. Turn it on in Settings.&quot;
            return
        }

        do {
            try AudioSessionConfigurator.configure()
            audio.volume = 0.3          // start low, always
            try audio.start()
            isLive = true
        } catch {
            errorMessage = &quot;Couldn't start audio: \(error.localizedDescription)&quot;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note &lt;code&gt;audio.volume = 0.3&lt;/code&gt;. Starting quiet is a safety feature, not a nicety.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-taming-feedback-and-latency&quot;&gt;5. Taming feedback and latency&lt;/h3&gt;
&lt;p&gt;No single switch removes feedback. There are trade-offs; pick sensible defaults and let the user adjust.&lt;/p&gt;
&lt;h4 id=&quot;headphones-vs-speaker&quot;&gt;Headphones vs. speaker&lt;/h4&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;That's why my App Store listing has plain &quot;Safety tips&quot;: 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.&lt;/p&gt;
&lt;h4 id=&quot;voice-processing&quot;&gt;Voice processing&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;setVoiceProcessingEnabled(_:)&lt;/code&gt; (on &lt;code&gt;AVAudioIONode&lt;/code&gt;, iOS 13+) turns on Apple's voice processing I/O: echo cancellation, automatic gain control and noise suppression, the same family used for calls. &lt;code&gt;isVoiceProcessingAGCEnabled&lt;/code&gt; on the input node lets you keep echo cancellation but drop automatic gain.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Change it while the engine is stopped.&lt;/strong&gt; It reconfigures the I/O unit and the input format can change, which is why &lt;code&gt;LiveAudioEngine&lt;/code&gt; rebuilds connections on every &lt;code&gt;start()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It changes the sound.&lt;/strong&gt; Tuned for speech, it can make singing and instruments sound processed, so I make it a user choice.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It's not magic.&lt;/strong&gt; It reduces the loop; at high enough volume on the built-in speaker you can still get feedback.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;buffer-size-trade-offs&quot;&gt;Buffer size trade-offs&lt;/h4&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Request a small buffer, then read back what you got.&lt;/li&gt;
&lt;li&gt;Keep the chain light. Apple's built-in EQ and reverb are fine; heavy custom DSP is where trouble starts.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Accept that Bluetooth and AirPlay add buffering you can't remove. I tell users plainly that wired gives the lowest delay.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-interruptions-and-route-changes&quot;&gt;6. Interruptions and route changes&lt;/h3&gt;
&lt;p&gt;This separates a demo from a shipped app. Real phones get calls, alarms, Siri, other apps taking over audio, and headphones unplugged mid-sentence.&lt;/p&gt;
&lt;h4 id=&quot;interruptions&quot;&gt;Interruptions&lt;/h4&gt;
&lt;p&gt;When another session (say, a phone call) takes over, the system posts &lt;code&gt;AVAudioSession.interruptionNotification&lt;/code&gt;. On &lt;code&gt;.began&lt;/code&gt;, the system has already stopped your audio; update state and UI. On &lt;code&gt;.ended&lt;/code&gt;, the options may include &lt;code&gt;.shouldResume&lt;/code&gt;, a hint that resuming without user input is appropriate.&lt;/p&gt;
&lt;p&gt;What's new: Apple's latest docs mark &lt;code&gt;.shouldResume&lt;/code&gt; deprecated as of iOS 27 in favour of a newer &lt;code&gt;resumptionRecommendationNotification&lt;/code&gt;. It still works on the versions most apps support, so the sample uses it; on the newest SDK, see Apple's &quot;Handling audio interruptions&quot; article.&lt;/p&gt;
&lt;p&gt;For a live mic I'm conservative: auto-resume only if the user was live before &lt;em&gt;and&lt;/em&gt; the system says resuming is fine. A mic that silently turns itself back on after a call is a privacy surprise.&lt;/p&gt;
&lt;h4 id=&quot;route-changes&quot;&gt;Route changes&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;AVAudioSession.routeChangeNotification&lt;/code&gt; carries a reason. The critical one is &lt;strong&gt;&lt;code&gt;.oldDeviceUnavailable&lt;/code&gt;&lt;/strong&gt;: 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 &lt;code&gt;.newDeviceAvailable&lt;/code&gt;, keep going but rebuild the graph, since the input format may have changed.&lt;/p&gt;
&lt;h4 id=&quot;media-services-reset&quot;&gt;Media services reset&lt;/h4&gt;
&lt;p&gt;Rarely, the media server restarts and the system posts &lt;code&gt;AVAudioSession.mediaServicesWereResetNotification&lt;/code&gt;. Every audio object is now invalid: recreate the engine and reconfigure the session. Separately, &lt;code&gt;AVAudioEngine&lt;/code&gt; posts &lt;code&gt;.AVAudioEngineConfigurationChange&lt;/code&gt; when the hardware I/O format changes; the engine stops, and you reconnect and restart.&lt;/p&gt;
&lt;h4 id=&quot;putting-it-together-1&quot;&gt;Putting it together&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

@MainActor
final class AudioEventsHandler {

    private var engine: LiveAudioEngine
    private var wasLiveBeforeInterruption = false
    private var observers: [NSObjectProtocol] = []
    var onStateChange: ((Bool) -&amp;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;&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)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-background-audio-and-app-store-notes&quot;&gt;7. Background audio and App Store notes&lt;/h3&gt;
&lt;h4 id=&quot;background-audio&quot;&gt;Background audio&lt;/h4&gt;
&lt;p&gt;To keep the mic live with the screen locked, add &lt;code&gt;audio&lt;/code&gt; to &lt;code&gt;UIBackgroundModes&lt;/code&gt; (Xcode: Background Modes → &quot;Audio, AirPlay, and Picture in Picture&quot;). With an active &lt;code&gt;.playAndRecord&lt;/code&gt; session and a running engine, iOS keeps the app alive and shows its microphone-in-use indicator, so users always know.&lt;/p&gt;
&lt;p&gt;The flip side: &lt;strong&gt;when the user stops, really stop.&lt;/strong&gt; Stop the engine and deactivate with &lt;code&gt;.notifyOthersOnDeactivation&lt;/code&gt; so paused music and podcasts can resume. Holding the session forever drains battery and blocks other audio.&lt;/p&gt;
&lt;h4 id=&quot;app-store-review-considerations&quot;&gt;App Store review considerations&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A specific purpose string&lt;/strong&gt;, in the user's words.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Background audio must be real.&lt;/strong&gt; Declaring the &lt;code&gt;audio&lt;/code&gt; mode means actually producing audible content in the background; an idle app holding the session doesn't qualify.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An obvious &quot;live&quot; state&lt;/strong&gt;: a big on/off control and a clear indicator.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Honest privacy.&lt;/strong&gt; If you record sessions, say so and say where they're stored, and keep your App Privacy details consistent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't over-promise.&lt;/strong&gt; If the app can't act as a system-wide mic for a computer or other apps, don't imply it can.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-virtual-mic-a-k-a-mic-to-speaker&quot;&gt;8. Real-world example: Virtual Mic, a.k.a. Mic to Speaker&lt;/h3&gt;
&lt;p&gt;Everything above comes from building and maintaining my own apps. The main one is on the App Store as &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/mic-to-speaker-virtual-mic/id1514011963&quot;&gt;Mic to Speaker – Virtual Mic&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-screenshot-1.jpg&quot; alt=&quot;Virtual Mic App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its public App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in the &lt;strong&gt;Music&lt;/strong&gt; and &lt;strong&gt;Utilities&lt;/strong&gt; categories&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;May 2020&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;About &lt;strong&gt;3,400 ratings&lt;/strong&gt; in the US store, averaging around &lt;strong&gt;4.5 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 16.6 and later&lt;/strong&gt;, on iPhone and iPad&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The listing describes the core idea simply: &quot;Use your device as a live microphone! Connect to any speaker and your voice comes out loud and clear.&quot; Here's how the listing's features map to the techniques in this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;What makes it work&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Open the app and tap the screen. That's it!&quot; / &quot;One tap to turn on or off&quot; &lt;/td&gt;&lt;td&gt;Permission request on first tap (section 2), then session + engine start (sections 3–4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Connect via Bluetooth, AirPlay, or cable&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;.playAndRecord&lt;/code&gt; with &lt;code&gt;.defaultToSpeaker&lt;/code&gt;, &lt;code&gt;.allowBluetoothA2DP&lt;/code&gt;, &lt;code&gt;.allowAirPlay&lt;/code&gt; (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Works in the background&quot; / &quot;Runs even when screen is locked&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;UIBackgroundModes&lt;/code&gt; → &lt;code&gt;audio&lt;/code&gt;, active session, running engine (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Add echo and reverb… Adjust bass and treble. Make your voice louder or softer. Everything updates in real-time&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;AVAudioUnitReverb&lt;/code&gt;, &lt;code&gt;AVAudioUnitEQ&lt;/code&gt; bands and gain, mixer volume, all live in the graph (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Every session is saved automatically… MP3, WAV, M4A&quot; &lt;/td&gt;&lt;td&gt;A tap on the graph feeding a file writer, off the real-time thread (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Safety tips: Start with low volume… Avoid feedback by keeping device away from speakers&quot; &lt;/td&gt;&lt;td&gt;The feedback realities in section 5, and starting at a low volume by default&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-screenshot-2.jpg&quot; alt=&quot;Virtual Mic App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;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 &quot;start quiet&quot; and &quot;stop on &lt;code&gt;.oldDeviceUnavailable&lt;/code&gt;&quot;. It's also why the app still supports iOS 16.6 and carries the pre-iOS 17 permission path from section 2.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/virtual-mic-screenshot-3.jpg&quot; alt=&quot;Virtual Mic App Store screenshot 3&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;android-sidebar-virtual-mic-on-google-play&quot;&gt;Android sidebar: Virtual Mic on Google Play&lt;/h4&gt;
&lt;p&gt;The same idea exists on Android as &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.aryansbtloe.microphone.android&quot;&gt;Virtual Mic on Google Play&lt;/a&gt;&lt;/strong&gt; (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.&lt;/p&gt;
&lt;p&gt;On Android the building blocks are &lt;code&gt;AudioRecord&lt;/code&gt; (capture) and &lt;code&gt;AudioTrack&lt;/code&gt; (playback), or Google's &lt;strong&gt;Oboe&lt;/strong&gt; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-live-audio-a-7-point-checklist&quot;&gt;9. Before you ship live audio: a 7-point checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Permission flow is complete.&lt;/strong&gt; &lt;code&gt;NSMicrophoneUsageDescription&lt;/code&gt; is specific; you ask at the moment of need; iOS 17+ uses &lt;code&gt;AVAudioApplication&lt;/code&gt;, older versions fall back to &lt;code&gt;AVAudioSession&lt;/code&gt;; denied users get an &quot;Open Settings&quot; path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The session is configured on purpose.&lt;/strong&gt; &lt;code&gt;.playAndRecord&lt;/code&gt;, &lt;code&gt;.defaultToSpeaker&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Formats come from the hardware.&lt;/strong&gt; You connect using the input node's format, check it's valid, and rebuild connections whenever the route or configuration changes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Feedback has defaults.&lt;/strong&gt; Start at low volume, recommend headphones or distance, offer voice processing as a user choice, and use echo-cancelled input only where &lt;code&gt;isEchoCancelledInputAvailable&lt;/code&gt; says you can.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Interruptions and route changes are tested on a real device.&lt;/strong&gt; Phone call, alarm, Siri, unplugging headphones, Bluetooth disconnecting, AirPlay switching: the app ends up in a sensible, visible state every time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Background behaviour is honest.&lt;/strong&gt; The &lt;code&gt;audio&lt;/code&gt; background mode is declared only because you really play audio in the background, and you deactivate the session with &lt;code&gt;.notifyOthersOnDeactivation&lt;/code&gt; when the user stops.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The real-time thread stays clean.&lt;/strong&gt; No allocations, locks, logging or file I/O inside render callbacks or taps; heavy work is handed off.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-1&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;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. &lt;code&gt;AVAudioSession&lt;/code&gt; plus &lt;code&gt;AVAudioEngine&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;If you want to see the result in action, try my apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mic to Speaker – Virtual Mic (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/mic-to-speaker-virtual-mic/id1514011963&quot;&gt;https://apps.apple.com/us/app/mic-to-speaker-virtual-mic/id1514011963&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Virtual Mic (Android):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://play.google.com/store/apps/details?id=com.aryansbtloe.microphone.android&quot;&gt;https://play.google.com/store/apps/details?id=com.aryansbtloe.microphone.android&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All 24 of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/&quot;&gt;https://bestlatestapps.com/blog/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32678/building-a-real-time-mic-to-speaker-app-on-ios-with-avaudioengine</guid>
<pubDate>Tue, 27 Oct 2026 12:40:22 +0000</pubDate>
</item>
<item>
<title>Tap-to-Match Marble Game in SpriteKit and SwiftUI: Physics, Cluster Matching, Lives and Power-Ups</title>
<link>https://coderlegion.com/32677/tap-match-marble-game-spritekit-and-swiftui-physics-cluster-matching-lives-and-power-ups</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/&quot;&gt;https://bestlatestapps.com/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;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&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/marbles-popperz-icon-512.jpg&quot; alt=&quot;Marbles Popperz app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;Every game developer has a &quot;small&quot; game that taught them how games actually work. Mine is &lt;strong&gt;Marbles Popperz&lt;/strong&gt;, 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-why-spritekit-and-how-it-fits-with-swiftui&quot;&gt;1. Why SpriteKit, and how it fits with SwiftUI&lt;/h3&gt;
&lt;p&gt;For a 2D casual game on Apple platforms, SpriteKit is the shortest path. It gives you a scene graph (&lt;code&gt;SKScene&lt;/code&gt;, &lt;code&gt;SKSpriteNode&lt;/code&gt;), actions for animation (&lt;code&gt;SKAction&lt;/code&gt;), a built-in physics engine (&lt;code&gt;SKPhysicsBody&lt;/code&gt;) and particles (&lt;code&gt;SKEmitterNode&lt;/code&gt;), all rendered on the GPU, with no third-party engine to update.&lt;/p&gt;
&lt;p&gt;Since iOS 14, &lt;code&gt;SpriteView&lt;/code&gt; lets a SwiftUI view host a scene directly. So menus, the HUD and settings can be ordinary SwiftUI, and only the playfield is SpriteKit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The rule I follow: &lt;strong&gt;the scene owns the simulation; SwiftUI owns the chrome.&lt;/strong&gt; The scene writes to a shared &lt;code&gt;GameState&lt;/code&gt;, 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-a-playfield-of-marbles-that-settle-under-physics&quot;&gt;2. A playfield of marbles that settle under physics&lt;/h3&gt;
&lt;p&gt;Marbles should &lt;em&gt;behave&lt;/em&gt; 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:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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: &quot;marble-\(color)&quot;)    // from a texture atlas
        super.init(texture: texture, color: .clear,
                   size: CGSize(width: Self.radius * 2, height: Self.radius * 2))
        name = &quot;marble&quot;
        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..&amp;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)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two cheap wins: put every marble texture in one &lt;strong&gt;texture atlas&lt;/strong&gt;, so all marbles are drawn in a few batched calls; and set &lt;code&gt;ignoresSiblingOrder&lt;/code&gt; so SpriteKit is free to reorder draws for batching.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-tap-to-match-finding-the-touching-cluster&quot;&gt;3. Tap-to-match: finding the touching cluster&lt;/h3&gt;
&lt;p&gt;The listing describes the core loop simply: &quot;Tap marbles of the same color to match them… The more marbles you match, the higher your score.&quot; The approach I'd use is &lt;strong&gt;cluster matching&lt;/strong&gt;. 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.&lt;/p&gt;
&lt;p&gt;Hit-testing uses &lt;code&gt;nodes(at:)&lt;/code&gt;. The cluster is a breadth-first search over &quot;touching&quot; neighbours:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension MarbleScene {
    override func touchesBegan(_ touches: Set&amp;lt;UITouch&amp;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 &amp;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)]))   // &quot;no match&quot; wiggle
            return
        }
        pop(cluster)
        state?.score += score(forClusterOf: cluster.count)
    }

    func cluster(from start: Marble) -&amp;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&amp;lt;ObjectIdentifier&amp;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) &amp;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) -&amp;gt; Int { n * n * 5 }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-popping-with-juice&quot;&gt;4. Popping with juice&lt;/h3&gt;
&lt;p&gt;A match should feel &lt;em&gt;good&lt;/em&gt;: a quick scale-up, a fade, a particle burst and a sound. The physics then lets the marbles above fall into the gap.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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(&quot;pop.caf&quot;, waitForCompletion: false))
    }

    func spawnBurst(at point: CGPoint) {
        guard let emitter = SKEmitterNode(fileNamed: &quot;Pop.sks&quot;) else { return }   // designed in Xcode
        emitter.position = point
        emitter.numParticlesToEmit = 24
        addChild(emitter)
        emitter.run(.sequence([.wait(forDuration: 0.6), .removeFromParent()]))
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The 30 ms stagger is the cheapest &quot;juice&quot; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-a-countdown-that-survives-pausing&quot;&gt;5. A countdown that survives pausing&lt;/h3&gt;
&lt;p&gt;&quot;Watch the timer – you need to match quickly!&quot; The timer must stop when the game is paused, when a phone call comes in and when the app goes to the background. A &lt;code&gt;Timer&lt;/code&gt; or &lt;code&gt;Date&lt;/code&gt; arithmetic gets this wrong. The robust pattern is to &lt;strong&gt;accumulate frame deltas in &lt;code&gt;update(_:)&lt;/code&gt;&lt;/strong&gt;, and only while the game is actually running:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension MarbleScene {
    override func update(_ currentTime: TimeInterval) {
        defer { lastUpdate = currentTime }
        guard lastUpdate &amp;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 &amp;lt;= 0 { timeUp() }
        if children.compactMap({ $0 as? Marble }).isEmpty { levelCleared() }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;code&gt;remaining&lt;/code&gt; and &lt;code&gt;lastUpdate&lt;/code&gt; are the stored properties declared on the scene in section 2.) Three details carry the weight:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Clamp &lt;code&gt;dt&lt;/code&gt;.&lt;/strong&gt; After a pause, &lt;code&gt;currentTime&lt;/code&gt; jumps forward. Without the clamp, the first frame back would subtract all of the paused time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Guard on &lt;code&gt;isPaused&lt;/code&gt;.&lt;/strong&gt; Pausing the &lt;code&gt;SKView&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Throttle UI updates.&lt;/strong&gt; Writing to &lt;code&gt;@Published&lt;/code&gt; state 60 times a second makes SwiftUI re-render the HUD 60 times a second. Write only when the displayed value changes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pausing itself is one line, &lt;code&gt;scene.isPaused = true&lt;/code&gt;. Wire it to the scene phase going to &lt;code&gt;.inactive&lt;/code&gt; or &lt;code&gt;.background&lt;/code&gt; in SwiftUI.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-lives-and-levels&quot;&gt;6. Lives and levels&lt;/h3&gt;
&lt;p&gt;The listing has a &quot;Lives system – be careful not to run out!&quot; and &quot;Level progression – difficulty increases as you advance&quot;. 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:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct LevelConfig {
    let marbleCount: Int
    let colorCount: Int
    let seconds: TimeInterval
    let powerUpChance: Double

    static func forLevel(_ level: Int) -&amp;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 &amp;lt;= 0 { state.isGameOver = true; isPaused = true }
        else { restartLevel() }
    }

    func restartLevel() {
        enumerateChildNodes(withName: &quot;marble&quot;) { 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)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-power-ups-lightning-heart-and-bomb&quot;&gt;7. Power-ups: Lightning, Heart and Bomb&lt;/h3&gt;
&lt;p&gt;The listing names three power-ups: &quot;Lightning Flash – Clear multiple marbles at once&quot;, &quot;Heart – Gain an extra life&quot; and &quot;Bomb – Explode surrounding marbles&quot;. I'd model them as special marbles that spawn occasionally and trigger when tapped:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 &amp;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) &amp;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))
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Which marbles &quot;Lightning&quot; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-marbles-popperz&quot;&gt;8. Real-world example: Marbles Popperz&lt;/h3&gt;
&lt;p&gt;This walkthrough is based on building &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/marbles-popperz/id1601511276&quot;&gt;Marbles Popperz&lt;/a&gt;&lt;/strong&gt;. The code here is the approach I'd use, not the shipped source.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/marbles-popperz-screenshot-1.jpg&quot; alt=&quot;Marbles Popperz App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Games&lt;/strong&gt; (Puzzle, Board)&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;December 2021&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;3.7 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 16.0 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Simple tap-to-match gameplay&quot; / &quot;Match marbles by color to remove them&quot; &lt;/td&gt;&lt;td&gt;Hit-testing and cluster matching (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Beautiful visual effects and animations&quot; &lt;/td&gt;&lt;td&gt;Staggered pops and particle bursts (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Watch the timer - you need to match quickly!&quot; &lt;/td&gt;&lt;td&gt;Pause-safe countdown in &lt;code&gt;update(_:)&lt;/code&gt; (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Lives system&quot; / &quot;Level progression - difficulty increases as you advance&quot; &lt;/td&gt;&lt;td&gt;Lives and a level table (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Lightning Flash&quot;, &quot;Heart&quot;, &quot;Bomb&quot; &lt;/td&gt;&lt;td&gt;Power-ups (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Global leaderboards - compete with players worldwide&quot; &lt;/td&gt;&lt;td&gt;Game Center, covered in my Speck Match article&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/marbles-popperz-screenshot-2.jpg&quot; alt=&quot;Marbles Popperz App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-a-casual-game-a-checklist&quot;&gt;9. Before you ship a casual game: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The timer pauses&lt;/strong&gt; on pause, on interruptions and in the background, and &lt;code&gt;dt&lt;/code&gt; is clamped on resume.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The UI isn't updated every frame&lt;/strong&gt;, only when displayed values change.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Textures are in an atlas&lt;/strong&gt; and &lt;code&gt;ignoresSiblingOrder&lt;/code&gt; is on. Check the draw count with &lt;code&gt;showsDrawCount&lt;/code&gt; during development.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Finished emitters and actions are removed.&lt;/strong&gt; No invisible nodes pile up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The difficulty table is data&lt;/strong&gt;, and each level has been played by someone other than you.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Taps feel fair.&lt;/strong&gt; The hit area isn't smaller than the visible marble, and a mis-tap gets clear feedback.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Colour isn't the only cue.&lt;/strong&gt; Add patterns or symbols to marbles for colour-blind players.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sound respects the silent switch&lt;/strong&gt;, and there's a mute option.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test on the oldest supported device&lt;/strong&gt; at 60 fps with the maximum marble count.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-2&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;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 &quot;it works&quot; into &quot;it feels good&quot;. SpriteKit and SwiftUI together cover all of it with nothing to install.&lt;/p&gt;
&lt;p&gt;Play the game, or explore my other work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Marbles Popperz (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/marbles-popperz/id1601511276&quot;&gt;https://apps.apple.com/us/app/marbles-popperz/id1601511276&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What's your favourite bit of game &quot;juice&quot;? Share it in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/&quot;&gt;https://bestlatestapps.com/blog/building-a-tap-to-match-marble-game-with-spritekit-and-swiftui/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32677/tap-match-marble-game-spritekit-and-swiftui-physics-cluster-matching-lives-and-power-ups</guid>
<pubDate>Tue, 27 Oct 2026 12:39:50 +0000</pubDate>
</item>
<item>
<title>AdMob Earnings Dashboard for iOS: OAuth with PKCE, AdMob Reporting API and Offline Caching</title>
<link>https://coderlegion.com/32550/admob-earnings-dashboard-for-ios-oauth-with-pkce-admob-reporting-api-and-offline-caching</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&quot;&gt;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Signing in with ASWebAuthenticationSession and PKCE, storing tokens safely in the Keychain, calling networkReport:generate, decoding a streamed report, turning micros into money, why &quot;today&quot; is a Los Angeles day, live earning rates, and caching for offline use&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-icon-512.jpg&quot; alt=&quot;AdEarnings Pro app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;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 &lt;strong&gt;AdEarnings Pro&lt;/strong&gt;. 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: &quot;Your AdMob data is not saved, shared, or accessible to us.&quot;&lt;/p&gt;
&lt;p&gt;That promise shapes the whole architecture. There's no server of mine in the middle. The app talks &lt;strong&gt;directly&lt;/strong&gt; to Google's AdMob API using the user's own OAuth token, and everything is cached on the device.&lt;/p&gt;
&lt;p&gt;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 &lt;code&gt;America/Los_Angeles&lt;/code&gt;, which changes what &quot;today&quot; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-shape-of-the-app-no-middleman&quot;&gt;1. The shape of the app: no middleman&lt;/h3&gt;
&lt;p&gt;The data flow is deliberately short:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The user signs in with Google and grants a &lt;strong&gt;read-only reporting scope&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The app stores the refresh token in the &lt;strong&gt;Keychain&lt;/strong&gt;, on the device.&lt;/li&gt;
&lt;li&gt;The app calls &lt;code&gt;admob.googleapis.com&lt;/code&gt; &lt;strong&gt;directly&lt;/strong&gt; with a short-lived access token.&lt;/li&gt;
&lt;li&gt;Results are cached locally for offline viewing.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Google's AdMob API getting-started guide lists two scopes. &lt;code&gt;admob.readonly&lt;/code&gt; lets an app &quot;See all AdMob data&quot;, and Google notes that this &quot;doesn't include sensitive data, such as payments or campaign details&quot;. &lt;code&gt;admob.report&lt;/code&gt; lets it &quot;See ad performance and earnings reports. See publisher ID, timezone, and default currency code.&quot; For an earnings dashboard, &lt;strong&gt;&lt;code&gt;admob.report&lt;/code&gt; is the narrowest scope that works&lt;/strong&gt;, 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.&lt;/p&gt;
&lt;p&gt;The listing also notes &quot;This app requires access to your existing Google AdMob account&quot;. The API reads accounts; it can't create one.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-oauth-for-a-native-app-system-browser-pkce-no-embedded-web-views&quot;&gt;2. OAuth for a native app: system browser, PKCE, no embedded web views&lt;/h3&gt;
&lt;p&gt;Google's OAuth documentation is blunt about embedded web views. Opening the authorization page in &lt;code&gt;WKWebView&lt;/code&gt; produces a &lt;code&gt;disallowed_useragent&lt;/code&gt; error, and Google recommends its Google Sign-In for iOS library or AppAuth instead. Both are built on the same standard flow: the &lt;strong&gt;system browser&lt;/strong&gt; plus &lt;strong&gt;PKCE&lt;/strong&gt; (Proof Key for Code Exchange). Here's that flow with Apple's own &lt;code&gt;ASWebAuthenticationSession&lt;/code&gt; (iOS 12+), so you can see every step:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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() -&amp;gt; PKCE {
        var bytes = [UInt8](repeating: 0, count: 32)
        _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &amp;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() -&amp;gt; String {
        base64EncodedString().replacingOccurrences(of: &quot;+&quot;, with: &quot;-&quot;)
            .replacingOccurrences(of: &quot;/&quot;, with: &quot;_&quot;).replacingOccurrences(of: &quot;=&quot;, with: &quot;&quot;)
    }
}

@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 = &quot;1234-abc.apps.googleusercontent.com&quot;
    var redirectScheme: String { &quot;com.googleusercontent.apps.1234-abc&quot; }
    var redirectURI: String { &quot;\(redirectScheme):/oauth2redirect&quot; }

    func signIn() async throws -&amp;gt; (code: String, pkce: PKCE) {
        let pkce = PKCE.make()
        let state = UUID().uuidString
        var url = URLComponents(string: &quot;https://accounts.google.com/o/oauth2/v2/auth&quot;)!
        url.queryItems = [
            .init(name: &quot;client_id&quot;, value: clientID),
            .init(name: &quot;redirect_uri&quot;, value: redirectURI),
            .init(name: &quot;response_type&quot;, value: &quot;code&quot;),
            .init(name: &quot;scope&quot;, value: &quot;https://www.googleapis.com/auth/admob.report&quot;),
            .init(name: &quot;code_challenge&quot;, value: pkce.challenge),
            .init(name: &quot;code_challenge_method&quot;, value: &quot;S256&quot;),
            .init(name: &quot;state&quot;, 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 == &quot;state&quot; })?.value == state,       // CSRF check
              let code = items.first(where: { $0.name == &quot;code&quot; })?.value else { throw URLError(.userAuthenticationRequired) }
        return (code, pkce)
    }

    func presentationAnchor(for session: ASWebAuthenticationSession) -&amp;gt; ASPresentationAnchor {
        UIApplication.shared.connectedScenes.compactMap { ($0 as? UIWindowScene)?.keyWindow }.first ?? ASPresentationAnchor()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every parameter here comes from Google's OAuth documentation for installed apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The authorization endpoint&lt;/strong&gt; is &lt;code&gt;accounts.google.com/o/oauth2/v2/auth&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;response_type=code&lt;/code&gt;&lt;/strong&gt; is required.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The challenge is S256&lt;/strong&gt;, which Google recommends.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The verifier is 43 to 128 characters.&lt;/strong&gt; Thirty-two random bytes, base64url-encoded, gives exactly 43.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;state&lt;/code&gt;&lt;/strong&gt; protects against cross-site request forgery. Google calls it out as important, and the code checks it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On iOS 17.4 and later, &lt;code&gt;ASWebAuthenticationSession&lt;/code&gt; also accepts a &lt;code&gt;Callback&lt;/code&gt; object (&lt;code&gt;ASWebAuthenticationSession.Callback&lt;/code&gt;). SwiftUI has a &lt;code&gt;webAuthenticationSession&lt;/code&gt; environment value from iOS 16.4. Either is a tidier wrapper around the same flow.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-exchanging-the-code-and-keeping-tokens-safe&quot;&gt;3. Exchanging the code and keeping tokens safe&lt;/h3&gt;
&lt;p&gt;Next, swap the code for tokens at &lt;code&gt;oauth2.googleapis.com/token&lt;/code&gt;, sending the PKCE &lt;strong&gt;verifier&lt;/strong&gt; (not the challenge). Google's docs note that &lt;code&gt;client_secret&lt;/code&gt; doesn't apply to iOS clients:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 -&amp;gt; TokenResponse {
        try await tokenRequest([
            &quot;client_id&quot;: clientID, &quot;code&quot;: code, &quot;code_verifier&quot;: pkce.verifier,
            &quot;grant_type&quot;: &quot;authorization_code&quot;, &quot;redirect_uri&quot;: redirectURI
        ])
    }

    func refresh(using refreshToken: String) async throws -&amp;gt; TokenResponse {
        try await tokenRequest([&quot;client_id&quot;: clientID, &quot;grant_type&quot;: &quot;refresh_token&quot;, &quot;refresh_token&quot;: refreshToken])
    }

    private func tokenRequest(_ form: [String: String]) async throws -&amp;gt; TokenResponse {
        var request = URLRequest(url: URL(string: &quot;https://oauth2.googleapis.com/token&quot;)!)
        request.httpMethod = &quot;POST&quot;
        request.setValue(&quot;application/x-www-form-urlencoded&quot;, forHTTPHeaderField: &quot;Content-Type&quot;)
        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)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then store the &lt;strong&gt;refresh token&lt;/strong&gt; in the Keychain, never in &lt;code&gt;UserDefaults&lt;/code&gt;, and mark it device-only:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Security

enum TokenVault {
    static let account = &quot;admob.refresh&quot;

    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)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;AfterFirstUnlockThisDeviceOnly&lt;/code&gt; 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 &lt;code&gt;scope&lt;/code&gt;: 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 &lt;strong&gt;keep and reuse&lt;/strong&gt; the refresh token rather than signing in again each time.&lt;/p&gt;
&lt;p&gt;&quot;Revoke consent anytime&quot; maps to two things. The user can remove access from their Google Account settings, and the app can call &lt;code&gt;https://oauth2.googleapis.com/revoke?token=…&lt;/code&gt; on sign-out and then delete the Keychain item.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-asking-admob-for-a-report&quot;&gt;4. Asking AdMob for a report&lt;/h3&gt;
&lt;p&gt;The AdMob API's core endpoint is &lt;code&gt;accounts.networkReport.generate&lt;/code&gt;, a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;https://admob.googleapis.com/v1/accounts/{publisherId}/networkReport:generate&lt;/code&gt; with a &lt;code&gt;reportSpec&lt;/code&gt;. Here's a request for daily earnings and clicks over the last seven days:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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) -&amp;gt; NetworkReportRequest {
    .init(reportSpec: .init(
        dateRange: .init(startDate: start, endDate: end),
        dimensions: [&quot;DATE&quot;],
        metrics: [&quot;ESTIMATED_EARNINGS&quot;, &quot;CLICKS&quot;, &quot;IMPRESSIONS&quot;],
        localizationSettings: .init(currencyCode: currency, languageCode: &quot;en-US&quot;),
        timeZone: &quot;America/Los_Angeles&quot;
    ))
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three facts from Google's reference page shape this request:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ESTIMATED_EARNINGS&lt;/code&gt; is in micros.&lt;/strong&gt; &quot;$6.50 would be represented as 6500000.&quot; Never put that number in a &lt;code&gt;Double&lt;/code&gt; and divide casually; use &lt;code&gt;Decimal&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Currency comes from &lt;code&gt;localizationSettings.currencyCode&lt;/code&gt;.&lt;/strong&gt; Google says the currency unit of the earnings metrics is &quot;determined by the localization setting for currency&quot;. The approach I'd use for &quot;View earnings in your preferred currency&quot; is to ask AdMob for that currency directly, so the figures are AdMob's own.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The time zone currently has only one supported value.&lt;/strong&gt; Google: &quot;Warning: The 'America/Los_Angeles' is the only supported value at the moment.&quot; Section 6 is about what that means.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-decoding-a-streamed-report&quot;&gt;5. Decoding a streamed report&lt;/h3&gt;
&lt;p&gt;The response isn't a simple object. It's &quot;the result of a server-side streaming RPC&quot;: a JSON &lt;strong&gt;array&lt;/strong&gt; whose first element has a &lt;code&gt;header&lt;/code&gt;, then one &lt;code&gt;row&lt;/code&gt; per result, then a &lt;code&gt;footer&lt;/code&gt;. Each element has exactly one of those keys. So decode each element as an enum-like struct:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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          // &quot;20261022&quot;
    let earnings: Decimal    // in report currency
    let clicks: Int
}

func parse(_ data: Data) throws -&amp;gt; [DailyEarnings] {
    try JSONDecoder().decode([ReportChunk].self, from: data).compactMap(\.row).map { row in
        let micros = Decimal(string: row.metricValues[&quot;ESTIMATED_EARNINGS&quot;]?.microsValue ?? &quot;0&quot;) ?? 0
        return DailyEarnings(day: row.dimensionValues[&quot;DATE&quot;]?.value ?? &quot;&quot;,
                             earnings: micros / 1_000_000,
                             clicks: Int(row.metricValues[&quot;CLICKS&quot;]?.integerValue ?? &quot;0&quot;) ?? 0)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Google's example response shows &lt;code&gt;&quot;microsValue&quot;: 6500000&lt;/code&gt; 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 &lt;code&gt;init(from:)&lt;/code&gt;). The &lt;code&gt;DATE&lt;/code&gt; dimension value is &lt;code&gt;YYYYMMDD&lt;/code&gt;, as Google's reference describes.&lt;/p&gt;
&lt;p&gt;Displaying money is then &lt;code&gt;earnings.formatted(.currency(code: currency))&lt;/code&gt;. Don't hand-roll formats, because symbol placement and grouping vary by locale.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-why-today-is-a-los-angeles-day&quot;&gt;6. Why &quot;today&quot; is a Los Angeles day&lt;/h3&gt;
&lt;p&gt;Here's the subtle part. If reports can only be in &lt;code&gt;America/Los_Angeles&lt;/code&gt;, then AdMob's &quot;today&quot; starts at &lt;strong&gt;midnight Pacific time&lt;/strong&gt;. 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 &quot;Today&quot; without explanation will confuse anyone outside the Pacific time zone.&lt;/p&gt;
&lt;p&gt;The approach I'd use is to compute &quot;today&quot; in the report's calendar and say so in the UI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ReportCalendar {
    static let zone = TimeZone(identifier: &quot;America/Los_Angeles&quot;)!
    static var calendar: Calendar { var c = Calendar(identifier: .gregorian); c.timeZone = zone; return c }

    static func reportDate(for date: Date = .now) -&amp;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) -&amp;gt; TimeInterval {
        now.timeIntervalSince(calendar.startOfDay(for: now))
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;live earning rates&lt;/strong&gt; in the listing (&quot;per second, minute, and hour&quot;) follow naturally. Today's earnings divided by seconds into the report day gives an average rate so far:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;func rates(todayEarnings: Decimal, now: Date = .now) -&amp;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)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Label it honestly, as &quot;average so far today&quot;. Ad revenue isn't evenly spread over a day, and AdMob's figures are &lt;em&gt;estimated&lt;/em&gt; earnings that can be revised.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-pull-to-refresh-offline-cache-and-the-weekly-view&quot;&gt;7. Pull to refresh, offline cache and the weekly view&lt;/h3&gt;
&lt;p&gt;&quot;Pull-to-refresh for fresh data&quot; is one modifier, &lt;code&gt;refreshable&lt;/code&gt; (iOS 15+). &quot;Offline-ready with cached data&quot; means saving the last good response, with a timestamp, and showing it whenever the network fails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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: &quot;report-week.json&quot;)

    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 &quot;Updated 2 h ago&quot; 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(&quot;Updated \(updatedAt, format: .relative(presentation: .named))&quot;).font(.caption)
            }
            ForEach(model.days.reversed()) { day in
                LabeledContent(day.day, value: day.earnings, format: .currency(code: &quot;USD&quot;))
            }
        }
        .refreshable { await model.refresh(using: api) }
        .task { await model.refresh(using: api) }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Putting the cache in &lt;strong&gt;Caches&lt;/strong&gt; 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. (&lt;code&gt;AdMobAPI&lt;/code&gt; here is the small wrapper around section 4's request, with a valid access token.)&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-adearnings-pro&quot;&gt;8. Real-world example: AdEarnings Pro&lt;/h3&gt;
&lt;p&gt;This walkthrough comes from building &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/adearnings-pro/id6754509445&quot;&gt;AdEarnings Pro&lt;/a&gt;&lt;/strong&gt;. The code above is the approach I'd use, written for this article, not a copy of the app's source.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-screenshot-1.jpg&quot; alt=&quot;AdEarnings Pro App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Developer Tools&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;November 2025&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;No ratings yet in the US store&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 17.6 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Secure Google Sign-In authentication&quot; / &quot;Google OAuth authentication is required to access AdMob API services&quot; &lt;/td&gt;&lt;td&gt;System-browser OAuth with PKCE, tokens in the Keychain (sections 2–3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Your AdMob data is not saved, shared, or accessible to us&quot; &lt;/td&gt;&lt;td&gt;Direct device-to-Google calls, no middleman (section 1)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;View today's earnings with live updates. Compare yesterday's performance.&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;networkReport:generate&lt;/code&gt; with the &lt;code&gt;DATE&lt;/code&gt; dimension (sections 4–5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Live earning rates per second, minute, and hour&quot; &lt;/td&gt;&lt;td&gt;Rates over the Pacific report day (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Weekly average trends for earnings and clicks&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;ESTIMATED_EARNINGS&lt;/code&gt; and &lt;code&gt;CLICKS&lt;/code&gt; (sections 4 and 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Convert earnings to any currency…&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;localizationSettings.currencyCode&lt;/code&gt; (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Pull-to-refresh for fresh data&quot; / &quot;Offline-ready with cached data&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;refreshable&lt;/code&gt; and a timestamped cache (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Revoke consent anytime&quot; &lt;/td&gt;&lt;td&gt;Google Account settings and the revoke endpoint (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-admob-earnings-dashboard-for-ios/adearnings-pro-screenshot-2.jpg&quot; alt=&quot;AdEarnings Pro App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;The listing also mentions unpaid earnings, last payment history and GDPR consent management. Those are outside this article's scope.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-an-oauth-powered-dashboard-a-checklist&quot;&gt;9. Before you ship an OAuth-powered dashboard: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Use the narrowest scope that works&lt;/strong&gt;, and check the granted &lt;code&gt;scope&lt;/code&gt; in the token response.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use the system browser, PKCE (S256) and a &lt;code&gt;state&lt;/code&gt; check.&lt;/strong&gt; No &lt;code&gt;WKWebView&lt;/code&gt; for sign-in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the refresh token in the Keychain&lt;/strong&gt;, device-only, and reuse it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sign-out revokes the token&lt;/strong&gt; and clears the Keychain and caches.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Handle money as &lt;code&gt;Decimal&lt;/code&gt; from micros&lt;/strong&gt;, formatted with the currency's own format.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make the report time zone explicit.&lt;/strong&gt; &quot;Today&quot; is a Pacific day, and the UI says so.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Label rates and estimates honestly&lt;/strong&gt;: &quot;average so far&quot; and &quot;estimated&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Show cached data instantly&lt;/strong&gt;, with a visible &quot;Updated…&quot; time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Complete Google's OAuth app verification&lt;/strong&gt; if your scopes need it before public release. Google's docs describe an &quot;unverified app&quot; screen otherwise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No third-party analytics touches the earnings data.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-3&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;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 &quot;today&quot; is a Los Angeles day. Get those right, and a glance at your phone replaces a dozen trips to a desktop website.&lt;/p&gt;
&lt;p&gt;Try the app, or explore my other work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AdEarnings Pro (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/adearnings-pro/id6754509445&quot;&gt;https://apps.apple.com/us/app/adearnings-pro/id6754509445&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What's the one metric you check most in your ad dashboard? Tell me in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&quot;&gt;https://bestlatestapps.com/blog/building-an-admob-earnings-dashboard-for-ios/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32550/admob-earnings-dashboard-for-ios-oauth-with-pkce-admob-reporting-api-and-offline-caching</guid>
<pubDate>Mon, 26 Oct 2026 12:42:23 +0000</pubDate>
</item>
<item>
<title>Offline Quotes App in SwiftUI: 50K-Quote Library, Daily Quote, Streaks and Share-as-Image</title>
<link>https://coderlegion.com/32549/offline-quotes-app-in-swiftui-50k-quote-library-daily-quote-streaks-and-share-as-image</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-an-offline-quotes-app-in-swiftui/&quot;&gt;https://bestlatestapps.com/blog/building-an-offline-quotes-app-in-swiftui/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Bundling and importing a large library into SwiftData, picking a &quot;quote of the day&quot; deterministically, search with recent queries, collections, time-zone-safe streaks, rendering shareable quote cards with ImageRenderer, and Light/Dark/Auto themes&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-offline-quotes-app-in-swiftui/facts-and-quotes-icon-512.jpg&quot; alt=&quot;Facts and Quotes app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;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 &quot;quote of the day&quot; 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?&lt;/p&gt;
&lt;p&gt;I've worked through those questions building &lt;strong&gt;Facts and Quotes&lt;/strong&gt;, 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-ship-the-library-in-the-bundle-import-it-once&quot;&gt;1. Ship the library in the bundle, import it once&lt;/h3&gt;
&lt;p&gt;&quot;Offline access, read anywhere&quot; 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 &lt;strong&gt;querying&lt;/strong&gt; them is better done from a real store, so I'd import into SwiftData on first launch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The import runs once, in the background, in batches, so memory stays flat and the UI never blocks:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;@ModelActor
actor LibraryImporter {
    func importIfNeeded(bundleVersion: Int) throws {
        let key = &quot;library.importedVersion&quot;
        guard UserDefaults.standard.integer(forKey: key) &amp;lt; bundleVersion,
              let url = Bundle.main.url(forResource: &quot;quotes&quot;, withExtension: &quot;json&quot;) else { return }

        let records = try JSONDecoder().decode([QuoteRecord].self, from: Data(contentsOf: url))
        let existing = Set(try modelContext.fetch(FetchDescriptor&amp;lt;Quote&amp;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)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two design points:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A library version number.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stable IDs.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a &quot;daily new quotes automatically updated&quot; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-a-quote-of-the-day-with-no-server&quot;&gt;2. A quote of the day with no server&lt;/h3&gt;
&lt;p&gt;A &quot;daily quote&quot; should be the same all day, change at midnight and need no network. The trick is a &lt;strong&gt;deterministic pick from the local date&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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) -&amp;gt; Int {
        guard count &amp;gt; 0 else { return 0 }
        let c = calendar.dateComponents([.year, .month, .day], from: date)
        let key = &quot;\(c.year!)-\(c.month!)-\(c.day!)&quot;
        return Int(stableHash(key) % UInt64(count))
    }

    /// FNV-1a: stable across launches and devices (unlike Swift's `hashValue`).
    static func stableHash(_ string: String) -&amp;gt; UInt64 {
        var hash: UInt64 = 0xcbf2_9ce4_8422_2325
        for byte in string.utf8 {
            hash ^= UInt64(byte)
            hash &amp;amp;*= 0x0000_0100_0000_01B3
        }
        return hash
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The gotcha worth a paragraph: &lt;strong&gt;don't use &lt;code&gt;String.hashValue&lt;/code&gt;&lt;/strong&gt;. Swift randomises hashing per process launch, so your &quot;quote of the day&quot; would change every time the app opened. Use a fixed algorithm like FNV-1a.&lt;/p&gt;
&lt;p&gt;Then fetch by a sorted offset rather than loading everything:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;func quoteOfTheDay(in context: ModelContext) throws -&amp;gt; Quote? {
    let total = try context.fetchCount(FetchDescriptor&amp;lt;Quote&amp;gt;())          // iOS 17+
    var descriptor = FetchDescriptor&amp;lt;Quote&amp;gt;(sortBy: [SortDescriptor(\.id)])
    descriptor.fetchOffset = DailyPick.index(count: total)
    descriptor.fetchLimit = 1
    return try context.fetch(descriptor).first
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sorting by the stable ID matters too. Without a sort order, &quot;the 1,234th quote&quot; isn't guaranteed to be the same row tomorrow.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-search-across-content-author-and-category-with-recent-searches&quot;&gt;3. Search across content, author and category, with recent searches&lt;/h3&gt;
&lt;p&gt;SwiftUI's &lt;code&gt;searchable&lt;/code&gt; (iOS 16+) and SwiftData's &lt;code&gt;#Predicate&lt;/code&gt; (iOS 17+) do most of the work. &lt;code&gt;localizedStandardContains&lt;/code&gt; gives the case- and diacritic-insensitive matching people expect from a search box:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftUI
import SwiftData

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

    var body: some View {
        NavigationStack {
            QuoteResults(query: text)
                .navigationTitle(&quot;Search&quot;)
                .searchable(text: $text, prompt: &quot;Quotes, authors, categories&quot;)
                .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&amp;lt;Quote&amp;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(&quot;“\(quote.text)”&quot;)
                Text(&quot;— \(quote.author)&quot;).font(.caption).foregroundStyle(.secondary)
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rebuilding the &lt;code&gt;Query&lt;/code&gt; 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 &lt;code&gt;@AppStorage&lt;/code&gt; is the right home for them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-collections-and-favourites&quot;&gt;4. Collections and favourites&lt;/h3&gt;
&lt;p&gt;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 &quot;automatically syncs your liked quotes&quot;, so I'd treat it as a &lt;em&gt;virtual&lt;/em&gt; collection, a query, rather than a stored one that can drift out of sync:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct FavoritesView: View {
    @Query(filter: #Predicate&amp;lt;Quote&amp;gt; { $0.isFavorite }, sort: \Quote.author)
    private var favorites: [Quote]

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

func add(_ quote: Quote, to collection: QuoteCollection) {
    guard !collection.quotes.contains(where: { $0.id == quote.id }) else { return }
    collection.quotes.append(quote)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deleting a collection shouldn't delete its quotes. The default delete rule for a relationship, nullify, does exactly that.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-streaks-that-survive-time-zones&quot;&gt;5. Streaks that survive time zones&lt;/h3&gt;
&lt;p&gt;A streak is &quot;the number of consecutive days, ending today, on which you read&quot;. The bugs come from storing &lt;em&gt;timestamps&lt;/em&gt; and doing arithmetic in seconds. Then 23:50 and 00:10 count as the same day, or a flight makes a day disappear.&lt;/p&gt;
&lt;p&gt;The fix is to store &lt;strong&gt;calendar days as the user experienced them&lt;/strong&gt;, as plain date strings, and count backwards:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

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

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

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

    func currentStreak(asOf now: Date = .now) -&amp;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
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why this is honest:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Each day is recorded in the local calendar at the moment it happens.&lt;/strong&gt; Reading at 23:55 in Delhi is &quot;the 22nd&quot;, whatever happens afterwards.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Today doesn't break the streak until it's over.&lt;/strong&gt; Opening the app at 8 a.m. shouldn't show zero because you haven't read yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Calendar arithmetic, not 86,400-second arithmetic.&lt;/strong&gt; &lt;code&gt;date(byAdding: .day…)&lt;/code&gt; handles daylight-saving days of 23 or 25 hours.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Achievements, like &quot;7-day streak&quot; or &quot;read 100 quotes&quot;, are then pure functions of this data. Compute them; don't store them separately.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-share-as-a-beautiful-image&quot;&gt;6. Share as a beautiful image&lt;/h3&gt;
&lt;p&gt;&quot;Share quotes as beautiful images or text&quot; is two lines of SwiftUI once you have the pieces: &lt;code&gt;ImageRenderer&lt;/code&gt; (iOS 16+) to rasterise a SwiftUI view, and &lt;code&gt;ShareLink&lt;/code&gt; with a &lt;code&gt;Transferable&lt;/code&gt; item (iOS 16+):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftUI

struct QuoteCard: View {
    let quote: Quote
    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            Text(&quot;“\(quote.text)”&quot;).font(.system(size: 44, weight: .semibold, design: .serif))
            Text(&quot;— \(quote.author)&quot;).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 { &quot;“\($0.quote.text)” — \($0.quote.author)&quot; }   // text fallback
    }
}

struct ShareButton: View {
    let quote: Quote
    var body: some View {
        ShareLink(item: ShareableQuote(quote: quote),
                  preview: SharePreview(&quot;Quote by \(quote.author)&quot;)) {
            Label(&quot;Share&quot;, systemImage: &quot;square.and.arrow.up&quot;)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Details that matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Design the card at output pixel size&lt;/strong&gt; and render with &lt;code&gt;scale = 1&lt;/code&gt;. Sizes stay predictable, and text is sharp at the size social apps actually use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Offer text too.&lt;/strong&gt; The &lt;code&gt;ProxyRepresentation&lt;/code&gt; gives the share sheet a plain-text version, so Messages or Notes receive words, not a picture of words.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rendering happens on the main actor.&lt;/strong&gt; &lt;code&gt;ImageRenderer&lt;/code&gt; is main-actor bound, hence the &lt;code&gt;MainActor.run&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-light-dark-and-auto-with-colour-tokens&quot;&gt;7. Light, Dark and Auto, with colour tokens&lt;/h3&gt;
&lt;p&gt;&quot;Light, Dark, and Auto&quot; is a three-way setting where Auto means &quot;follow the system&quot;. In SwiftUI, &lt;code&gt;preferredColorScheme(nil)&lt;/code&gt; is exactly that:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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(&quot;theme&quot;) private var theme = ThemeSetting.auto
    var body: some Scene {
        WindowGroup {
            RootView().preferredColorScheme(theme.scheme)
        }
        .modelContainer(for: [Quote.self, QuoteCollection.self])
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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 (&lt;code&gt;Color(&quot;surfaceElevated&quot;)&lt;/code&gt;, &lt;code&gt;Color(&quot;textSecondary&quot;)&lt;/code&gt;) rather than by hue. Then a theme change is free, and no screen hard-codes &lt;code&gt;.black&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-facts-and-quotes&quot;&gt;8. Real-world example: Facts and Quotes&lt;/h3&gt;
&lt;p&gt;All of this comes from building &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/facts-and-quotes/id1607878544&quot;&gt;Facts and Quotes&lt;/a&gt;&lt;/strong&gt;. The code above is the approach I'd use, written for this article, not a copy of the app's source.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-offline-quotes-app-in-swiftui/facts-and-quotes-screenshot-1.jpg&quot; alt=&quot;Facts and Quotes App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Health &amp;amp; Fitness&lt;/strong&gt; and &lt;strong&gt;Productivity&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;August 2022&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3 ratings&lt;/strong&gt; in the US store, all &lt;strong&gt;5 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 17.6 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;50,000+ curated quotes&quot;, &quot;Offline access - read anywhere, anytime&quot; &lt;/td&gt;&lt;td&gt;Bundled library imported into SwiftData (section 1)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Daily new quotes automatically updated&quot; &lt;/td&gt;&lt;td&gt;Versioned importer plus a deterministic daily pick (sections 1–2)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Search quotes by content, author, or category&quot; / &quot;Recent searches&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;searchable&lt;/code&gt; + &lt;code&gt;#Predicate&lt;/code&gt; + &lt;code&gt;searchSuggestions&lt;/code&gt; (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Create custom collections…&quot; / &quot;Favorites collection automatically syncs your liked quotes&quot; &lt;/td&gt;&lt;td&gt;SwiftData relationships and a query-based Favorites (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Track your reading streak and daily progress&quot; / &quot;Unlock achievements&quot; &lt;/td&gt;&lt;td&gt;Calendar-day streaks (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Share quotes as beautiful images or text&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;ImageRenderer&lt;/code&gt; + &lt;code&gt;Transferable&lt;/code&gt; + &lt;code&gt;ShareLink&lt;/code&gt; (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Light, Dark, and Auto theme support&quot; / &quot;50+ color tokens&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;preferredColorScheme&lt;/code&gt; and semantic colours (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-offline-quotes-app-in-swiftui/facts-and-quotes-screenshot-2.jpg&quot; alt=&quot;Facts and Quotes App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-a-content-app-a-checklist&quot;&gt;9. Before you ship a content app: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The first launch is fast.&lt;/strong&gt; The import runs in the background, in batches, and the UI shows today's quote immediately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every item has a stable ID.&lt;/strong&gt; Updates never break favourites or collections.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The daily pick uses a stable hash.&lt;/strong&gt; No &lt;code&gt;hashValue&lt;/code&gt;, and the fetch has a fixed sort order.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Search is case- and accent-insensitive&lt;/strong&gt;, and recent searches are capped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Streaks are tested across midnight, daylight saving and a time-zone change.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shared images are rendered at their target pixel size&lt;/strong&gt;, with a text fallback.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auto theme follows the system&lt;/strong&gt;; no colour is hard-coded.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Attribution is correct.&lt;/strong&gt; Every quote has an author, or &quot;Unknown&quot;. Misattributed quotes are the most common complaint in this category.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accessibility.&lt;/strong&gt; Quote text scales with Dynamic Type, and VoiceOver reads the author after the quote.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-4&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;The app is simple to &lt;em&gt;use&lt;/em&gt; because the hard parts are hidden. Ship content offline and import it once. Make &quot;daily&quot; 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.&lt;/p&gt;
&lt;p&gt;Try the app, or explore my other work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Facts and Quotes (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/facts-and-quotes/id1607878544&quot;&gt;https://apps.apple.com/us/app/facts-and-quotes/id1607878544&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Got a favourite quote? Drop it in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-an-offline-quotes-app-in-swiftui/&quot;&gt;https://bestlatestapps.com/blog/building-an-offline-quotes-app-in-swiftui/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32549/offline-quotes-app-in-swiftui-50k-quote-library-daily-quote-streaks-and-share-as-image</guid>
<pubDate>Mon, 26 Oct 2026 12:42:11 +0000</pubDate>
</item>
<item>
<title>Offline eBook Reader for iOS: Downloads, Typography, Reading Progress and Highlights</title>
<link>https://coderlegion.com/32548/offline-ebook-reader-for-ios-downloads-typography-reading-progress-and-highlights</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-an-offline-ebook-reader-for-ios/&quot;&gt;https://bestlatestapps.com/blog/building-an-offline-ebook-reader-for-ios/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;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&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-offline-ebook-reader-for-ios/novels-club-icon-512.jpg&quot; alt=&quot;Novels Club app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;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. &lt;strong&gt;Novels Club&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;A reading app sounds like &quot;show text in a scroll view&quot;. 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: &lt;strong&gt;how do you remember someone's place when they change the font size and every &quot;page&quot; moves?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;This article walks through how I'd build each part. The APIs are checked against Apple's documentation, including two iOS 27 deprecations in &lt;code&gt;UITextView&lt;/code&gt; that matter for highlighting.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-library-model&quot;&gt;1. The library model&lt;/h3&gt;
&lt;p&gt;A book has metadata you can browse before downloading, and an optional local file once it's downloaded. In SwiftData:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 }

    /// &quot;About 6 h 40 min&quot; 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 }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two decisions shape everything later. &lt;strong&gt;Positions are character offsets, not page numbers&lt;/strong&gt; (section 5). And &lt;strong&gt;marks cascade-delete with their book&lt;/strong&gt;, so removing a book never leaves orphaned highlights behind.&lt;/p&gt;
&lt;p&gt;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 &quot;about&quot;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-downloading-books-and-keeping-them-out-of-icloud-backup&quot;&gt;2. Downloading books, and keeping them out of iCloud backup&lt;/h3&gt;
&lt;p&gt;&quot;Download books for offline reading&quot; with URLSession's async API (iOS 15+) is short. The details that matter are &lt;em&gt;where&lt;/em&gt; the file goes and &lt;em&gt;whether it's backed up&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

enum BookStore {
    static var directory: URL {
        let dir = URL.applicationSupportDirectory.appending(path: &quot;Books&quot;, 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: &quot;\(book.id).txt&quot;)
        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
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Application Support, not Caches.&lt;/strong&gt; The system can purge Caches under storage pressure, and a book vanishing mid-read on a plane is exactly what &quot;offline reading&quot; promises won't happen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;isExcludedFromBackup&lt;/code&gt;.&lt;/strong&gt; The books can be downloaded again, so they shouldn't eat the user's iCloud quota. Apple's storage guidance asks for exactly this.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Move the temp file immediately.&lt;/strong&gt; The async &lt;code&gt;download(from:)&lt;/code&gt; gives you a temporary file that's deleted once you're done with it, so move it before doing anything else.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a progress bar, pass a &lt;code&gt;URLSessionTaskDelegate&lt;/code&gt; to &lt;code&gt;download(from:delegate:)&lt;/code&gt;, or use a &lt;code&gt;URLSessionDownloadTask&lt;/code&gt; with a &lt;code&gt;URLSessionDownloadDelegate&lt;/code&gt; (iOS 7+) and observe the task's &lt;code&gt;progress&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-preview-before-downloading&quot;&gt;3. Preview before downloading&lt;/h3&gt;
&lt;p&gt;&quot;Preview books before downloading&quot; doesn't need a separate preview file. If the server supports range requests, ask for just the first few kilobytes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension BookStore {
    static func preview(_ book: Book, bytes: Int = 16_384) async throws -&amp;gt; String {
        var request = URLRequest(url: book.remoteURL)
        request.setValue(&quot;bytes=0-\(bytes - 1)&quot;, forHTTPHeaderField: &quot;Range&quot;)
        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..&amp;lt;4 {
            if let text = String(data: slice.dropLast(trim), encoding: .utf8) { return text }
        }
        return &quot;&quot;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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 &lt;code&gt;String(data:encoding:)&lt;/code&gt; returns nil for the whole preview.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-a-reader-with-real-typography-settings&quot;&gt;4. A reader with real typography settings&lt;/h3&gt;
&lt;p&gt;The listing promises &quot;font size, font family, line height, margins&quot; and &quot;light, dark, and custom color schemes&quot;. &lt;code&gt;UITextView&lt;/code&gt; (read-only) gives you excellent text layout, selection and accessibility for free. Since iOS 16 it can be created explicitly with TextKit 2 via &lt;code&gt;init(usingTextLayoutManager:)&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftUI
import UIKit

struct ReaderSettings: Equatable {
    var fontName = &quot;Georgia&quot;
    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) -&amp;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() -&amp;gt; Coordinator { Coordinator(offset: $offset) }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;applied != settings&lt;/code&gt; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-reading-progress-that-survives-font-changes&quot;&gt;5. Reading progress that survives font changes&lt;/h3&gt;
&lt;p&gt;Here's the trap. If you save &quot;page 143&quot;, 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 &lt;strong&gt;the character the reader is looking at&lt;/strong&gt;, a UTF-16 offset into the text, which no layout change can move:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;final class Coordinator: NSObject, UITextViewDelegate {
    var offset: Binding&amp;lt;Int&amp;gt;
    var applied: ReaderSettings?
    var onHighlight: (([NSRange]) -&amp;gt; Void)?      // set by the reader screen to save Marks
    init(offset: Binding&amp;lt;Int&amp;gt;) { self.offset = offset }

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

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

    func search(_ query: String, translation: String, limit: Int = 100) throws -&amp;gt; [Hit] {
        // Turn &quot;love one another&quot; into &quot;love* one* another*&quot;: prefix match on every word.
        let terms = query
            .split(whereSeparator: { !$0.isLetter &amp;amp;&amp;amp; !$0.isNumber })
            .map { &quot;\($0)*&quot; }
            .joined(separator: &quot; &quot;)
        guard !terms.isEmpty else { return [] }

        let sql = &quot;&quot;&quot;
        SELECT id, snippet(verses_fts, 0, '[', ']', '…', 12)
        FROM verses_fts
        WHERE verses_fts MATCH ? AND translation = ?
        ORDER BY rank LIMIT ?
        &quot;&quot;&quot;
        var stmt: OpaquePointer?
        guard sqlite3_prepare_v2(db, sql, -1, &amp;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
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few things that matter more than the SQL:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sanitise the query.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Debounce in the UI.&lt;/strong&gt; Search on every keystroke, but cancel the previous task. A &lt;code&gt;.task(id: query)&lt;/code&gt; modifier in SwiftUI with a short &lt;code&gt;Task.sleep&lt;/code&gt; at the top gives you that for free.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run it off the main thread.&lt;/strong&gt; It's fast, but &quot;fast&quot; on an older iPhone in Low Power Mode isn't the same as on your desk.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Search history is user data&lt;/strong&gt;, so it goes in the user store (next section), not the read-only file.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-user-data-core-data-with-cloudkit-mirroring&quot;&gt;5. User data: Core Data with CloudKit mirroring&lt;/h3&gt;
&lt;p&gt;The listing says &quot;iCloud sync across all your devices… Access your highlights, notes, and bookmarks anywhere&quot;. The app supports iOS 16.0 and later, which rules out SwiftData (iOS 17+). The natural fit is &lt;strong&gt;&lt;code&gt;NSPersistentCloudKitContainer&lt;/code&gt;&lt;/strong&gt; (iOS 13+), which keeps a normal local Core Data store and mirrors it to the user's private CloudKit database in the background.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import CoreData

final class UserStore {
    static let shared = UserStore()
    let container: NSPersistentCloudKitContainer

    private init() {
        container = NSPersistentCloudKitContainer(name: &quot;UserData&quot;) // UserData.xcdatamodeld

        guard let description = container.persistentStoreDescriptions.first else {
            fatalError(&quot;No store description&quot;)
        }
        description.cloudKitContainerOptions =
            NSPersistentCloudKitContainerOptions(containerIdentifier: &quot;iCloud.com.example.bible&quot;)
        // 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(&quot;Store failed to load: \(error)&quot;) }
        }
        container.viewContext.automaticallyMergesChangesFromParent = true
        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;Highlight&lt;/code&gt; entity is deliberately boring: &lt;code&gt;verseID&lt;/code&gt; (Integer 64), &lt;code&gt;translation&lt;/code&gt; (String, optional), &lt;code&gt;colorName&lt;/code&gt; (String), &lt;code&gt;createdAt&lt;/code&gt; (Date) and a UUID &lt;code&gt;id&lt;/code&gt;. A few CloudKit rules shape that model, and they catch everyone once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Every attribute must be optional or have a default value.&lt;/strong&gt; CloudKit can deliver records in any order and partially.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No unique constraints.&lt;/strong&gt; 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 &lt;code&gt;verseID&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relationships must be optional&lt;/strong&gt; and need inverses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enable the capabilities:&lt;/strong&gt; iCloud with CloudKit and your container, plus Background Modes → Remote notifications, so pushes from CloudKit can wake the app to sync.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deploy the schema to production&lt;/strong&gt; in the CloudKit Console before you ship. A development schema works on your device and silently fails for users. During development, &lt;code&gt;initializeCloudKitSchema(options:)&lt;/code&gt; can create the schema from your model for you.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Saving a highlight is then plain Core Data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension UserStore {
    func toggleHighlight(_ ref: VerseRef, color: String) throws {
        let context = container.viewContext
        let request = NSFetchRequest&amp;lt;NSManagedObject&amp;gt;(entityName: &quot;Highlight&quot;)
        request.predicate = NSPredicate(format: &quot;verseID == %lld&quot;, Int64(ref.id))

        let existing = try context.fetch(request)
        if existing.isEmpty {
            let h = NSEntityDescription.insertNewObject(forEntityName: &quot;Highlight&quot;, into: context)
            h.setValue(UUID(), forKey: &quot;id&quot;)
            h.setValue(Int64(ref.id), forKey: &quot;verseID&quot;)
            h.setValue(color, forKey: &quot;colorName&quot;)
            h.setValue(Date(), forKey: &quot;createdAt&quot;)
        } else {
            existing.forEach(context.delete) // also cleans up offline duplicates
        }
        try context.save()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-listen-mode-with-avspeechsynthesizer&quot;&gt;6. Listen mode with AVSpeechSynthesizer&lt;/h3&gt;
&lt;p&gt;The listing's &quot;Listen &amp;amp; Read&quot; section mentions text-to-speech and auto-scroll. On iOS that's &lt;code&gt;AVSpeechSynthesizer&lt;/code&gt; (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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 = &quot;en-US&quot;) {
        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]
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The view observes &lt;code&gt;currentVerse&lt;/code&gt; and calls &lt;code&gt;ScrollViewReader.scrollTo(_:anchor:)&lt;/code&gt; with the verse ID. For word-level highlighting, implement &lt;code&gt;speechSynthesizer(_:willSpeakRangeOfSpeechString:utterance:)&lt;/code&gt; as well.&lt;/p&gt;
&lt;p&gt;Two practical notes. If you want speech to continue with the screen locked (&quot;perfect for listening while commuting&quot;), set the audio session category to &lt;code&gt;.playback&lt;/code&gt; and add the &lt;code&gt;audio&lt;/code&gt; 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 &lt;code&gt;AVSpeechSynthesisVoice(language:)&lt;/code&gt; returns &lt;code&gt;nil&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-verse-of-the-day-on-the-home-screen-with-widgetkit&quot;&gt;7. Verse of the Day on the Home Screen with WidgetKit&lt;/h3&gt;
&lt;p&gt;The listing has &quot;Home screen widgets: quick access to verse of the day&quot;. 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:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The text database is read-only and in the bundle&lt;/strong&gt;, so the widget extension can ship its own copy (or you can share it through an App Group container). No sync needed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The verse of the day is a deterministic function of the date.&lt;/strong&gt; Every device shows the same verse without a server.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import WidgetKit
import SwiftUI

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

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

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

    func getTimeline(in context: Context, completion: @escaping (Timeline&amp;lt;VerseEntry&amp;gt;) -&amp;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) -&amp;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: &quot;VerseOfTheDay&quot;, 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(&quot;Verse of the Day&quot;)
        .supportedFamilies([.systemSmall, .systemMedium])
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;DailyVerses.list&lt;/code&gt; is a small curated array compiled into the extension. With an iOS 16 deployment target, wrap &lt;code&gt;containerBackground&lt;/code&gt; in an availability check, since that modifier is iOS 17+. The &lt;code&gt;.after(tomorrow)&lt;/code&gt; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-bible-study-ai-read-offline&quot;&gt;8. Real-world example: Bible Study AI – Read Offline&lt;/h3&gt;
&lt;p&gt;All of this comes from building and maintaining my own app, &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/bible-study-ai-read-offline/id1508795537&quot;&gt;Bible Study AI – Read Offline&lt;/a&gt;&lt;/strong&gt;. 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.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-offline-first-bible-reader-on-ios/bible-study-ai-screenshot-1.jpg&quot; alt=&quot;Bible Study AI App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its public App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in the &lt;strong&gt;Books&lt;/strong&gt; and &lt;strong&gt;Education&lt;/strong&gt; categories&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;April 2020&lt;/strong&gt;: my first app on the store&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;113 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;4.6 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 16.0 and later&lt;/strong&gt;, on iPhone and iPad&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here's how the listing's features map to the sections above:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Read all 66 books of the Bible offline&quot; / &quot;Multiple translations: KJV, ASV, Webster, World English, and more&quot; &lt;/td&gt;&lt;td&gt;Bundled read-only SQLite, one table keyed by translation + verse ID (sections 2–3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Search across all books, chapters, and verses&quot; / &quot;Advanced search with history&quot; &lt;/td&gt;&lt;td&gt;FTS5 index shipped in the file, sanitised prefix queries (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Highlight verses with multiple colors&quot; / &quot;Add personal notes&quot; / &quot;Bookmark favorite passages&quot; &lt;/td&gt;&lt;td&gt;Small Core Data entities keyed by verse ID (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;iCloud sync across all your devices&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;NSPersistentCloudKitContainer&lt;/code&gt; mirroring to the private database (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Text-to-speech support&quot; / &quot;Auto-scroll for hands-free reading&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;AVSpeechSynthesizer&lt;/code&gt; and its delegate driving the scroll position (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Home screen widgets: quick access to verse of the day&quot; &lt;/td&gt;&lt;td&gt;WidgetKit &lt;code&gt;StaticConfiguration&lt;/code&gt;, date-derived entry (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/building-an-offline-first-bible-reader-on-ios/bible-study-ai-screenshot-2.jpg&quot; alt=&quot;Bible Study AI App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;The listing also covers reading plans, daily devotionals, a prayer journal with reminders and a &quot;Bible Study Assistant&quot; 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 &lt;em&gt;isn't&lt;/em&gt; offline, and the most important design rule there is simply to make sure the rest of the app never waits on it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-an-offline-first-reader-a-checklist&quot;&gt;9. Before you ship an offline-first reader: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Airplane-mode test from a fresh install.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Text and user data are separate.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stable IDs everywhere.&lt;/strong&gt; Every highlight, note and bookmark points at a canonical verse ID, never at a row number or a translation-specific string.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The CloudKit model follows the rules.&lt;/strong&gt; Optional attributes or defaults, no unique constraints, optional relationships with inverses, and the schema is deployed to production.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Duplicates are handled.&lt;/strong&gt; Two offline devices editing the same verse produce two records; your code merges them predictably.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Search can't crash on user input.&lt;/strong&gt; Quotes, hyphens, emoji and other scripts are all tested against your FTS query builder.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Background audio is honest.&lt;/strong&gt; The &lt;code&gt;audio&lt;/code&gt; mode is used only while read-aloud is playing, and the session is deactivated when it stops.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The widget works with no app launch.&lt;/strong&gt; Install, add the widget, never open the app: it should still show a verse.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-6&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;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 &lt;code&gt;AVSpeechSynthesizer&lt;/code&gt; and WidgetKit and you get most of what people want from a daily reading app, without a single required network call.&lt;/p&gt;
&lt;p&gt;If you want to see where this ended up, try the app:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bible Study AI – Read Offline (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/bible-study-ai-read-offline/id1508795537&quot;&gt;https://apps.apple.com/us/app/bible-study-ai-read-offline/id1508795537&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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 &quot;bundled&quot; and &quot;user&quot; data.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/building-an-offline-first-bible-reader-on-ios/&quot;&gt;https://bestlatestapps.com/blog/building-an-offline-first-bible-reader-on-ios/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32388/offline-first-bible-reader-on-ios-bundled-sqlite-full-text-search-and-icloud-synced-notes</guid>
<pubDate>Sun, 25 Oct 2026 12:40:54 +0000</pubDate>
</item>
<item>
<title>Convert-As-You-Type Currency Conversion in Swift: Decimal Maths, Cached Rates and Formatting</title>
<link>https://coderlegion.com/32387/convert-as-you-type-currency-conversion-swift-decimal-maths-cached-rates-and-formatting</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/convert-as-you-type-currency-conversion-in-swift/&quot;&gt;https://bestlatestapps.com/blog/convert-as-you-type-currency-conversion-in-swift/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;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&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/convert-as-you-type-currency-conversion-in-swift/currency-converter-icon-512.jpg&quot; alt=&quot;Currency Converter : Fast app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;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 &quot;Convert&quot;. Nobody wants a spinner. And nobody wants to see &lt;code&gt;0.30000000000000004&lt;/code&gt; on the screen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Currency Converter : Fast&lt;/strong&gt; is one of my first apps, published in April 2020. Its listing describes the whole philosophy in a few lines: 150+ currencies, &quot;converts as you type (no need for a 'Convert' button!)&quot;, exchange rates &quot;updated automatically — you don't have to do anything!&quot;, and 3 decimal places &quot;for greater accuracy&quot;.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-money-is-not-a-double&quot;&gt;1. Money is not a Double&lt;/h3&gt;
&lt;p&gt;Binary floating point can't represent most decimal fractions exactly. &lt;code&gt;0.1 + 0.2&lt;/code&gt; isn't &lt;code&gt;0.3&lt;/code&gt;, and after a few multiplications by exchange rates, the error shows up in exactly the decimal places you're displaying.&lt;/p&gt;
&lt;p&gt;Foundation's &lt;code&gt;Decimal&lt;/code&gt; is a base-10 number type, so &lt;code&gt;0.1&lt;/code&gt; really is 0.1:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

let a: Double = 0.1 + 0.2
print(a)                                  // 0.30000000000000004

let b = Decimal(string: &quot;0.1&quot;)! + Decimal(string: &quot;0.2&quot;)!
print(b)                                  // 0.3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The rule I follow: &lt;strong&gt;amounts and rates are &lt;code&gt;Decimal&lt;/code&gt; from the moment they enter the app&lt;/strong&gt;, whether that's the user's typing or the JSON from the rates API, until the moment they're formatted for display. Two gotchas:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Decimal(0.1)&lt;/code&gt; converts &lt;em&gt;from a Double&lt;/em&gt;, so the binary error comes with it. Initialise from strings: &lt;code&gt;Decimal(string:locale:)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;JSONDecoder&lt;/code&gt; decodes &lt;code&gt;Decimal&lt;/code&gt; fields via &lt;code&gt;Double&lt;/code&gt; internally on some systems, which can reintroduce error in the last digits. If exactness matters, decode rates as &lt;code&gt;String&lt;/code&gt;s and convert them yourself, as below.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-one-base-every-cross-rate&quot;&gt;2. One base, every cross rate&lt;/h3&gt;
&lt;p&gt;You don't need a 150 × 150 table. Fetch every currency's rate against &lt;strong&gt;one base&lt;/strong&gt; (say USD), and derive any pair:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;amount_in_B = amount_in_A × (rate_B / rate_A)       where rate_X = units of X per 1 base
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

struct RateTable: Codable {
    let base: String                     // &quot;USD&quot;
    let rates: [String: Decimal]         // &quot;EUR&quot;: 0.92 means 1 USD = 0.92 EUR
    let fetchedAt: Date

    func rate(from: String, to: String) -&amp;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) -&amp;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 &quot;\(value)&quot; to limit binary error.
        let raw = try c.decode([String: String].self, forKey: .rates)
        rates = raw.compactMapValues { Decimal(string: $0, locale: Locale(identifier: &quot;en_US_POSIX&quot;)) }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;RateTable.rate(from:to:)&lt;/code&gt; is also how you show &quot;the current exchange rate of what you're converting&quot;, one of the listing's features: &lt;code&gt;1 EUR = 1.087 USD&lt;/code&gt;, computed from the same table.&lt;/p&gt;
&lt;p&gt;Note the &lt;code&gt;en_US_POSIX&lt;/code&gt; locale when parsing API data. API numbers always use &lt;code&gt;.&lt;/code&gt; as the decimal separator, whatever the user's device language is.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-convert-as-you-type&quot;&gt;3. Convert as you type&lt;/h3&gt;
&lt;p&gt;With &lt;code&gt;Decimal&lt;/code&gt; maths and a local rate table, conversion is instant and synchronous. There's no reason for a button. In SwiftUI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftUI

@MainActor
final class ConverterModel: ObservableObject {
    @Published var input = &quot;&quot;
    @Published var from = &quot;USD&quot;
    @Published var to = &quot;EUR&quot;
    @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(&quot;Amount&quot;, text: $model.input)
                    .keyboardType(.decimalPad)
                    .font(.largeTitle.monospacedDigit())
                CurrencyPicker(title: &quot;From&quot;, code: $model.from)
            }
            Section {
                Text(model.result.map { CurrencyText.format($0, code: model.to) } ?? &quot;—&quot;)
                    .font(.largeTitle.monospacedDigit())
                    .contentTransition(.numericText())          // iOS 17: digits roll smoothly
                    .animation(.snappy, value: model.result)
                CurrencyPicker(title: &quot;To&quot;, code: $model.to)
            }
            Button(&quot;Swap&quot;, systemImage: &quot;arrow.up.arrow.down&quot;, action: model.swap)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Because &lt;code&gt;result&lt;/code&gt; is a computed property, every keystroke recomputes it: there's no debounce and no task. &lt;code&gt;CurrencyPicker&lt;/code&gt; is a searchable list of codes (section 5).&lt;/p&gt;
&lt;p&gt;The decimal-pad keyboard shows the user's locale separator, and the &lt;code&gt;NumberFormatter&lt;/code&gt; uses &lt;code&gt;Locale.current&lt;/code&gt; by default, so &quot;12,50&quot; in Germany and &quot;12.50&quot; in the US both parse.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-rates-that-update-themselves-and-work-offline&quot;&gt;4. Rates that update themselves, and work offline&lt;/h3&gt;
&lt;p&gt;&quot;Exchange rates are updated automatically&quot; 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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

actor RateStore {
    static let shared = RateStore()
    private let fileURL: URL = FileManager.default
        .urls(for: .cachesDirectory, in: .userDomainMask)[0]
        .appendingPathComponent(&quot;rates.json&quot;)
    private let maxAge: TimeInterval = 60 * 60          // refresh if older than an hour

    /// Always returns something if we've ever succeeded, even offline.
    func cached() -&amp;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 -&amp;gt; RateTable? {
        if !force, let c = cached(), Date().timeIntervalSince(c.fetchedAt) &amp;lt; maxAge { return c }
        do {
            var request = URLRequest(url: URL(string: &quot;https://rates.example.com/latest?base=USD&quot;)!)
            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
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key behaviours:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Show the cache immediately&lt;/strong&gt;, then refresh. The UI never waits on the network.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On failure, keep the last good table.&lt;/strong&gt; A slightly old rate is far more useful than an error screen at a market stall.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Show the age.&lt;/strong&gt; &quot;Rates updated 3 hours ago&quot; in small text is honest, and it's the one piece of UI that explains a surprising number.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bundle a seed table&lt;/strong&gt; in the app, so a first launch in Airplane Mode still converts. Label it with its date.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For background refresh, SwiftUI's &lt;code&gt;backgroundTask(_:action:)&lt;/code&gt; scene modifier (iOS 16+) with &lt;code&gt;.appRefresh&lt;/code&gt; pairs with &lt;code&gt;BGAppRefreshTaskRequest&lt;/code&gt; (iOS 13+):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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(&quot;rates.refresh&quot;)) {
                _ = await RateStore.shared.refreshIfStale(force: true)
                scheduleRefresh()
            }
    }
}

func scheduleRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: &quot;rates.refresh&quot;)
    request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 60 * 60)
    try? BGTaskScheduler.shared.submit(request)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add &lt;code&gt;rates.refresh&lt;/code&gt; to &lt;code&gt;BGTaskSchedulerPermittedIdentifiers&lt;/code&gt; in Info.plist and enable Background Modes → Background fetch. Remember that &lt;code&gt;earliestBeginDate&lt;/code&gt; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-150-currencies-names-symbols-and-decimals&quot;&gt;5. 150+ currencies: names, symbols and decimals&lt;/h3&gt;
&lt;p&gt;Foundation knows a great deal about currencies, so you don't have to maintain that list by hand:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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) -&amp;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) -&amp;gt; String {
        value.formatted(.currency(code: code).precision(.fractionLength(3)))
    }
}

// CurrencyText.format(1234.5678, code: &quot;EUR&quot;) → &quot;€1,234.568&quot; in en_US, &quot;1.234,568 €&quot; in de_DE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;localizedString(forCurrencyCode:)&lt;/code&gt; gives &quot;Colombian Peso&quot; in English and &quot;Peso colombiano&quot; in Spanish for free. &lt;code&gt;Decimal.FormatStyle.Currency&lt;/code&gt; (iOS 15+) handles the symbol, its position, grouping and the decimal separator for the user's locale.&lt;/p&gt;
&lt;p&gt;About &lt;strong&gt;three decimal places&lt;/strong&gt;: 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 &lt;code&gt;.precision(.fractionLength(0...3))&lt;/code&gt; with rounding, or read the currency's default fraction digits from a &lt;code&gt;NumberFormatter&lt;/code&gt; set to &lt;code&gt;.currency&lt;/code&gt; style.&lt;/p&gt;
&lt;p&gt;Make the picker &lt;strong&gt;searchable by code and by name&lt;/strong&gt; (&lt;code&gt;.searchable(text:)&lt;/code&gt;), and put recently used currencies at the top. Most people convert between the same two or three currencies over and over.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-real-world-example-currency-converter-fast&quot;&gt;6. Real-world example: Currency Converter : Fast&lt;/h3&gt;
&lt;p&gt;This is the approach I'd use, and recommend, for &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/currency-converter-fast/id1509291290&quot;&gt;Currency Converter : Fast&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/convert-as-you-type-currency-conversion-in-swift/currency-converter-screenshot-1.jpg&quot; alt=&quot;Currency Converter App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Finance&lt;/strong&gt; and &lt;strong&gt;Productivity&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;April 2020&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;7 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;4.7 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 17.6 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing's features map to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Over 150 currencies supported&quot; / &quot;from the US Dollar to the Colombian Peso&quot; &lt;/td&gt;&lt;td&gt;One-base rate table plus Foundation's currency names (sections 2, 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Converts as you type (no need for a 'Convert' button!)&quot; &lt;/td&gt;&lt;td&gt;Synchronous &lt;code&gt;Decimal&lt;/code&gt; conversion as a computed property (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Exchange rates are updated automatically – you don't have to do anything!&quot; &lt;/td&gt;&lt;td&gt;Cache-first store, foreground refresh, background app refresh (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Uses 3 decimal places for greater accuracy!&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;Decimal&lt;/code&gt; maths and &lt;code&gt;.precision(.fractionLength(3))&lt;/code&gt; (sections 1, 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Find the current exchange rate of what you're converting&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;RateTable.rate(from:to:)&lt;/code&gt; (section 2)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/convert-as-you-type-currency-conversion-in-swift/currency-converter-screenshot-2.jpg&quot; alt=&quot;Currency Converter App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;Because the app targets iOS 17.6 and later, everything above, including &lt;code&gt;contentTransition(.numericText())&lt;/code&gt;, the two-parameter &lt;code&gt;onChange&lt;/code&gt; and the SwiftUI background task, is available without availability checks.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-before-you-ship-a-currency-converter-a-checklist&quot;&gt;7. Before you ship a currency converter: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Decimal&lt;/code&gt; end to end.&lt;/strong&gt; No &lt;code&gt;Double&lt;/code&gt; between parsing and formatting, and no &lt;code&gt;Decimal(someDouble)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;API numbers are parsed with &lt;code&gt;en_US_POSIX&lt;/code&gt;&lt;/strong&gt;, and user input with the user's locale.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross rates come from one base.&lt;/strong&gt; Test that A → B → A returns the original amount, within rounding.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cache is shown first.&lt;/strong&gt; Launch in Airplane Mode: you see the last rates and their age, not an error.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A seed table ships in the bundle&lt;/strong&gt; for first-launch offline use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Background refresh is configured&lt;/strong&gt; (permitted identifiers, background mode), and foreground refresh does the real work.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Formatting is tested in several locales&lt;/strong&gt;: en_US, de_DE, fr_FR, ja_JP and ar, at least.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero-decimal and three-decimal currencies look right&lt;/strong&gt;, according to your product decision.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rates are labelled honestly.&lt;/strong&gt; These are indicative mid-market-style rates, not what a bank or card will charge.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-7&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;The &quot;fast&quot; 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 &lt;code&gt;Decimal&lt;/code&gt;, 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.&lt;/p&gt;
&lt;p&gt;Try it, or explore my other apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Currency Converter : Fast (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/currency-converter-fast/id1509291290&quot;&gt;https://apps.apple.com/us/app/currency-converter-fast/id1509291290&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How do you handle money in Swift: &lt;code&gt;Decimal&lt;/code&gt;, integer minor units, or a library? I'd love to hear the trade-offs you've found in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/convert-as-you-type-currency-conversion-in-swift/&quot;&gt;https://bestlatestapps.com/blog/convert-as-you-type-currency-conversion-in-swift/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32387/convert-as-you-type-currency-conversion-swift-decimal-maths-cached-rates-and-formatting</guid>
<pubDate>Sun, 25 Oct 2026 12:40:42 +0000</pubDate>
</item>
<item>
<title>Drag-to-Connect and Game Center Leaderboards in a SwiftUI Puzzle Game, Updated for iOS 26</title>
<link>https://coderlegion.com/32386/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game-updated-for-ios-26</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game/&quot;&gt;https://bestlatestapps.com/blog/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;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&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game/speck-match-icon-512.jpg&quot; alt=&quot;Speck Match app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Speck Match&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;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 &lt;strong&gt;drag-to-connect mechanic built entirely in SwiftUI&lt;/strong&gt;, with &lt;code&gt;Canvas&lt;/code&gt; and &lt;code&gt;TimelineView&lt;/code&gt; instead of a game engine. The second is &lt;strong&gt;Game Center leaderboards in depth&lt;/strong&gt;, which have changed more in the last few iOS releases than most developers realise. The latest change: Apple's documentation now marks &lt;code&gt;GKGameCenterViewController&lt;/code&gt; as &lt;strong&gt;deprecated in iOS 26&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-board-as-plain-data&quot;&gt;1. The board as plain data&lt;/h3&gt;
&lt;p&gt;Keep the game state free of SwiftUI, so you can unit-test it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 &amp;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)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&quot;The game gets faster and more challenging as you progress&quot; becomes one computed property, &lt;code&gt;spawnInterval&lt;/code&gt;, and &quot;Multiple difficulty levels&quot; becomes three rows of numbers. The values are illustrative; tune them by playing.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-drawing-and-ticking-with-canvas-and-timelineview&quot;&gt;2. Drawing and ticking with Canvas and TimelineView&lt;/h3&gt;
&lt;p&gt;For a few dozen circles, SwiftUI's &lt;code&gt;Canvas&lt;/code&gt; (iOS 15+) is fast and simple. It's immediate-mode drawing, so you're not creating one view per dot. &lt;code&gt;TimelineView(.animation(minimumInterval:paused:))&lt;/code&gt; (iOS 15+) gives you a frame clock that &lt;strong&gt;can be paused&lt;/strong&gt;, which matters later:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 &amp;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() } }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This code uses iOS 17 conveniences (&lt;code&gt;@Observable&lt;/code&gt; and the two-parameter &lt;code&gt;onChange&lt;/code&gt;). For the app's iOS 15.6 minimum, the same model works as an &lt;code&gt;ObservableObject&lt;/code&gt; with the single-parameter &lt;code&gt;onChange(of:perform:)&lt;/code&gt;. Two more notes. Keep &lt;code&gt;tick&lt;/code&gt; cheap: spawn when due, check the overfull condition, and nothing else. And auto-pause when the scene leaves &lt;code&gt;.active&lt;/code&gt;. A phone call shouldn't cost a player their best run, and the listing promises &quot;Pause and resume anytime&quot;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-drag-to-connect&quot;&gt;3. Drag to connect&lt;/h3&gt;
&lt;p&gt;The listing's core loop is &quot;Tap and drag dots to match them by color. Connect matching colored dots to clear them from the board.&quot; 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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;@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) -&amp;gt; Speck? {
        board.specks.first { hypot($0.position.x - point.x, $0.position.y - point.y) &amp;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 &amp;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 &amp;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 &amp;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() }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;DragGesture(minimumDistance: 0)&lt;/code&gt; 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 &quot;Smooth, responsive controls&quot; means in practice.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-scoring-speed&quot;&gt;4. Scoring speed&lt;/h3&gt;
&lt;p&gt;&quot;Speed matters – faster matches = higher scores.&quot; There are many ways to express that. The approach I'd use rewards &lt;strong&gt;how fresh the dots were&lt;/strong&gt; when you cleared them, plus a bonus for longer chains:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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) -&amp;gt; Int {
        let perDot = specks.map { speck -&amp;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())
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;bornAt&lt;/code&gt; timestamp uses &lt;strong&gt;game time&lt;/strong&gt; (&lt;code&gt;board.elapsed&lt;/code&gt;), not wall-clock time. Otherwise pausing would make every dot on screen &quot;old&quot;, and the player would be punished for taking a break.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-signing-in-to-game-center&quot;&gt;5. Signing in to Game Center&lt;/h3&gt;
&lt;p&gt;Leaderboards start with authentication. On iOS, you set &lt;code&gt;GKLocalPlayer.local.authenticateHandler&lt;/code&gt; once, early. GameKit calls it, possibly more than once, with either a view controller to present or the result:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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(&quot;Game Center unavailable: \(error.localizedDescription)&quot;) }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Rules I follow:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Never block the game on Game Center.&lt;/strong&gt; If the player declines or is offline, the game plays normally and leaderboards just aren't offered.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Present the sign-in view controller from a real, visible controller.&lt;/strong&gt; Presenting from a controller that isn't in the window is a classic &quot;Game Center never shows&quot; bug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Show the access point only on menus.&lt;/strong&gt; &lt;code&gt;GKAccessPoint&lt;/code&gt; (iOS 14+) is a small Game Center badge that the system draws. Set &lt;code&gt;isActive = false&lt;/code&gt; during play so it doesn't overlap the board.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-submitting-scores-the-modern-way&quot;&gt;6. Submitting scores the modern way&lt;/h3&gt;
&lt;p&gt;The old &lt;code&gt;GKScore&lt;/code&gt; class is &lt;strong&gt;deprecated since iOS 14&lt;/strong&gt;. The current API is a single class method on &lt;code&gt;GKLeaderboard&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension GameCenter {
    static let leaderboardID = &quot;speck.highscores.normal&quot;   // 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: [&quot;speck.highscores.\(difficulty)&quot;]) { error in
            if let error { print(&quot;Score submit failed: \(error.localizedDescription)&quot;) }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Design choices:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One leaderboard per difficulty.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Submit at game over, not per match.&lt;/strong&gt; One call per run is plenty. Game Center keeps each player's best score according to the leaderboard's settings.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Configure the sort order and format in App Store Connect&lt;/strong&gt; (high to low, integer). The client just sends numbers.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-showing-leaderboards-in-ios-18-and-26&quot;&gt;7. Showing leaderboards in iOS 18 and 26&lt;/h3&gt;
&lt;p&gt;This is where things have moved. For years the answer was &quot;present a &lt;code&gt;GKGameCenterViewController&lt;/code&gt;&quot;. Apple's documentation now marks &lt;strong&gt;&lt;code&gt;GKGameCenterViewController&lt;/code&gt; deprecated in iOS 26&lt;/strong&gt;. Meanwhile, &lt;code&gt;GKAccessPoint&lt;/code&gt; gained &lt;code&gt;trigger(leaderboardID:playerScope:timeScope:handler:)&lt;/code&gt; in &lt;strong&gt;iOS 18&lt;/strong&gt;, which opens the system Game Center UI straight to one leaderboard.&lt;/p&gt;
&lt;p&gt;Since Speck Match supports iOS 15.6 and later, the approach I'd use branches:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 &quot;Top 10&quot; without leaving the app (iOS 14+).
    func topTen(id: String) async throws -&amp;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) }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;loadEntries(for:timeScope:range:)&lt;/code&gt; returns the local player's entry, the requested range and the total count. Use &lt;code&gt;formattedScore&lt;/code&gt; rather than formatting the raw value yourself, so the display matches what Game Center shows everywhere else.&lt;/p&gt;
&lt;p&gt;The app's most recent release notes include &quot;Fixed Game Center leaderboard display issue&quot;. That's a reminder that the leaderboard &lt;em&gt;display&lt;/em&gt; path is the fragile part. Test it on every iOS version you support, and especially on both sides of the iOS 18 branch.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-speck-match&quot;&gt;8. Real-world example: Speck Match&lt;/h3&gt;
&lt;p&gt;This walkthrough is grounded in building &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/speck-match/id1600688336&quot;&gt;Speck Match&lt;/a&gt;&lt;/strong&gt;. The code is the approach I'd use, written for this article, not the shipped source.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game/speck-match-screenshot-1.jpg&quot; alt=&quot;Speck Match App Store screenshot&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free to play&lt;/strong&gt; (&quot;with optional ads that can be removed&quot;), in &lt;strong&gt;Games&lt;/strong&gt; (Board, Strategy)&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;December 2021&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1 rating&lt;/strong&gt; in the US store, &lt;strong&gt;5 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 15.6 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Tap and drag to connect matching dots&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;DragGesture&lt;/code&gt; chains (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Game gets faster and more challenging as you progress&quot; / &quot;Multiple difficulty levels&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;spawnInterval&lt;/code&gt; and difficulty presets (section 1)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Speed matters - faster matches = higher scores&quot; &lt;/td&gt;&lt;td&gt;Freshness-based scoring (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Pause and resume anytime&quot; &lt;/td&gt;&lt;td&gt;A pausable &lt;code&gt;TimelineView&lt;/code&gt; and game time (sections 2 and 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Game Center integration&quot; / &quot;Global leaderboards&quot; &lt;/td&gt;&lt;td&gt;Authentication, submitting and display (sections 5–7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Release notes: &quot;Fixed Game Center leaderboard display issue&quot; &lt;/td&gt;&lt;td&gt;Version-branching the display path (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-a-game-center-game-a-checklist&quot;&gt;9. Before you ship a Game Center game: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The game plays fully without Game Center.&lt;/strong&gt; Declined sign-in and offline are both tested.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;authenticateHandler&lt;/code&gt; is set once, early&lt;/strong&gt;, and presents from a visible view controller.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There's no &lt;code&gt;GKScore&lt;/code&gt;.&lt;/strong&gt; Use &lt;code&gt;GKLeaderboard.submitScore&lt;/code&gt;, once per run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There's one leaderboard per difficulty&lt;/strong&gt;, with IDs matching App Store Connect exactly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The display path is branched.&lt;/strong&gt; &lt;code&gt;GKAccessPoint.trigger(leaderboardID:…)&lt;/code&gt; on iOS 18+, the view controller on older systems, and both tested.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The access point is hidden during play.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scores use game time.&lt;/strong&gt; Pausing never changes the outcome.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hit areas are generous&lt;/strong&gt;, and &lt;code&gt;minimumDistance: 0&lt;/code&gt; is set for instant drags.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Colour isn't the only cue.&lt;/strong&gt; Consider shapes for colour-blind players.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-8&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;A drag-to-connect game shows how far plain SwiftUI goes. &lt;code&gt;Canvas&lt;/code&gt; draws the board, &lt;code&gt;TimelineView&lt;/code&gt; ticks it, &lt;code&gt;DragGesture&lt;/code&gt; drives it, and nothing needs a game engine. Game Center is the part that keeps evolving. Use the iOS 14 &lt;code&gt;GKLeaderboard&lt;/code&gt; APIs, adopt the iOS 18 access-point trigger, and plan for the iOS 26 deprecation of the old dashboard controller.&lt;/p&gt;
&lt;p&gt;Play the game, or explore my other work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Speck Match (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/speck-match/id1600688336&quot;&gt;https://apps.apple.com/us/app/speck-match/id1600688336&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Think you can top the leaderboard? Let me know your score in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game/&quot;&gt;https://bestlatestapps.com/blog/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32386/drag-to-connect-and-game-center-leaderboards-in-a-swiftui-puzzle-game-updated-for-ios-26</guid>
<pubDate>Sun, 25 Oct 2026 12:40:22 +0000</pubDate>
</item>
<item>
<title>From Barcode to PDF: A Private, Offline Medication Tracker with VisionKit, WidgetKit and PDFKit</title>
<link>https://coderlegion.com/32273/from-barcode-pdf-private-offline-medication-tracker-with-visionkit-widgetkit-and-pdfkit</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/from-barcode-to-pdf/&quot;&gt;https://bestlatestapps.com/blog/from-barcode-to-pdf/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;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&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/from-barcode-to-pdf/track-your-pills-icon-512.jpg&quot; alt=&quot;Track Your Pills app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;I have two medication apps on the App Store. The first, &lt;strong&gt;Daily Pills | Reminder&lt;/strong&gt;, does one thing: remind you on time and count your supply. I've written about its notification engineering separately. The second, &lt;strong&gt;Track Your Pills&lt;/strong&gt;, came out of what people asked for next: &quot;Can I just scan the box?&quot; &quot;Can I show my doctor what I've actually taken?&quot; &quot;Can I see today's doses without opening the app?&quot;&lt;/p&gt;
&lt;p&gt;Track Your Pills launched in February 2023. Its listing covers medications added &quot;with photos, barcodes, or manual entry&quot;, tablets, capsules, syrups and injections, adherence calendars and statistics, PDF reports to &quot;share with healthcare providers&quot;, home-screen widgets, CSV and JSON export with backup and restore, and a firm privacy line: &quot;All data stored locally on your device… No account creation needed.&quot;&lt;/p&gt;
&lt;p&gt;This article covers the parts that are &lt;em&gt;different&lt;/em&gt; 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 &lt;code&gt;UIGraphicsPDFRenderer&lt;/code&gt;, 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-model-shared-by-the-app-and-its-widgets&quot;&gt;1. The model, shared by the app and its widgets&lt;/h3&gt;
&lt;p&gt;The app targets iOS 17.6 and later, so SwiftData is available. The model separates &lt;em&gt;what&lt;/em&gt; the medication is from &lt;em&gt;what happened&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftData
import Foundation

@Model
final class Medication {
    var name: String
    var form: String                 // &quot;tablet&quot;, &quot;capsule&quot;, &quot;syrup&quot;, &quot;injection&quot;
    var strength: String?            // &quot;500 mg&quot;, &quot;5 ml&quot;
    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
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;@Attribute(.externalStorage)&lt;/code&gt; keeps photos out of the main database file. Each intake records both the &lt;em&gt;scheduled&lt;/em&gt; time and the &lt;em&gt;logged&lt;/em&gt; time, which lets the report distinguish &quot;taken on time&quot; from &quot;taken two hours late&quot;.&lt;/p&gt;
&lt;p&gt;Widgets run in a separate process, so the store has to live somewhere both processes can reach. That's an &lt;strong&gt;App Group&lt;/strong&gt; container:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum SharedStore {
    static let appGroup = &quot;group.com.example.trackyourpills&quot;

    static func makeContainer() throws -&amp;gt; ModelContainer {
        let config = ModelConfiguration(
            &quot;Medications&quot;,
            groupContainer: .identifier(appGroup),   // same file for app and widget extension
            cloudKitDatabase: .none                  // &quot;No cloud sync required&quot;
        )
        return try ModelContainer(for: Medication.self, Intake.self, configurations: config)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the App Groups capability to both the app and widget targets with the same identifier. &lt;code&gt;cloudKitDatabase: .none&lt;/code&gt; is the explicit, privacy-preserving choice. SwiftData otherwise looks for a CloudKit container if the iCloud capability is present.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-adding-a-medication-by-scanning-its-barcode&quot;&gt;2. Adding a medication by scanning its barcode&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;DataScannerViewController&lt;/code&gt; (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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftUI
import VisionKit

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

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

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

    final class Coordinator: NSObject, DataScannerViewControllerDelegate {
        let onCode: (String) -&amp;gt; Void
        private var delivered = false
        init(onCode: @escaping (String) -&amp;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
                }
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Practical notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Check both flags.&lt;/strong&gt; Apple's docs say &lt;code&gt;isSupported&lt;/code&gt; requires a device with the &lt;strong&gt;A12 Bionic chip or later&lt;/strong&gt;. &lt;code&gt;isAvailable&lt;/code&gt; additionally reflects camera permission and restrictions. When either is false, fall back to manual entry; never show a dead button.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add &lt;code&gt;NSCameraUsageDescription&lt;/code&gt;&lt;/strong&gt;, for example: &quot;Scan the barcode on your medication box to fill in its details.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pick the symbologies you need.&lt;/strong&gt; Retail packaging is mostly EAN/UPC, and many pharmaceutical packs carry a GS1 DataMatrix. A narrower list means faster, more reliable recognition.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;from-code-to-name-an-on-device-lookup&quot;&gt;From code to name: an on-device lookup&lt;/h4&gt;
&lt;p&gt;The listing mentions &quot;Auto-fill from extensive medication name database&quot; and &quot;Barcode scanning for instant medication lookup&quot;, and also says &quot;No internet connection required&quot;. The approach that satisfies both is a &lt;strong&gt;bundled, read-only lookup table&lt;/strong&gt;: 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.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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: &quot;catalog&quot;, withExtension: &quot;json&quot;)!
        byBarcode = (try? JSONDecoder().decode([String: Entry].self, from: Data(contentsOf: url))) ?? [:]
    }

    func lookup(_ code: String) -&amp;gt; Entry? { byBarcode[code] }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Always show the result as an editable form, never a silent auto-save. A wrong strength is a dangerous error.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-adherence-the-numbers-behind-the-calendar&quot;&gt;3. Adherence: the numbers behind the calendar&lt;/h3&gt;
&lt;p&gt;&quot;Visual adherence calendar&quot;, &quot;weekly and overall adherence statistics&quot; and &quot;per-medication adherence metrics&quot; all come from one calculation: of the doses that were &lt;strong&gt;due&lt;/strong&gt;, how many were &lt;strong&gt;taken&lt;/strong&gt;?&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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) -&amp;gt; AdherenceStats {
        let due = intakes.filter { interval.contains($0.scheduledAt) &amp;amp;&amp;amp; $0.status != .skipped }
        let taken = due.filter { $0.status == .taken }
        let late = taken.filter { ($0.loggedAt ?? $0.scheduledAt)
            .timeIntervalSince($0.scheduledAt) &amp;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)
        -&amp;gt; [(day: Date, rate: Double?)] {
        let today = calendar.startOfDay(for: .now)
        return (0..&amp;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
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two definitions you have to choose deliberately, and should explain on screen:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Skipped is not missed.&lt;/strong&gt; If a doctor said &quot;skip today&quot;, that dose shouldn't hurt the adherence score. Exclude skipped doses from &lt;em&gt;due&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No dose scheduled ≠ 100%.&lt;/strong&gt; A day with nothing due should render as empty, not as a perfect green square.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Swift Charts' &lt;code&gt;RectangleMark&lt;/code&gt; makes the calendar heat map straightforward, with &lt;code&gt;foregroundStyle(by:)&lt;/code&gt; binned into three or four colour bands.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-a-doctor-ready-pdf-with-uigraphicspdfrenderer&quot;&gt;4. A doctor-ready PDF with UIGraphicsPDFRenderer&lt;/h3&gt;
&lt;p&gt;&quot;Generate detailed medication reports… Share reports with healthcare providers… Printable format for medical records.&quot; &lt;code&gt;UIGraphicsPDFRenderer&lt;/code&gt; (iOS 10+) turns drawing code into PDF data. A report is just text and table rows drawn page by page:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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]) -&amp;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: &quot;Track Your Pills&quot;]
        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 [&quot;Medication&quot;, &quot;Schedule&quot;, &quot;Adherence&quot;, &quot;Remaining&quot;].enumerated() {
                    (h as NSString).draw(at: CGPoint(x: columns[i], y: y), withAttributes: headAttrs)
                }
                y += 20
            }

            newPage()
            for row in rows {
                if y &amp;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 &amp;lt; 3 ? columns[i + 1] : page.width - margin) - columns[i] - 8,
                                                       height: 32), withAttributes: bodyAttrs)
                }
                y += 34
            }

            let footer = &quot;Generated on this device. Not medical advice; review with your clinician.&quot;
            (footer as NSString).draw(at: CGPoint(x: margin, y: page.height - margin),
                                      withAttributes: [.font: UIFont.italicSystemFont(ofSize: 9)])
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To preview it, wrap &lt;code&gt;PDFView&lt;/code&gt; (PDFKit, iOS 11+) in a &lt;code&gt;UIViewRepresentable&lt;/code&gt;, set &lt;code&gt;document = PDFDocument(data: data)&lt;/code&gt; and &lt;code&gt;autoScales = true&lt;/code&gt;. To share or print, write the data to a temporary file named something human, like &quot;Medication report – Oct 2026.pdf&quot;, and hand the URL to &lt;code&gt;ShareLink&lt;/code&gt;. The system share sheet includes Print, Mail and Files, so you don't need any extra UI for &quot;printable&quot;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-widgets-that-read-the-shared-store&quot;&gt;5. Widgets that read the shared store&lt;/h3&gt;
&lt;p&gt;&quot;Home screen widgets for quick medication view… Upcoming medication reminders… Adherence at a glance.&quot; The widget's timeline provider opens the same App Group container and computes the next doses:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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) -&amp;gt; NextDoseEntry {
        NextDoseEntry(date: .now, upcoming: [(&quot;Vitamin D&quot;, .now)], todayRate: 1)
    }

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

    func getTimeline(in context: Context, completion: @escaping (Timeline&amp;lt;NextDoseEntry&amp;gt;) -&amp;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) -&amp;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&amp;lt;Medication&amp;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 &amp;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)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the app logs a dose, call &lt;code&gt;WidgetCenter.shared.reloadTimelines(ofKind:)&lt;/code&gt;, so the widget updates now rather than at its next scheduled refresh.&lt;/p&gt;
&lt;p&gt;Keep widget work small. Fetch, compute three items and return. Widgets have tight time and memory budgets.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-export-backup-and-restore-without-a-server&quot;&gt;6. Export, backup and restore without a server&lt;/h3&gt;
&lt;p&gt;&quot;Export data in CSV or JSON formats… Complete backup and restore… Privacy-focused: all data stored locally.&quot; With no account and no cloud, export &lt;em&gt;is&lt;/em&gt; the backup strategy, so treat it as a first-class feature.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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(&quot;TrackYourPills-backup.json&quot;)
    }
}

enum CSVExport {
    static func intakes(_ meds: [Medication]) -&amp;gt; String {
        func esc(_ s: String) -&amp;gt; String { &quot;\&quot;\(s.replacingOccurrences(of: &quot;\&quot;&quot;, with: &quot;\&quot;\&quot;&quot;))\&quot;&quot; }
        let iso = ISO8601DateFormatter()
        var lines = [&quot;medication,scheduled_at,logged_at,status&quot;]
        for med in meds {
            for i in med.intakes.sorted(by: { $0.scheduledAt &amp;lt; $1.scheduledAt }) {
                lines.append([esc(med.name), iso.string(from: i.scheduledAt),
                              i.loggedAt.map(iso.string(from:)) ?? &quot;&quot;, i.statusRaw].joined(separator: &quot;,&quot;))
            }
        }
        return lines.joined(separator: &quot;\n&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ShareLink(item: backup, preview: SharePreview(&quot;Backup&quot;))&lt;/code&gt; (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, &lt;code&gt;.fileImporter(isPresented:allowedContentTypes:onCompletion:)&lt;/code&gt; with &lt;code&gt;[.json]&lt;/code&gt; picks the file. Decode it, check &lt;code&gt;version&lt;/code&gt;, and import inside a single &lt;code&gt;ModelContext&lt;/code&gt; transaction, so a bad file can't leave half a database.&lt;/p&gt;
&lt;p&gt;A design rule I'd hold to: &lt;strong&gt;JSON is the backup, CSV is the report.&lt;/strong&gt; CSV is for spreadsheets and doctors and loses structure; only JSON round-trips. Label them that way in the UI.&lt;/p&gt;
&lt;p&gt;If you use &lt;code&gt;fileExporter&lt;/code&gt; instead of &lt;code&gt;ShareLink&lt;/code&gt;, note that Apple's current docs mark the original &lt;code&gt;fileExporter(isPresented:document:contentType:defaultFilename:onCompletion:)&lt;/code&gt; as deprecated in iOS 27.2, in favour of the overload that adds &lt;code&gt;onCancellation:&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-interaction-warnings-handle-with-great-care&quot;&gt;7. Interaction warnings: handle with great care&lt;/h3&gt;
&lt;p&gt;The listing includes a &quot;Drug Interaction Checker&quot; with &quot;Safety alerts before adding new medications&quot; and &quot;Medical disclaimers and recommendations&quot;. The technical side is simple: a bundled table of known interacting pairs, checked when a medication is added. The responsibility side is not:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Offline data ages.&lt;/strong&gt; Show the data's version date in the warning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Absence of a warning isn't safety.&lt;/strong&gt; Word it as &quot;No interactions found &lt;em&gt;in this app's list&lt;/em&gt;&quot;, never &quot;safe to combine&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never block, always refer.&lt;/strong&gt; Warn, show the source and recommend checking with a pharmacist or doctor.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I won't publish a table here; the content is the hard part, and it must come from a reliable, licensed source.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-track-your-pills&quot;&gt;8. Real-world example: Track Your Pills&lt;/h3&gt;
&lt;p&gt;All of this describes the approach I'd use, and recommend, for &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/track-your-pills/id1660782134&quot;&gt;Track Your Pills&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/from-barcode-to-pdf/track-your-pills-screenshot-1.jpg&quot; alt=&quot;Track Your Pills App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Medical&lt;/strong&gt; and &lt;strong&gt;Health &amp;amp; Fitness&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;February 2023&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;7 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;4.7 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 17.6 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Add medications with photos, barcodes, or manual entry&quot; / &quot;Barcode scanning for instant medication lookup&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;DataScannerViewController&lt;/code&gt; plus a bundled lookup, with manual fallback (section 2)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Support for tablets, capsules, syrups, and injections&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;form&lt;/code&gt; and a &lt;code&gt;Double&lt;/code&gt; quantity in the model (section 1)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Visual adherence calendar… Weekly and overall adherence statistics&quot; &lt;/td&gt;&lt;td&gt;Due vs taken, skipped excluded (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Generate detailed medication reports… Share reports with healthcare providers&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;UIGraphicsPDFRenderer&lt;/code&gt;, &lt;code&gt;PDFView&lt;/code&gt; preview, &lt;code&gt;ShareLink&lt;/code&gt; (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Home screen widgets… Upcoming medication reminders… Adherence at a glance&quot; &lt;/td&gt;&lt;td&gt;App Group SwiftData store read by a &lt;code&gt;TimelineProvider&lt;/code&gt; (sections 1, 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Export data in CSV or JSON formats… Complete backup and restore&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;Transferable&lt;/code&gt; JSON backup, CSV report, versioned import (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;All data stored locally… No cloud sync required… No account creation needed&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;cloudKitDatabase: .none&lt;/code&gt;, no server, user-controlled export (sections 1, 6)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/from-barcode-to-pdf/track-your-pills-screenshot-2.jpg&quot; alt=&quot;Track Your Pills App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-a-medication-tracker-a-checklist&quot;&gt;9. Before you ship a medication tracker: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Scanning degrades gracefully.&lt;/strong&gt; Unsupported devices and denied camera access both lead to manual entry.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scanned data is always reviewed by the user&lt;/strong&gt; before it's saved.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The store lives in an App Group&lt;/strong&gt;, and widgets read it without crashing on first launch, before any data exists.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adherence definitions are explicit&lt;/strong&gt;: skipped vs missed, late threshold, and days with nothing due.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The PDF paginates&lt;/strong&gt;, has a title, a date range and a disclaimer, and opens cleanly in Files and Mail.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The JSON backup round-trips.&lt;/strong&gt; Export, delete the app, reinstall, import: every record comes back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backups are versioned&lt;/strong&gt;, and import is all-or-nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Privacy claims are literally true&lt;/strong&gt;: no CloudKit, no analytics surprises, and accurate App Privacy details.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Interaction warnings are worded carefully&lt;/strong&gt; and dated, and always refer the user to a professional.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-9&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Try it, or explore my other apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Track Your Pills (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/track-your-pills/id1660782134&quot;&gt;https://apps.apple.com/us/app/track-your-pills/id1660782134&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Daily Pills | Reminder (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/daily-pills-reminder/id1510203960&quot;&gt;https://apps.apple.com/us/app/daily-pills-reminder/id1510203960&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you've built health features with no backend, how did you handle backups? Tell me in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/from-barcode-to-pdf/&quot;&gt;https://bestlatestapps.com/blog/from-barcode-to-pdf/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32273/from-barcode-pdf-private-offline-medication-tracker-with-visionkit-widgetkit-and-pdfkit</guid>
<pubDate>Sat, 24 Oct 2026 12:40:29 +0000</pubDate>
</item>
<item>
<title>Medication Reminders That Actually Fire: Local Notifications and the 64-Notification Limit</title>
<link>https://coderlegion.com/32272/medication-reminders-that-actually-fire-local-notifications-and-the-notification-limit</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/medication-reminders-that-actually-fire/&quot;&gt;https://bestlatestapps.com/blog/medication-reminders-that-actually-fire/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Repeating calendar triggers, a notification budget, &quot;Taken&quot; and &quot;Snooze&quot; actions, run-out dates and on-device privacy, from building a pill reminder app&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/medication-reminders-that-actually-fire/daily-pills-icon-512.jpg&quot; alt=&quot;Daily Pills | Reminder app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Daily Pills | Reminder&lt;/strong&gt; 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 &quot;Never miss your medication again!&quot; and promises that &quot;your medication data stays private on your device.&quot;&lt;/p&gt;
&lt;p&gt;That combination, reliable reminders with no server, rests entirely on &lt;strong&gt;local notifications&lt;/strong&gt;, 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-the-model-keep-it-boring&quot;&gt;1. The model: keep it boring&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

struct ReminderTime: Codable, Hashable, Identifiable {
    var hour: Int          // 0...23, local wall-clock time
    var minute: Int
    var id: String { String(format: &quot;%02d%02d&quot;, 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
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two choices to notice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reminder times are an hour and a minute, not a &lt;code&gt;Date&lt;/code&gt;.&lt;/strong&gt; &quot;8:00 every morning&quot; is a wall-clock intention. Storing it as a &lt;code&gt;Date&lt;/code&gt; bakes in a time zone, and the reminder drifts when the user travels or when daylight saving changes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The log is append-only.&lt;/strong&gt; Missed and taken doses are facts. Supply and adherence are computed from them, never stored separately where they could disagree.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-repeating-reminders-with-uncalendarnotificationtrigger&quot;&gt;2. Repeating reminders with UNCalendarNotificationTrigger&lt;/h3&gt;
&lt;p&gt;For &quot;every day at 08:00&quot;, a single repeating calendar trigger is exactly right:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import UserNotifications

enum ReminderScheduler {
    static let doseCategory = &quot;DOSE_REMINDER&quot;

    static func identifier(for med: Medication, time: ReminderTime) -&amp;gt; String {
        &quot;dose.\(med.id.uuidString).\(time.id)&quot;
    }

    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 = &quot;Time for \(med.name)&quot;
            content.body = med.pillsPerDose == 1 ? &quot;Take 1 pill.&quot; : &quot;Take \(med.pillsPerDose) pills.&quot;
            content.sound = .default
            content.categoryIdentifier = doseCategory
            content.userInfo = [&quot;medID&quot;: med.id.uuidString, &quot;time&quot;: 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(&quot;dose.\(med.id.uuidString).&quot;) }
        center.removePendingNotificationRequests(withIdentifiers: ids)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What makes this reliable:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Deterministic identifiers.&lt;/strong&gt; &lt;code&gt;dose.&amp;lt;medID&amp;gt;.&amp;lt;HHmm&amp;gt;&lt;/code&gt; means rescheduling replaces rather than duplicates, and cancelling one medication can't touch another's reminders.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Date components without a time zone&lt;/strong&gt; fire at local wall-clock time. If the user flies from Delhi to London, &quot;08:00&quot; stays 08:00 local, which is usually what a daily pill means. (For medications that must be taken at strict &lt;em&gt;intervals&lt;/em&gt; regardless of time zone, that's a different design: talk to the user, and consider fixed-interval triggers.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.timeSensitive&lt;/code&gt; (iOS 15+)&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And of course, ask permission first, at the moment the user adds their first reminder, with &lt;code&gt;requestAuthorization(options: [.alert, .sound, .badge])&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-the-64-notification-limit-and-a-budget&quot;&gt;3. The 64-notification limit, and a budget&lt;/h3&gt;
&lt;p&gt;Here's the trap. iOS keeps only a limited number of pending local notifications per app. Apple's documentation for the older &lt;code&gt;UILocalNotification&lt;/code&gt; API states it plainly: the system keeps the soonest-firing &lt;strong&gt;64&lt;/strong&gt; notifications and &lt;strong&gt;discards the rest&lt;/strong&gt;. Automatically repeating notifications count as a single notification. The same cap is widely observed with &lt;code&gt;UserNotifications&lt;/code&gt;, and it fails &lt;em&gt;silently&lt;/em&gt;: &lt;code&gt;add(_:)&lt;/code&gt; succeeds and the 65th reminder just never fires.&lt;/p&gt;
&lt;p&gt;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 &lt;strong&gt;one-off&lt;/strong&gt; notifications: a different message per day (&quot;Day 12 of 30&quot;), weekly schedules built from individual dates, snoozes or low-supply warnings. Each of those is its own slot.&lt;/p&gt;
&lt;p&gt;So treat the pending queue as a budget, and check it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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) -&amp;gt; Int {
        meds.reduce(0) { $0 + $1.times.count } + extraOneOffs
    }

    static func canSchedule(_ meds: [Medication], extraOneOffs: Int = 0) -&amp;gt; Bool {
        projected(for: meds, extraOneOffs: extraOneOffs) &amp;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(&quot;Pending: \(pending.count) / \(systemLimit)&quot;)
        pending.forEach { print(&quot; •&quot;, $0.identifier, $0.trigger.map(String.init(describing:)) ?? &quot;-&quot;) }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you genuinely need more than the budget allows, for example non-repeating schedules, schedule a &lt;strong&gt;rolling window&lt;/strong&gt; (the next N days) and top it up whenever the app becomes active. Never assume &quot;scheduled once&quot; means &quot;scheduled forever&quot;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-taken-and-snooze-right-from-the-notification&quot;&gt;4. &quot;Taken&quot; and &quot;Snooze&quot; right from the notification&lt;/h3&gt;
&lt;p&gt;A reminder you have to unlock the phone and open the app to acknowledge is a reminder people ignore. Register a category with actions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import UserNotifications

enum DoseActions {
    static let taken = &quot;TAKEN&quot;
    static let snooze = &quot;SNOOZE_10&quot;

    static func register() {
        let takenAction = UNNotificationAction(identifier: taken, title: &quot;Taken&quot;, options: [])
        let snoozeAction = UNNotificationAction(identifier: snooze, title: &quot;Snooze 10 min&quot;, 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[&quot;medID&quot;] 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: &quot;snooze.\(idString)&quot;,   // one snooze per med
                                                content: content, trigger: trigger)
            try? await center.add(request)

        default:
            break
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the categories and set the delegate early, in &lt;code&gt;application(_:didFinishLaunchingWithOptions:)&lt;/code&gt; or your &lt;code&gt;App&lt;/code&gt; initialiser, because the system may launch your app in the background specifically to handle an action.&lt;/p&gt;
&lt;p&gt;Notice the action options are empty: no &lt;code&gt;.foreground&lt;/code&gt; (which would open the app) and no &lt;code&gt;.authenticationRequired&lt;/code&gt; (which forces an unlock). &quot;Taken&quot; works straight from the Lock Screen. That has a consequence for storage, which section 6 covers.&lt;/p&gt;
&lt;p&gt;Using one fixed identifier per medication for snoozes means repeated snoozes &lt;em&gt;replace&lt;/em&gt; each other instead of filling your 64-slot budget.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-supply-maths-pills-left-run-out-date-and-a-warning&quot;&gt;5. Supply maths: pills left, run-out date and a warning&lt;/h3&gt;
&lt;p&gt;The listing promises you'll &quot;know when your supply will run out&quot; and &quot;get notified before you run out&quot;. That's arithmetic on the model, plus one more scheduled notification:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

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

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

    func runOutDate(from now: Date = Date(), calendar: Calendar = .current) -&amp;gt; Date? {
        guard pillsPerDay &amp;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 &quot;running low&quot; reminder at 09:00, `lowSupplyWarningDays` before run-out.
    static func scheduleLowSupplyWarning(for med: Medication, calendar: Calendar = .current) async throws {
        let id = &quot;supply.\(med.id.uuidString)&quot;
        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 &amp;gt; Date() else { return }

        var components = calendar.dateComponents([.year, .month, .day], from: warnDay)
        components.hour = 9
        let content = UNMutableNotificationContent()
        content.title = &quot;Running low on \(med.name)&quot;
        content.body = &quot;About \(med.lowSupplyWarningDays) days left. Time to refill.&quot;
        content.sound = .default

        try await center.add(UNNotificationRequest(
            identifier: id, content: content,
            trigger: UNCalendarNotificationTrigger(dateMatching: components, repeats: false)))
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Missed doses.&lt;/strong&gt; The system can't tell you a reminder was &lt;em&gt;ignored&lt;/em&gt;. When the app becomes active, compare each scheduled time since the last check against the log, and record &lt;code&gt;.missed&lt;/code&gt; for any slot without a &lt;code&gt;.taken&lt;/code&gt; event within a grace window (say, 2 hours). Then show it honestly and let the user correct it (&quot;I took it, I just didn't tap&quot;).&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-private-on-device-storage-that-works-on-a-locked-phone&quot;&gt;6. Private, on-device storage that works on a locked phone&lt;/h3&gt;
&lt;p&gt;The listing makes a clear promise: &quot;Your medication data stays private on your device. We don't collect or share your personal health information.&quot; The simplest way to keep that promise is to have no server at all: a small &lt;code&gt;Codable&lt;/code&gt; file in Application Support.&lt;/p&gt;
&lt;p&gt;There's one subtlety. The &quot;Taken&quot; action from section 4 can run &lt;strong&gt;while the device is locked&lt;/strong&gt;. If you save with the strongest file protection (&lt;code&gt;.completeFileProtection&lt;/code&gt;), the file is unreadable while locked, and the write fails exactly when the user taps &quot;Taken&quot; on the Lock Screen.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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(&quot;medications.json&quot;)
        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(&quot;Save failed: \(error)&quot;)
        }
    }

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

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

    func image(for url: URL, pixelSize: CGFloat, scale: CGFloat) async -&amp;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))
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The details that matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;NSCache&lt;/code&gt; with a cost limit&lt;/strong&gt; sized in &lt;em&gt;decoded bytes&lt;/em&gt;, not image count, so the system can also evict under memory pressure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.task(id:)&lt;/code&gt;&lt;/strong&gt; cancels the load when a cell scrolls off-screen before finishing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;@Environment(\.displayScale)&lt;/code&gt;&lt;/strong&gt;, not &lt;code&gt;UIScreen.main.scale&lt;/code&gt;. Apple's docs now mark &lt;code&gt;UIScreen.main&lt;/code&gt; as deprecated in iOS 26. Read the scale and screen size from the view's environment or window scene instead.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Why not just &lt;code&gt;AsyncImage&lt;/code&gt; (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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-the-detail-screen-full-resolution-exactly-once&quot;&gt;4. The detail screen: full resolution, exactly once&lt;/h3&gt;
&lt;p&gt;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 &lt;strong&gt;device's native screen size&lt;/strong&gt; rather than the source's 4K:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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 -&amp;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
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;UIScreen.nativeBounds&lt;/code&gt; (iOS 8+) gives the screen's size in physical pixels. Get the screen from the current window scene (&lt;code&gt;view.window?.windowScene?.screen&lt;/code&gt;) rather than &lt;code&gt;UIScreen.main&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What about &quot;parallax-ready&quot;?&lt;/strong&gt; 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 &lt;em&gt;least&lt;/em&gt; 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 &lt;em&gt;save&lt;/em&gt; the original, not the preview.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-create-mode-blur-that-keeps-clean-edges&quot;&gt;5. Create mode: blur that keeps clean edges&lt;/h3&gt;
&lt;p&gt;&quot;Create your own wallpapers — edit photos and add blur effects.&quot; 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:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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) -&amp;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 &amp;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)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Performance tips for a live slider:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Preview on the downsampled image&lt;/strong&gt; and render the full-resolution output only when the user taps Save. Blur cost grows with the pixel count &lt;em&gt;and&lt;/em&gt; the radius.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scale the radius&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reuse one &lt;code&gt;CIContext&lt;/code&gt;.&lt;/strong&gt; Creating a context per slider tick is the slowest possible way to use Core Image.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The optional &lt;code&gt;dim&lt;/code&gt; parameter is a small design touch: a slightly darker, blurred background keeps the Lock Screen clock and widgets readable.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-saving-with-the-minimum-permission&quot;&gt;6. Saving with the minimum permission&lt;/h3&gt;
&lt;p&gt;Saving an image to Photos doesn't require read access to the user's library. iOS has an &lt;strong&gt;add-only&lt;/strong&gt; access level for exactly this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Declare &lt;code&gt;NSPhotoLibraryAddUsageDescription&lt;/code&gt;&lt;/strong&gt; (iOS 11+), not the full-library key. For example: &quot;Save wallpapers you choose to your photo library.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;requestAuthorization(for: .addOnly)&lt;/code&gt;&lt;/strong&gt; (iOS 14+) asks only for what you need. Users are far more likely to allow it, and you never see their photos.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Save the original file&lt;/strong&gt; with &lt;code&gt;PHAssetCreationRequest.addResource(with:fileURL:options:)&lt;/code&gt; (iOS 9+). Re-encoding a &lt;code&gt;UIImage&lt;/code&gt; can cost quality and strip metadata.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-the-honest-part-you-can-t-set-the-wallpaper-for-the-user&quot;&gt;7. The honest part: you can't set the wallpaper for the user&lt;/h3&gt;
&lt;p&gt;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 &quot;saved to Photos&quot;, and the user sets it from the Photos app or from Settings → Wallpaper.&lt;/p&gt;
&lt;p&gt;So make that last step effortless:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;After saving, show a short, illustrated guide: &lt;strong&gt;Photos → open the image → Share → Use as Wallpaper&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Offer a button that opens Photos. Don't promise a one-tap &quot;Set&quot; button you can't deliver; App Review and users both dislike that.&lt;/li&gt;
&lt;li&gt;Mention that the system lets you pinch and position the image, and that depth and motion options depend on the device and iOS version.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The listing's own wording handles this well: &quot;Save the image… Set it as your Lock Screen or Home Screen wallpaper&quot;. It's a user step, described as one.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-wallpapers-hd-exotic-4k&quot;&gt;8. Real-world example: Wallpapers HD: Exotic 4K&lt;/h3&gt;
&lt;p&gt;These are the techniques I'd use, and recommend, for &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/wallpapers-hd-exotic-4k/id6443850234&quot;&gt;Wallpapers HD: Exotic 4K&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/shipping-a-4k-wallpaper-app-without-running-out-of-memory/wallpapers-hd-screenshot-1.jpg&quot; alt=&quot;Wallpapers HD App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt; with an optional Premium tier, in &lt;strong&gt;Photo &amp;amp; Video&lt;/strong&gt; and &lt;strong&gt;Graphics &amp;amp; Design&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;December 2022&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;6 ratings&lt;/strong&gt; in the US store, all &lt;strong&gt;5 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 16.0 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Fresh 4K HD wallpapers added regularly&quot; / &quot;Browse by category… Quick search&quot; &lt;/td&gt;&lt;td&gt;A small rendition for the grid, downsampled and cost-cached (sections 2–3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Parallax-ready backgrounds that look sharp on every iPhone display&quot; &lt;/td&gt;&lt;td&gt;Native-size preview, original saved, room for system cropping (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Create your own wallpapers — edit photos and add blur effects&quot; &lt;/td&gt;&lt;td&gt;Clamped Gaussian blur with optional dimming (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Save the image… Set it as your Lock Screen or Home Screen wallpaper&quot; &lt;/td&gt;&lt;td&gt;Add-only Photos saving, then a guided user step (sections 6–7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Free: Browse and download HD wallpapers with ads / Premium: … advanced blur effects… ad-free&quot; &lt;/td&gt;&lt;td&gt;Gate the content and editor features by entitlement; the pipeline is the same&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/shipping-a-4k-wallpaper-app-without-running-out-of-memory/wallpapers-hd-screenshot-2.jpg&quot; alt=&quot;Wallpapers HD App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-an-image-heavy-app-a-checklist&quot;&gt;9. Before you ship an image-heavy app: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Nothing is decoded larger than it's displayed.&lt;/strong&gt; Grid cells use downsampled thumbnails sized in pixels.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cache is costed in decoded bytes&lt;/strong&gt; with a sensible limit, and it responds to memory pressure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scrolling is profiled on your oldest supported device&lt;/strong&gt; with Instruments' Allocations and Animation Hitches tools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full-resolution images are downloaded to disk&lt;/strong&gt;, previewed at screen size, and saved as original bytes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blur previews run on the small image&lt;/strong&gt; and export at full size with a scaled radius.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Photos access is add-only&lt;/strong&gt;, with &lt;code&gt;NSPhotoLibraryAddUsageDescription&lt;/code&gt; and graceful handling of denial.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You don't promise &quot;set wallpaper&quot; automation.&lt;/strong&gt; Guide the user through Photos instead.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;UIScreen.main&lt;/code&gt; is gone from new code.&lt;/strong&gt; Use the environment's &lt;code&gt;displayScale&lt;/code&gt; and the window scene's screen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Image licensing is clear&lt;/strong&gt; for every image you serve.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-11&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Try it, or explore my other apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Wallpapers HD: Exotic 4K (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/wallpapers-hd-exotic-4k/id6443850234&quot;&gt;https://apps.apple.com/us/app/wallpapers-hd-exotic-4k/id6443850234&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What's the largest memory spike an image grid has ever given you? Tell me in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/shipping-a-4k-wallpaper-app-without-running-out-of-memory/&quot;&gt;https://bestlatestapps.com/blog/shipping-a-4k-wallpaper-app-without-running-out-of-memory/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32271/shipping-4k-wallpaper-app-without-running-out-memory-imageio-downsampling-and-core-image</guid>
<pubDate>Sat, 24 Oct 2026 12:40:06 +0000</pubDate>
</item>
<item>
<title>Text Plus Audio, Fully Offline: Building a Prayer App with AVAudioPlayer and Lock-Screen Controls</title>
<link>https://coderlegion.com/32186/text-plus-audio-fully-offline-building-prayer-app-with-avaudioplayer-lock-screen-controls</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/text-plus-audio-fully-offline/&quot;&gt;https://bestlatestapps.com/blog/text-plus-audio-fully-offline/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Bundled recitation audio, verse-by-verse highlighting from a cue sheet, background playback, Now Playing controls and the interruptions you must handle, on an app that still supports iOS 11&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/text-plus-audio-fully-offline/hanuman-chalisa-icon-512.jpg&quot; alt=&quot;Hanuman Chalisa app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;Some apps are used in a very particular moment. A prayer app is opened in the morning, often before anything else, frequently by someone older who isn't interested in settings screens, and sometimes somewhere with no signal at all. It has to open instantly, play immediately and never ask for an account.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hanuman Chalisa : Offline&lt;/strong&gt; was the second app I published, in April 2020, one day after my Bible app. The Hanuman Chalisa is a devotional hymn to Lord Hanuman, attributed to the poet-saint Tulsidas. The app does two things: it shows the Chalisa in English-language script for reading, and it plays an audio recitation, all without an internet connection.&lt;/p&gt;
&lt;p&gt;That combination, text plus audio that stay in step, is a pattern you'll find in language-learning apps, audiobooks with text, karaoke lyrics and guided meditations. This article walks through how I'd build it on iOS: bundling text and audio, a cue sheet for verse timing, &lt;code&gt;AVAudioPlayer&lt;/code&gt; with a proper &lt;code&gt;.playback&lt;/code&gt; session, highlighting the current verse, lock-screen controls through &lt;code&gt;MPNowPlayingInfoCenter&lt;/code&gt; and &lt;code&gt;MPRemoteCommandCenter&lt;/code&gt;, and the interruptions and route changes a real device throws at you. The app supports iOS 11 and later, so the code is UIKit-first and stays on long-established APIs. I'm describing the approach I'd use and recommend, not the app's exact source.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-what-offline-text-plus-audio-requires&quot;&gt;1. What &quot;offline text plus audio&quot; requires&lt;/h3&gt;
&lt;p&gt;Three requirements shape the design:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Zero network, zero setup.&lt;/strong&gt; Everything ships in the app bundle. First launch is the same as the hundredth.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Audio and text are one experience.&lt;/strong&gt; When the recitation reaches a verse, that verse is highlighted and scrolled into view. When the user taps a verse, the audio jumps there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It behaves like a real audio app.&lt;/strong&gt; It keeps playing with the screen locked, shows controls on the Lock Screen and in Control Center, pauses for a phone call, and stops when headphones are pulled out.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first two are about your data model. The third is about respecting the system.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-bundle-the-content-with-a-cue-sheet&quot;&gt;2. Bundle the content with a cue sheet&lt;/h3&gt;
&lt;p&gt;I keep the text and the timing together in one small JSON file in the bundle, next to the audio file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;{
  &quot;title&quot;: &quot;Hanuman Chalisa&quot;,
  &quot;audio&quot;: &quot;chalisa.m4a&quot;,
  &quot;verses&quot;: [
    { &quot;id&quot;: 1, &quot;kind&quot;: &quot;doha&quot;,  &quot;start&quot;: 0.0,  &quot;text&quot;: &quot;…&quot; },
    { &quot;id&quot;: 2, &quot;kind&quot;: &quot;doha&quot;,  &quot;start&quot;: 14.2, &quot;text&quot;: &quot;…&quot; },
    { &quot;id&quot;: 3, &quot;kind&quot;: &quot;chaupai&quot;, &quot;start&quot;: 27.8, &quot;text&quot;: &quot;…&quot; }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each verse carries the time, in seconds, at which its recitation &lt;strong&gt;starts&lt;/strong&gt;. That's the whole cue sheet. You produce it once, by listening with a waveform editor open, and it never changes unless the recording does.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

struct Verse: Decodable {
    let id: Int
    let kind: String
    let start: TimeInterval
    let text: String
}

struct Recitation: Decodable {
    let title: String
    let audio: String
    let verses: [Verse]

    static func loadBundled(named name: String = &quot;chalisa&quot;) throws -&amp;gt; Recitation {
        guard let url = Bundle.main.url(forResource: name, withExtension: &quot;json&quot;) else {
            throw CocoaError(.fileNoSuchFile)
        }
        let recitation = try JSONDecoder().decode(Recitation.self, from: Data(contentsOf: url))
        // Cue sheets must be sorted; fail loudly in debug if someone edits one by hand.
        assert(zip(recitation.verses, recitation.verses.dropFirst()).allSatisfy { $0.start &amp;lt; $1.start })
        return recitation
    }

    /// Index of the verse being recited at `time` (binary search over start times).
    func verseIndex(at time: TimeInterval) -&amp;gt; Int {
        var low = 0, high = verses.count - 1, answer = 0
        while low &amp;lt;= high {
            let mid = (low + high) / 2
            if verses[mid].start &amp;lt;= time { answer = mid; low = mid + 1 } else { high = mid - 1 }
        }
        return answer
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few practical notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AAC (&lt;code&gt;.m4a&lt;/code&gt;) is the right format&lt;/strong&gt; for a bundled spoken or sung recording: small, and decoded in hardware.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep the text as Unicode text, not images.&lt;/strong&gt; It scales with Dynamic Type, it works with VoiceOver, and you can offer other scripts later from the same structure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Binary search is overkill for 40-odd verses&lt;/strong&gt;, but it costs nothing, and the same code works for a 300-chapter audiobook.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-avaudioplayer-and-a-session-that-keeps-playing&quot;&gt;3. AVAudioPlayer and a session that keeps playing&lt;/h3&gt;
&lt;p&gt;For a single bundled file, &lt;code&gt;AVAudioPlayer&lt;/code&gt; (available since iOS 2.2) is all you need. There's no engine graph and nothing to stream. What matters is the &lt;strong&gt;audio session category&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

final class RecitationPlayer: NSObject, AVAudioPlayerDelegate {
    private(set) var player: AVAudioPlayer?
    var onFinish: (() -&amp;gt; Void)?

    func load(_ recitation: Recitation) throws {
        guard let url = Bundle.main.url(forResource: recitation.audio, withExtension: nil) else {
            throw CocoaError(.fileNoSuchFile)
        }
        let player = try AVAudioPlayer(contentsOf: url)
        player.delegate = self
        player.enableRate = true          // must be set before prepareToPlay() to allow rate changes
        player.prepareToPlay()
        self.player = player
    }

    func play() throws {
        let session = AVAudioSession.sharedInstance()
        // .playback: audio is central to the app; it plays with the Silent switch on
        // and, with the &quot;audio&quot; background mode, keeps going when the screen locks.
        try session.setCategory(.playback, mode: .spokenAudio, options: [])
        try session.setActive(true)
        player?.play()
    }

    func pause() { player?.pause() }

    func seek(to time: TimeInterval) {
        guard let player else { return }
        player.currentTime = max(0, min(time, player.duration))
    }

    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
        onFinish?()
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three details that matter more than they look:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;.playback&lt;/code&gt;, not the default.&lt;/strong&gt; The default category (&lt;code&gt;.soloAmbient&lt;/code&gt;) is silenced by the ring/silent switch and stops when the screen locks. For a prayer app, both are bugs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add the background mode.&lt;/strong&gt; In Xcode, enable Background Modes → &quot;Audio, AirPlay, and Picture in Picture&quot; (&lt;code&gt;audio&lt;/code&gt; in &lt;code&gt;UIBackgroundModes&lt;/code&gt;). Without it, &lt;code&gt;.playback&lt;/code&gt; alone won't keep you alive after the screen locks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;.spokenAudio&lt;/code&gt; mode&lt;/strong&gt; (iOS 9+) is meant for continuous spoken audio. Per Apple's docs, your audio &lt;em&gt;pauses&lt;/em&gt; rather than ducks when another app plays a short spoken prompt, such as a navigation instruction, and you can resume afterwards. A sung recitation sits somewhere between speech and music, so &lt;code&gt;.default&lt;/code&gt; is a reasonable choice too. &lt;code&gt;setCategory(_:mode:options:)&lt;/code&gt; is iOS 10+, so it's safe on an iOS 11 target.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-highlighting-the-current-verse&quot;&gt;4. Highlighting the current verse&lt;/h3&gt;
&lt;p&gt;To keep the text in step with the audio, poll &lt;code&gt;currentTime&lt;/code&gt; a few times a second and move the highlight when the verse index changes. On iOS 11 I use a &lt;code&gt;CADisplayLink&lt;/code&gt; (or a plain &lt;code&gt;Timer&lt;/code&gt;) rather than anything newer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import UIKit

final class RecitationViewController: UITableViewController {
    private let recitation: Recitation
    private let audio = RecitationPlayer()
    private var link: CADisplayLink?
    private var currentIndex = -1

    init(recitation: Recitation) {
        self.recitation = recitation
        super.init(style: .plain)
    }
    required init?(coder: NSCoder) { fatalError(&quot;init(coder:) is not supported&quot;) }

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: &quot;verse&quot;)
        tableView.rowHeight = UITableView.automaticDimension
        try? audio.load(recitation)
    }

    func startPlayback() {
        try? audio.play()
        link = CADisplayLink(target: self, selector: #selector(tick))
        link?.preferredFramesPerSecond = 10     // plenty for text; saves battery
        link?.add(to: .main, forMode: .common)
    }

    func stopUpdates() {
        link?.invalidate(); link = nil
    }

    @objc private func tick() {
        guard let time = audio.player?.currentTime else { return }
        let index = recitation.verseIndex(at: time)
        guard index != currentIndex else { return }
        let previous = currentIndex
        currentIndex = index
        let rows = [previous, index].filter { $0 &amp;gt;= 0 }.map { IndexPath(row: $0, section: 0) }
        tableView.reloadRows(at: rows, with: .fade)
        tableView.scrollToRow(at: IndexPath(row: index, section: 0), at: .middle, animated: true)
    }

    // Tap a verse to jump the audio there.
    override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        audio.seek(to: recitation.verses[indexPath.row].start)
        tableView.deselectRow(at: indexPath, animated: true)
    }

    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -&amp;gt; Int {
        recitation.verses.count
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -&amp;gt; UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: &quot;verse&quot;, for: indexPath)
        let isCurrent = indexPath.row == currentIndex
        cell.textLabel?.text = recitation.verses[indexPath.row].text
        cell.textLabel?.numberOfLines = 0
        cell.textLabel?.font = UIFont.preferredFont(forTextStyle: isCurrent ? .headline : .body)
        cell.textLabel?.adjustsFontForContentSizeCategory = true
        cell.backgroundColor = isCurrent ? UIColor.orange.withAlphaComponent(0.15) : .clear
        return cell
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two accessibility points that matter especially for this audience:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dynamic Type everywhere&lt;/strong&gt; (&lt;code&gt;preferredFont(forTextStyle:)&lt;/code&gt; with &lt;code&gt;adjustsFontForContentSizeCategory&lt;/code&gt;). Many people reading a prayer app use larger text sizes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't fight the user's scrolling.&lt;/strong&gt; If they've scrolled away to read ahead, pause auto-scrolling for a few seconds. Detect it in &lt;code&gt;scrollViewWillBeginDragging(_:)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Stop the display link when the view disappears or playback ends. A display link running in the background is wasted battery.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-lock-screen-and-control-center&quot;&gt;5. Lock Screen and Control Center&lt;/h3&gt;
&lt;p&gt;Once you're a real &lt;code&gt;.playback&lt;/code&gt; app, people expect to control it from the Lock Screen, Control Center, headphones and the car. That's two APIs: &lt;code&gt;MPNowPlayingInfoCenter&lt;/code&gt; (iOS 5+) for &lt;em&gt;what's playing&lt;/em&gt; and &lt;code&gt;MPRemoteCommandCenter&lt;/code&gt; (iOS 7.1+) for &lt;em&gt;what the buttons do&lt;/em&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import MediaPlayer
import UIKit

final class NowPlayingController {
    private weak var audio: RecitationPlayer?

    init(audio: RecitationPlayer, title: String, artwork: UIImage?) {
        self.audio = audio
        configureCommands()
        var info: [String: Any] = [
            MPMediaItemPropertyTitle: title,
            MPMediaItemPropertyArtist: &quot;Recitation&quot;,
            MPMediaItemPropertyPlaybackDuration: audio.player?.duration ?? 0
        ]
        if let artwork {
            info[MPMediaItemPropertyArtwork] =
                MPMediaItemArtwork(boundsSize: artwork.size) { _ in artwork }
        }
        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    }

    /// Call on play, pause and seek, not every frame. The system extrapolates
    /// elapsed time from the rate you give it.
    func update() {
        guard let player = audio?.player else { return }
        var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
        info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime
        info[MPNowPlayingInfoPropertyPlaybackRate] = player.isPlaying ? 1.0 : 0.0
        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    }

    private func configureCommands() {
        let center = MPRemoteCommandCenter.shared()

        center.playCommand.addTarget { [weak self] _ in
            try? self?.audio?.play(); self?.update(); return .success
        }
        center.pauseCommand.addTarget { [weak self] _ in
            self?.audio?.pause(); self?.update(); return .success
        }
        center.togglePlayPauseCommand.addTarget { [weak self] _ in
            guard let player = self?.audio?.player else { return .commandFailed }
            if player.isPlaying { self?.audio?.pause() } else { try? self?.audio?.play() }
            self?.update(); return .success
        }
        center.changePlaybackPositionCommand.addTarget { [weak self] event in
            guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
            self?.audio?.seek(to: e.positionTime); self?.update(); return .success
        }
        // Commands you don't support should be disabled, so the UI doesn't show dead buttons.
        center.nextTrackCommand.isEnabled = false
        center.previousTrackCommand.isEnabled = false
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MPMediaItemArtwork(boundsSize:requestHandler:)&lt;/code&gt; is iOS 10+, so it's fine on iOS 11. The scrubber on the Lock Screen needs &lt;code&gt;changePlaybackPositionCommand&lt;/code&gt; (iOS 9.1+); without it, the scrubber is display-only.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don't update Now Playing on every tick.&lt;/strong&gt; Set elapsed time and rate on play, pause and seek, and the system animates the progress itself.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-interruptions-and-route-changes-1&quot;&gt;6. Interruptions and route changes&lt;/h3&gt;
&lt;p&gt;Real phones ring. Here are the two notifications every audio app must handle:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import AVFoundation

final class AudioInterruptionHandler {
    private weak var audio: RecitationPlayer?
    private var tokens: [NSObjectProtocol] = []
    private var wasPlaying = false
    var onChange: (() -&amp;gt; Void)?

    init(audio: RecitationPlayer) {
        self.audio = audio
        let center = NotificationCenter.default
        let session = AVAudioSession.sharedInstance()

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

    deinit { tokens.forEach(NotificationCenter.default.removeObserver) }

    private func handleInterruption(_ note: Notification) {
        guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
              let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
        switch type {
        case .began:
            wasPlaying = audio?.player?.isPlaying ?? false   // the system has already paused us
        case .ended:
            let optionsRaw = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
            if wasPlaying, AVAudioSession.InterruptionOptions(rawValue: optionsRaw).contains(.shouldResume) {
                try? audio?.play()
            }
            wasPlaying = false
        @unknown default: break
        }
        onChange?()
    }

    private func handleRouteChange(_ note: Notification) {
        guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
              let reason = AVAudioSession.RouteChangeReason(rawValue: raw) else { return }
        // Headphones unplugged / Bluetooth gone: pause, as Apple recommends for media apps,
        // instead of suddenly reciting out loud on the speaker.
        if reason == .oldDeviceUnavailable { audio?.pause(); onChange?() }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two version notes. The Swift names &lt;code&gt;AVAudioSession.interruptionNotification&lt;/code&gt; and &lt;code&gt;routeChangeNotification&lt;/code&gt; are the modern spellings; on the very oldest SDKs they were &lt;code&gt;NSNotification.Name.AVAudioSessionInterruption&lt;/code&gt; and &lt;code&gt;AVAudioSessionRouteChange&lt;/code&gt;. And Apple's current docs mark &lt;code&gt;.shouldResume&lt;/code&gt; as deprecated in iOS 27 in favour of a newer resumption-recommendation mechanism. It still works on every version this app supports, so I've kept it here.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-real-world-example-hanuman-chalisa-offline&quot;&gt;7. Real-world example: Hanuman Chalisa : Offline&lt;/h3&gt;
&lt;p&gt;This is the approach I'd use for &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/hanuman-chalisa-offline/id1509206340&quot;&gt;Hanuman Chalisa : Offline&lt;/a&gt;&lt;/strong&gt;, and for any text-plus-audio app.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/text-plus-audio-fully-offline/hanuman-chalisa-screenshot-1.jpg&quot; alt=&quot;Hanuman Chalisa App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in the &lt;strong&gt;Book&lt;/strong&gt; and &lt;strong&gt;Music&lt;/strong&gt; categories&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;April 2020&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;22 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;4.8 stars&lt;/strong&gt;, the highest average among my apps with ten or more ratings&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 11.0 and later&lt;/strong&gt;, on iPhone, iPad and iPod touch&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The listing describes the content simply: &quot;This version has both Chalisa in English Language Script (for reading) and Audio.&quot; Here's how that maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Hanuman Chalisa : Offline&quot; &lt;/td&gt;&lt;td&gt;Text, cue sheet and audio all bundled; no network code at all (section 2)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Chalisa in English Language Script (for reading)&quot; &lt;/td&gt;&lt;td&gt;Unicode text with Dynamic Type in a table view (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;…and Audio&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;AVAudioPlayer&lt;/code&gt; with a &lt;code&gt;.playback&lt;/code&gt; session and background mode (section 3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;The iPhone, iPod &amp;amp; iPad version&quot; &lt;/td&gt;&lt;td&gt;UIKit-first code on long-standing APIs, iOS 11 and up (throughout)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/text-plus-audio-fully-offline/hanuman-chalisa-screenshot-2.jpg&quot; alt=&quot;Hanuman Chalisa App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;I later built a much larger devotional app, &lt;strong&gt;Mandir Darshan&lt;/strong&gt;, with live temple streams, the Bhagavad Gita and a chalisa collection. That one's a very different technical story, built on live streaming and picture-in-picture, and I'm covering it in its own article.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-before-you-ship-a-text-plus-audio-app-a-checklist&quot;&gt;8. Before you ship a text-plus-audio app: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;It works in Airplane Mode from a fresh install&lt;/strong&gt;: text, audio and highlighting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The category is &lt;code&gt;.playback&lt;/code&gt;&lt;/strong&gt; and the &lt;code&gt;audio&lt;/code&gt; background mode is enabled, so audio survives the Silent switch and the Lock Screen.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cue sheet is validated.&lt;/strong&gt; Start times are sorted, and every verse has one. Assert it in debug builds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tapping a verse seeks the audio&lt;/strong&gt;, and the highlight follows the audio, never the other way round.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lock-screen controls work&lt;/strong&gt;, including the scrubber, and unsupported commands are disabled.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A phone call pauses playback&lt;/strong&gt;, and it resumes only if it was playing and the system says it may.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unplugging headphones pauses playback.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Text respects Dynamic Type and VoiceOver.&lt;/strong&gt; Test at the largest accessibility sizes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The session is deactivated when playback finishes&lt;/strong&gt;, with &lt;code&gt;.notifyOthersOnDeactivation&lt;/code&gt;, so other audio can resume.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-12&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;A text-plus-audio app looks simple, and it can be, as long as you treat the audio session with respect. Bundle everything, describe timing with a tiny cue sheet, let &lt;code&gt;AVAudioPlayer&lt;/code&gt; play, keep the highlight driven by &lt;code&gt;currentTime&lt;/code&gt;, and give the system what it needs for the Lock Screen and for interruptions. It's well-established API, which is exactly why it still runs on iOS 11.&lt;/p&gt;
&lt;p&gt;Try the app, or explore my other work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hanuman Chalisa : Offline (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/hanuman-chalisa-offline/id1509206340&quot;&gt;https://apps.apple.com/us/app/hanuman-chalisa-offline/id1509206340&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Have you built synchronised text and audio for lyrics, language lessons or audiobooks? I'd love to hear how you produced your timings. Tell me in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/text-plus-audio-fully-offline/&quot;&gt;https://bestlatestapps.com/blog/text-plus-audio-fully-offline/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32186/text-plus-audio-fully-offline-building-prayer-app-with-avaudioplayer-lock-screen-controls</guid>
<pubDate>Fri, 23 Oct 2026 16:38:02 +0000</pubDate>
</item>
<item>
<title>Stop Hand-Rolling Unit Conversions: Foundation's Measurement, Dimension and MeasurementFormatter</title>
<link>https://coderlegion.com/32188/stop-hand-rolling-conversions-foundations-measurement-dimension-measurementformatter</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/stop-hand-rolling-unit-conversions/&quot;&gt;https://bestlatestapps.com/blog/stop-hand-rolling-unit-conversions/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Built-in units, custom dimensions, non-linear converters, locale-aware input and output, and the temperature trap that catches everyone&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/stop-hand-rolling-unit-conversions/units-converter-icon-512.jpg&quot; alt=&quot;Units Converter &amp;amp; Calculator app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;Every unit converter I've seen in a code review starts the same way: a dictionary of conversion factors, a &lt;code&gt;switch&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;My app &lt;strong&gt;Units Converter &amp;amp; Calculator&lt;/strong&gt; 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.&lt;/p&gt;
&lt;p&gt;Apple has shipped a proper units system since iOS 10: &lt;code&gt;Measurement&lt;/code&gt;, &lt;code&gt;Unit&lt;/code&gt;, &lt;code&gt;Dimension&lt;/code&gt; and &lt;code&gt;UnitConverter&lt;/code&gt;, plus &lt;code&gt;MeasurementFormatter&lt;/code&gt; and the newer &lt;code&gt;Measurement.FormatStyle&lt;/code&gt; for output. Most developers I talk to have never used more than &lt;code&gt;UnitLength.meters&lt;/code&gt;. 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-why-conversion-tables-rot&quot;&gt;1. Why conversion tables rot&lt;/h3&gt;
&lt;p&gt;A hand-written table has three problems:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;N² relationships.&lt;/strong&gt; If you store &quot;A to B&quot; factors, you need a factor for every pair. Add one unit and you owe the table N new entries.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hidden non-linearity.&lt;/strong&gt; Temperature has an offset; fuel economy is reciprocal (L/100 km vs mpg). These get special-cased, and special cases breed bugs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Formatting is an afterthought.&lt;/strong&gt; &quot;1.0000000000002 km&quot; shows up in the UI, and users in Germany type &quot;1,5&quot; and get an error.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Foundation's model fixes the first two by design: every unit in a &lt;strong&gt;dimension&lt;/strong&gt; knows how to convert &lt;em&gt;to and from that dimension's base unit&lt;/em&gt;. 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-the-basics-measurement-and-built-in-dimensions&quot;&gt;2. The basics: Measurement and built-in dimensions&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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)
      &amp;gt; Measurement(value: 2, unit: UnitMass.pounds))  // true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Foundation ships a little over twenty &lt;code&gt;Dimension&lt;/code&gt; 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 (&lt;code&gt;UnitInformationStorage&lt;/code&gt;, iOS 13+). Each has many units: &lt;code&gt;UnitEnergy.kilowattHours&lt;/code&gt;, &lt;code&gt;UnitSpeed.knots&lt;/code&gt;, &lt;code&gt;UnitPressure.inchesOfMercury&lt;/code&gt; and so on.&lt;/p&gt;
&lt;p&gt;For a converter UI, wrap the dimension in a small description type so a category screen can list its units generically:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct UnitCategory: Identifiable {
    let id: String                 // stable key, e.g. &quot;length&quot;
    let name: String
    let units: [Dimension]
}

let length = UnitCategory(id: &quot;length&quot;, name: &quot;Length&quot;, 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) -&amp;gt; Double {
    Measurement(value: value, unit: from).converted(to: to).value
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Measurement&amp;lt;Dimension&amp;gt;&lt;/code&gt; works here because &lt;code&gt;converted(to:)&lt;/code&gt; is available whenever &lt;code&gt;UnitType&lt;/code&gt; is a &lt;code&gt;Dimension&lt;/code&gt;. Just make sure &lt;code&gt;from&lt;/code&gt; and &lt;code&gt;to&lt;/code&gt; 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-custom-dimensions-for-what-foundation-doesn-t-cover&quot;&gt;3. Custom dimensions for what Foundation doesn't cover&lt;/h3&gt;
&lt;p&gt;Getting to 30+ categories means going beyond the built-ins: density, torque, data transfer rate, radiation dose, flow rate. A custom dimension is a &lt;code&gt;Dimension&lt;/code&gt; subclass with a base unit and static units defined by &lt;code&gt;UnitConverterLinear&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;final class UnitDensity: Dimension {
    // Base unit: kilograms per cubic metre.
    static let kilogramsPerCubicMeter = UnitDensity(
        symbol: &quot;kg/m³&quot;, converter: UnitConverterLinear(coefficient: 1))
    static let gramsPerCubicCentimeter = UnitDensity(
        symbol: &quot;g/cm³&quot;, converter: UnitConverterLinear(coefficient: 1_000))
    static let gramsPerLiter = UnitDensity(
        symbol: &quot;g/L&quot;, converter: UnitConverterLinear(coefficient: 1))
    static let poundsPerCubicFoot = UnitDensity(
        symbol: &quot;lb/ft³&quot;, converter: UnitConverterLinear(coefficient: 16.018_463))

    override class func baseUnit() -&amp;gt; UnitDensity { kilogramsPerCubicMeter }
}

let water = Measurement(value: 1, unit: UnitDensity.gramsPerCubicCentimeter)
print(water.converted(to: .poundsPerCubicFoot).value)   // ≈ 62.43
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;strong&gt;coefficient is &quot;how many base units in one of this unit&quot;&lt;/strong&gt;. 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.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UnitConverterLinear(coefficient:constant:)&lt;/code&gt; also takes a &lt;code&gt;constant&lt;/code&gt;, applied as &lt;code&gt;base = value × coefficient + constant&lt;/code&gt;. That's how Foundation models Celsius and Fahrenheit against kelvin. You'll rarely need it for custom units, but it's there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write a round-trip test for every unit.&lt;/strong&gt; It catches typos instantly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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, &quot;\(a.symbol) ↔ \(b.symbol)&quot;)
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Round-trips catch arithmetic mistakes, not wrong factors, so add one &quot;known value&quot; assertion per unit from a trusted reference too.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-non-linear-conversions-write-your-own-unitconverter&quot;&gt;4. Non-linear conversions: write your own UnitConverter&lt;/h3&gt;
&lt;p&gt;Some relationships aren't &lt;code&gt;a × x + b&lt;/code&gt;. The classic one is fuel economy: litres per 100 km and miles per gallon are &lt;em&gt;reciprocals&lt;/em&gt;. Foundation's &lt;code&gt;UnitFuelEfficiency&lt;/code&gt; already handles that internally, but you'll hit the same shape in your own categories. Runners, for example, want &lt;strong&gt;pace&lt;/strong&gt; (minutes per kilometre) and &lt;strong&gt;speed&lt;/strong&gt; (km/h) to convert into each other.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UnitConverter&lt;/code&gt; is an open class with two methods to override:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;/// 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) -&amp;gt; Double {
        value == 0 ? .infinity : k / value
    }
    override func value(fromBaseUnitValue baseUnitValue: Double) -&amp;gt; Double {
        baseUnitValue == 0 ? .infinity : k / baseUnitValue
    }
}

final class UnitPace: Dimension {
    // Base unit: seconds per metre.
    static let secondsPerMeter = UnitPace(symbol: &quot;s/m&quot;,
        converter: UnitConverterLinear(coefficient: 1))
    static let minutesPerKilometer = UnitPace(symbol: &quot;min/km&quot;,
        converter: UnitConverterLinear(coefficient: 60.0 / 1_000))
    static let minutesPerMile = UnitPace(symbol: &quot;min/mi&quot;,
        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: &quot;km/h&quot;,
        converter: UnitConverterReciprocal(reciprocal: 3.6))

    override class func baseUnit() -&amp;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
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Guard against zero. A pace of 0 is a meaningless input, and &lt;code&gt;.infinity&lt;/code&gt; displays far better than a crash.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-input-and-output-that-respect-the-user-s-locale&quot;&gt;5. Input and output that respect the user's locale&lt;/h3&gt;
&lt;h4 id=&quot;parsing-what-the-user-typed&quot;&gt;Parsing what the user typed&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Double(&quot;1,5&quot;)&lt;/code&gt; is &lt;code&gt;nil&lt;/code&gt;. In much of Europe, that's how you write one and a half. Parse with the user's locale:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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) -&amp;gt; Double? {
        let trimmed = text.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else { return nil }
        return formatter.number(from: trimmed)?.doubleValue
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pair it with &lt;code&gt;.keyboardType(.decimalPad)&lt;/code&gt;, which shows the locale's decimal separator automatically.&lt;/p&gt;
&lt;h4 id=&quot;formatting-the-result&quot;&gt;Formatting the result&lt;/h4&gt;
&lt;p&gt;Raw doubles leak floating-point noise. &lt;code&gt;Measurement.FormatStyle&lt;/code&gt; (iOS 15+) formats value and unit together, localised, with the precision you choose:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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))
))
// &quot;26.2188 mi&quot; in en_US; &quot;26,2188 mi&quot; in de_DE
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two options matter for a converter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;usage: .asProvided&lt;/code&gt;&lt;/strong&gt;. Without it, formatters may &quot;helpfully&quot; switch to the locale's preferred unit. That's great in a weather app and wrong in a converter.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Significant digits, not fixed decimals.&lt;/strong&gt; 0.000 012 3 and 12,300,000 both deserve a readable result.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On iOS 10–14, &lt;code&gt;MeasurementFormatter&lt;/code&gt; with &lt;code&gt;unitOptions = .providedUnit&lt;/code&gt; gives the same &quot;don't switch my unit&quot; behaviour.&lt;/p&gt;
&lt;p&gt;Custom units format fine too. Their &lt;code&gt;symbol&lt;/code&gt; is used, so choose symbols you're happy to show on screen.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-the-temperature-trap-points-vs-differences&quot;&gt;6. The temperature trap: points vs differences&lt;/h3&gt;
&lt;p&gt;Here's a bug that ships in real apps:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;let delta = Measurement(value: 10, unit: UnitTemperature.celsius)
print(delta.converted(to: .fahrenheit).value)   // 50.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the user meant &quot;it's 10 °C outside&quot;, 50 °F is right. If they meant &quot;the temperature &lt;em&gt;rose by&lt;/em&gt; 10 °C&quot;, the answer is &lt;strong&gt;18 °F&lt;/strong&gt;: a difference doesn't carry the 32° offset.&lt;/p&gt;
&lt;p&gt;Foundation's &lt;code&gt;UnitTemperature&lt;/code&gt; models &lt;em&gt;points&lt;/em&gt; on a scale. For differences, convert with the coefficient only:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;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) -&amp;gt; Double {
        func coefficient(_ u: UnitTemperature) -&amp;gt; Double {
            (u.converter as? UnitConverterLinear)?.coefficient ?? 1
        }
        return value * coefficient(from) / coefficient(to)
    }
}

print(TemperatureDelta.convert(10, from: .celsius, to: .fahrenheit))  // 18.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the UI, a &quot;temperature difference&quot; toggle, or a separate category, makes the intent explicit. It's a good teaching moment for a learning-focused app too.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-history-constants-and-challenges&quot;&gt;7. History, constants and challenges&lt;/h3&gt;
&lt;h4 id=&quot;persisting-history-with-stable-keys&quot;&gt;Persisting history with stable keys&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Measurement&lt;/code&gt; is &lt;code&gt;Codable&lt;/code&gt;, but I don't persist units by trusting the encoded symbol: symbols can change, and custom units need to come back as &lt;em&gt;your&lt;/em&gt; static instances. Store your own stable IDs instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ConversionRecord: Codable, Identifiable {
    var id = UUID()
    let categoryID: String     // &quot;length&quot;
    let fromUnitID: String     // &quot;km&quot;
    let toUnitID: String       // &quot;mi&quot;
    let input: Double
    let date: Date
}

// A registry maps IDs back to Dimension instances.
enum UnitRegistry {
    static let length: [String: UnitLength] = [
        &quot;m&quot;: .meters, &quot;km&quot;: .kilometers, &quot;mi&quot;: .miles, &quot;ft&quot;: .feet, &quot;in&quot;: .inches
    ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Recompute the output on display, rather than storing it. If you ever fix a factor, old history entries silently become correct.&lt;/p&gt;
&lt;h4 id=&quot;constants-are-data-not-code&quot;&gt;Constants are data, not code&lt;/h4&gt;
&lt;p&gt;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 &lt;em&gt;exact by definition&lt;/em&gt;, which is a nice fact to surface in a &quot;why it's important&quot; field.&lt;/p&gt;
&lt;h4 id=&quot;challenge-mode-scoring-an-estimate&quot;&gt;Challenge mode: scoring an estimate&lt;/h4&gt;
&lt;p&gt;For estimation games (&quot;how many feet in a kilometre?&quot;), score by &lt;strong&gt;ratio&lt;/strong&gt;, not absolute difference, so being off by 10% feels the same at every scale:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;/// 100 for a perfect answer, falling to 0 at a factor-of-two error.
func estimationScore(guess: Double, truth: Double) -&amp;gt; Int {
    guard guess &amp;gt; 0, truth &amp;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())
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-units-converter-amp-calculator&quot;&gt;8. Real-world example: Units Converter &amp;amp; Calculator&lt;/h3&gt;
&lt;p&gt;These are the techniques I'd use, and recommend, for a converter like &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/units-converter-calculator/id1515811220&quot;&gt;Units Converter &amp;amp; Calculator&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/stop-hand-rolling-unit-conversions/units-converter-screenshot-1.jpg&quot; alt=&quot;Units Converter App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Productivity&lt;/strong&gt; and &lt;strong&gt;Education&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;May 2020&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;32 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;4.7 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 16.0 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing lines up with this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Convert 200+ units across 30+ categories… completely offline&quot; &lt;/td&gt;&lt;td&gt;Built-in dimensions plus custom &lt;code&gt;Dimension&lt;/code&gt; subclasses, all on-device (sections 2–4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Real-time conversions with instant results&quot; &lt;/td&gt;&lt;td&gt;Locale-aware parsing on every keystroke, formatted output (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;180+ physical constants with detailed explanations&quot; &lt;/td&gt;&lt;td&gt;Constants as bundled data (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Challenge Mode: Test your estimation skills&quot; &lt;/td&gt;&lt;td&gt;Ratio-based scoring (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Automatic saving of all conversions… Organized by category&quot; &lt;/td&gt;&lt;td&gt;History with stable unit IDs, output recomputed on display (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/stop-hand-rolling-unit-conversions/units-converter-screenshot-2.jpg&quot; alt=&quot;Units Converter App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;The listing's audience is &quot;students learning physics and chemistry, engineers and scientists&quot;. 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-a-unit-converter-a-checklist&quot;&gt;9. Before you ship a unit converter: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Every unit has a round-trip test&lt;/strong&gt; and at least one known-value test from a trusted reference.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The coefficient means &quot;base units per one of this unit&quot;&lt;/strong&gt;, and it's written that way everywhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Non-linear units use a real &lt;code&gt;UnitConverter&lt;/code&gt; subclass&lt;/strong&gt;, and zero and negative inputs are handled.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Input parsing uses the user's locale&lt;/strong&gt;, and the decimal-pad keyboard matches it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Output uses significant digits&lt;/strong&gt; and &lt;code&gt;usage: .asProvided&lt;/code&gt; (or &lt;code&gt;.providedUnit&lt;/code&gt;), so the unit the user picked is the unit they see.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Temperature differences are a separate, explicit mode.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;History stores IDs and inputs, not outputs&lt;/strong&gt;, so fixes apply retroactively.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Everything works in Airplane Mode&lt;/strong&gt;, including constants and learning content.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-13&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;Unit conversion is one of those problems that looks like a lookup table and is really a type system. Foundation's &lt;code&gt;Measurement&lt;/code&gt; and &lt;code&gt;Dimension&lt;/code&gt; 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.&lt;/p&gt;
&lt;p&gt;Try the app, or browse the rest of my work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Units Converter &amp;amp; Calculator (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/units-converter-calculator/id1515811220&quot;&gt;https://apps.apple.com/us/app/units-converter-calculator/id1515811220&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Which unit has bitten you in production? For me, temperature differences top the list. Let me know in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/stop-hand-rolling-unit-conversions/&quot;&gt;https://bestlatestapps.com/blog/stop-hand-rolling-unit-conversions/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32188/stop-hand-rolling-conversions-foundations-measurement-dimension-measurementformatter</guid>
<pubDate>Fri, 23 Oct 2026 14:38:22 +0000</pubDate>
</item>
<item>
<title>The Device Side of APNs: Building a Push Notification Test Client in Swift</title>
<link>https://coderlegion.com/32185/the-device-side-of-apns-building-a-push-notification-test-client-in-swift</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/the-device-side-of-apns/&quot;&gt;https://bestlatestapps.com/blog/the-device-side-of-apns/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Permission, registration, device tokens, foreground presentation and payload inspection, plus the exact HTTP/2 request your backend needs to send&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/the-device-side-of-apns/push-notification-client-icon-512.jpg&quot; alt=&quot;Push Notification – Client app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;Every backend developer who has implemented Apple push notifications knows this moment. The server code is written and the request goes out. APNs answers &lt;code&gt;200&lt;/code&gt;, and nothing appears on the phone. Or APNs answers &lt;code&gt;400 BadDeviceToken&lt;/code&gt;, and you have no idea whether the problem is your token, your certificate, your environment or your JSON.&lt;/p&gt;
&lt;p&gt;The hard part is that the backend developer usually doesn't own the iOS app. They have to ask the mobile team for a build, a device token and the right certificate, and then wait again every time something changes.&lt;/p&gt;
&lt;p&gt;That's why I built &lt;strong&gt;Push Notification – Client&lt;/strong&gt;, a small iPhone app with one job: be a known-good &lt;em&gt;receiver&lt;/em&gt;. It hands you a device token, a bundle identifier and a certificate, so you can point your server code at it and see exactly what arrives. It's been on the App Store since June 2020 and is one of my more popular developer tools.&lt;/p&gt;
&lt;p&gt;This article covers both halves of that loop. First, the device side: what an iOS app has to do to receive a push, written so you can build your own test client. Then the server side: the exact request APNs expects, and the error codes you'll see when it's wrong. I've checked API names, header names and limits against Apple's documentation.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-how-a-push-actually-travels&quot;&gt;1. How a push actually travels&lt;/h3&gt;
&lt;p&gt;It helps to have the whole picture before writing code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;┌──────────┐ 1. register  ┌──────┐
│ iOS app  │─────────────▶│ APNs │
│          │◀─────────────│      │ 2. device token
└────┬─────┘              └──▲───┘
     │ 3. send token           │ 5. HTTP/2 POST /3/device/&amp;lt;token&amp;gt;
     ▼                         │    (cert or JWT, topic = bundle ID)
┌──────────────────────────────┴──┐
│ Your provider server            │ 4. store token
└─────────────────────────────────┘
               6. APNs delivers to the device → system shows it / app handles it
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three identities have to line up for delivery to work:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The app's bundle ID&lt;/strong&gt; is the APNs &lt;em&gt;topic&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The environment.&lt;/strong&gt; A development build gets a sandbox token; an App Store or TestFlight build gets a production token. Tokens aren't interchangeable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The credential.&lt;/strong&gt; A push certificate for that bundle ID, or a token-signing key from the same team.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Most &quot;it doesn't arrive&quot; bugs are one of these three not matching. A test client makes all three visible.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-capabilities-and-permission&quot;&gt;2. Capabilities and permission&lt;/h3&gt;
&lt;p&gt;In Xcode, add the &lt;strong&gt;Push Notifications&lt;/strong&gt; capability. This adds the &lt;code&gt;aps-environment&lt;/code&gt; entitlement, whose value (&lt;code&gt;development&lt;/code&gt; or &lt;code&gt;production&lt;/code&gt;) is set by your signing. If you want to test silent pushes too, add &lt;strong&gt;Background Modes → Remote notifications&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Then ask for permission. &lt;code&gt;UNUserNotificationCenter.requestAuthorization(options:)&lt;/code&gt; (iOS 10+) has an async form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import UserNotifications
import UIKit

enum PushPermission {
    /// Returns true if the user allowed alerts, sounds or badges.
    @MainActor
    static func request() async -&amp;gt; Bool {
        let center = UNUserNotificationCenter.current()
        do {
            let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
            // Register even if denied: you still get a token,
            // and silent (background) pushes don't need alert permission.
            UIApplication.shared.registerForRemoteNotifications()
            return granted
        } catch {
            print(&quot;Authorization request failed: \(error)&quot;)
            return false
        }
    }

    static func currentStatus() async -&amp;gt; UNAuthorizationStatus {
        await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details people often miss:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Registering and authorising are separate.&lt;/strong&gt; &lt;code&gt;registerForRemoteNotifications()&lt;/code&gt; gets you a device token whether or not the user allows alerts. Permission decides only how a push is &lt;em&gt;shown&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Provisional authorisation&lt;/strong&gt; (&lt;code&gt;.provisional&lt;/code&gt;, iOS 12+) delivers quietly to Notification Center without prompting. For a test tool I want the real prompt, because I want the full alert, but provisional is worth knowing about for consumer apps.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-getting-and-showing-the-device-token&quot;&gt;3. Getting and showing the device token&lt;/h3&gt;
&lt;p&gt;APNs returns the token to your app delegate as raw &lt;code&gt;Data&lt;/code&gt;. Servers expect a &lt;strong&gt;lowercase hex string&lt;/strong&gt;. Getting this conversion wrong (for example, using &lt;code&gt;description&lt;/code&gt; on the &lt;code&gt;Data&lt;/code&gt;) is the classic cause of &lt;code&gt;BadDeviceToken&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;In a SwiftUI app you wire the app delegate in with &lt;code&gt;@UIApplicationDelegateAdaptor&lt;/code&gt; (iOS 14+):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftUI
import UserNotifications

@MainActor
final class PushModel: ObservableObject {
    static let shared = PushModel()
    @Published var deviceToken: String?
    @Published var registrationError: String?
    @Published var received: [ReceivedPush] = []
}

struct ReceivedPush: Identifiable {
    let id = UUID()
    let date = Date()
    let json: String
}

final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {

    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -&amp;gt; Bool {
        UNUserNotificationCenter.current().delegate = self
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        let hex = deviceToken.map { String(format: &quot;%02x&quot;, $0) }.joined()
        Task { @MainActor in PushModel.shared.deviceToken = hex }
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        // Common on the Simulator without a configured environment,
        // or when the entitlement is missing.
        Task { @MainActor in PushModel.shared.registrationError = error.localizedDescription }
    }
}

@main
struct PushClientApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate

    var body: some Scene {
        WindowGroup { ContentView().environmentObject(PushModel.shared) }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The token screen should make the token trivially easy to get off the phone: a big monospaced label, a &lt;strong&gt;Copy&lt;/strong&gt; button (&lt;code&gt;UIPasteboard.general.string = token&lt;/code&gt;) and a &lt;strong&gt;Share&lt;/strong&gt; button (&lt;code&gt;ShareLink(item: token)&lt;/code&gt;). Show the bundle ID next to it (&lt;code&gt;Bundle.main.bundleIdentifier&lt;/code&gt;), plus a clear label for the environment this build uses, because the person on the other end will need all three.&lt;/p&gt;
&lt;p&gt;One more rule: &lt;strong&gt;treat the token as changeable.&lt;/strong&gt; The system can issue a new one, for example after a restore to a new device. Real apps send it to their server on every launch, not only the first.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-seeing-pushes-while-the-app-is-open&quot;&gt;4. Seeing pushes while the app is open&lt;/h3&gt;
&lt;p&gt;By default, if your app is in the foreground when a push arrives, iOS doesn't show a banner. For a test client that's backwards: the user is staring at the app, waiting for the push. Implement &lt;code&gt;userNotificationCenter(_:willPresent:withCompletionHandler:)&lt;/code&gt; and ask for a banner anyway. While you're there, capture the payload so you can show it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension AppDelegate {

    // Foreground delivery: show it AND log it.
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                willPresent notification: UNNotification,
                                withCompletionHandler completionHandler:
                                    @escaping (UNNotificationPresentationOptions) -&amp;gt; Void) {
        log(notification.request.content.userInfo)
        completionHandler([.banner, .list, .sound, .badge])
    }

    // The user tapped a notification (app was in background or not running).
    func userNotificationCenter(_ center: UNUserNotificationCenter,
                                didReceive response: UNNotificationResponse,
                                withCompletionHandler completionHandler: @escaping () -&amp;gt; Void) {
        log(response.notification.request.content.userInfo)
        completionHandler()
    }

    // Silent pushes (&quot;content-available&quot;: 1) arrive here, if Background Modes allows it.
    func application(_ application: UIApplication,
                     didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                     fetchCompletionHandler completionHandler:
                        @escaping (UIBackgroundFetchResult) -&amp;gt; Void) {
        log(userInfo)
        completionHandler(.newData)
    }

    private func log(_ userInfo: [AnyHashable: Any]) {
        // userInfo is the decoded JSON payload. Re-serialise it so you see exactly what arrived.
        let dict = Dictionary(uniqueKeysWithValues: userInfo.map { (&quot;\($0.key)&quot;, $0.value) })
        let data = (try? JSONSerialization.data(withJSONObject: dict,
                                                options: [.prettyPrinted, .sortedKeys])) ?? Data()
        let json = String(decoding: data, as: UTF8.self)
        Task { @MainActor in PushModel.shared.received.insert(ReceivedPush(json: json), at: 0) }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.banner&lt;/code&gt; and &lt;code&gt;.list&lt;/code&gt; are iOS 14+. They replaced the older &lt;code&gt;.alert&lt;/code&gt; option.&lt;/p&gt;
&lt;p&gt;Showing the raw JSON is the single most useful debugging feature in a test client. Half the &quot;my custom data didn't arrive&quot; bugs turn out to be a backend nesting a key inside &lt;code&gt;aps&lt;/code&gt; that belongs outside it, or the other way round.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-payload-anatomy-and-the-limits-that-bite&quot;&gt;5. Payload anatomy, and the limits that bite&lt;/h3&gt;
&lt;p&gt;A push payload is a JSON dictionary with an &lt;code&gt;aps&lt;/code&gt; key for Apple-defined fields. Your own data goes &lt;em&gt;alongside&lt;/em&gt; &lt;code&gt;aps&lt;/code&gt;, never inside it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;{
  &quot;aps&quot;: {
    &quot;alert&quot;: { &quot;title&quot;: &quot;Order shipped&quot;, &quot;body&quot;: &quot;Your parcel is on its way.&quot; },
    &quot;sound&quot;: &quot;default&quot;,
    &quot;badge&quot;: 1,
    &quot;thread-id&quot;: &quot;orders&quot;,
    &quot;interruption-level&quot;: &quot;active&quot;
  },
  &quot;orderId&quot;: &quot;A-1042&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fields I test most often:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;alert&lt;/code&gt;&lt;/strong&gt;: a string, or a dictionary with &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;subtitle&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;content-available: 1&lt;/code&gt;&lt;/strong&gt;: a background (silent) push. Apple's docs say to &lt;em&gt;omit&lt;/em&gt; &lt;code&gt;alert&lt;/code&gt;, &lt;code&gt;badge&lt;/code&gt; and &lt;code&gt;sound&lt;/code&gt; for a silent update, and to send it with &lt;code&gt;apns-push-type: background&lt;/code&gt; and &lt;code&gt;apns-priority: 5&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;mutable-content: 1&lt;/code&gt;&lt;/strong&gt;: sends the push through your Notification Service Extension (&lt;code&gt;UNNotificationServiceExtension&lt;/code&gt;, iOS 10+) before it's shown. This is how you attach images or decrypt content.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;interruption-level&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;passive&lt;/code&gt;, &lt;code&gt;active&lt;/code&gt;, &lt;code&gt;time-sensitive&lt;/code&gt; or &lt;code&gt;critical&lt;/code&gt;. Time-sensitive needs its own capability; critical needs an entitlement from Apple.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the hard limit: &lt;strong&gt;4 KB (4,096 bytes)&lt;/strong&gt; for a normal remote notification (VoIP pushes get 5 KB). Over that, APNs answers &lt;code&gt;413 PayloadTooLarge&lt;/code&gt;. Push an ID and fetch the details; don't push the whole document.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-the-other-half-what-your-server-sends&quot;&gt;6. The other half: what your server sends&lt;/h3&gt;
&lt;p&gt;Here's the request in its plainest form. APNs uses &lt;strong&gt;HTTP/2 over TLS 1.2 or later&lt;/strong&gt;, at &lt;code&gt;api.sandbox.push.apple.com&lt;/code&gt; (development) or &lt;code&gt;api.push.apple.com&lt;/code&gt; (production), on port 443 (or 2197).&lt;/p&gt;
&lt;p&gt;With a &lt;strong&gt;certificate&lt;/strong&gt; (the listing's flow: use the device token, bundle ID and certificate from the app), &lt;code&gt;curl&lt;/code&gt; is enough to prove the pipeline end to end:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;TOKEN=&amp;lt;hex device token from the app&amp;gt;
TOPIC=&amp;lt;bundle id shown in the app&amp;gt;

curl -v --http2 \
  --cert-type P12 --cert ./push-cert.p12:&amp;lt;p12-password&amp;gt; \
  -H &quot;apns-topic: ${TOPIC}&quot; \
  -H &quot;apns-push-type: alert&quot; \
  -H &quot;apns-priority: 10&quot; \
  -d '{&quot;aps&quot;:{&quot;alert&quot;:{&quot;title&quot;:&quot;Hello&quot;,&quot;body&quot;:&quot;From curl&quot;},&quot;sound&quot;:&quot;default&quot;}}' \
  &quot;https://api.sandbox.push.apple.com/3/device/${TOKEN}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With &lt;strong&gt;token-based auth&lt;/strong&gt;, you sign a short JWT with your &lt;code&gt;.p8&lt;/code&gt; key instead. Apple's docs require &lt;code&gt;ES256&lt;/code&gt;, a 10-character key ID (&lt;code&gt;kid&lt;/code&gt;) and your team ID (&lt;code&gt;iss&lt;/code&gt;). Refresh the token no more than once every 20 minutes and no less than once every 60. In Python, with &lt;code&gt;PyJWT&lt;/code&gt; and &lt;code&gt;httpx[http2]&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import time, jwt, httpx

TEAM_ID, KEY_ID = &quot;ABCDE12345&quot;, &quot;FGHIJ67890&quot;
P8 = open(&quot;AuthKey_FGHIJ67890.p8&quot;).read()

def provider_token() -&amp;gt; str:
    # Cache this and reuse it for ~30-50 minutes; don't mint one per request.
    return jwt.encode({&quot;iss&quot;: TEAM_ID, &quot;iat&quot;: int(time.time())}, P8,
                      algorithm=&quot;ES256&quot;, headers={&quot;kid&quot;: KEY_ID})

def send(device_token: str, topic: str, payload: dict, sandbox: bool = True):
    host = &quot;api.sandbox.push.apple.com&quot; if sandbox else &quot;api.push.apple.com&quot;
    with httpx.Client(http2=True) as client:
        r = client.post(f&quot;https://{host}/3/device/{device_token}&quot;, json=payload, headers={
            &quot;authorization&quot;: f&quot;bearer {provider_token()}&quot;,
            &quot;apns-topic&quot;: topic,
            &quot;apns-push-type&quot;: &quot;alert&quot;,
            &quot;apns-priority&quot;: &quot;10&quot;,
        })
    print(r.status_code, r.headers.get(&quot;apns-id&quot;), r.text)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The app's listing includes guidance and code examples for Java, Go, Python, Ruby, Erlang and Node.js. The shape is the same in every language: an HTTP/2 POST to &lt;code&gt;/3/device/&amp;lt;token&amp;gt;&lt;/code&gt;, the topic header, the push-type header, and either a client certificate or a bearer token.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-reading-apns-errors-like-a-checklist&quot;&gt;7. Reading APNs errors like a checklist&lt;/h3&gt;
&lt;p&gt;When it fails, APNs tells you why, in a JSON body with a &lt;code&gt;reason&lt;/code&gt;. These are the ones I see most, straight from Apple's response table:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Status &lt;/th&gt;&lt;th&gt;&lt;code&gt;reason&lt;/code&gt; &lt;/th&gt;&lt;th&gt;What it usually means&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;400 &lt;/td&gt;&lt;td&gt;&lt;code&gt;BadDeviceToken&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Token is malformed, or &lt;strong&gt;doesn't match the environment&lt;/strong&gt; (sandbox token sent to production, or vice versa)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;400 &lt;/td&gt;&lt;td&gt;&lt;code&gt;DeviceTokenNotForTopic&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The token belongs to a different bundle ID than your &lt;code&gt;apns-topic&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;400 &lt;/td&gt;&lt;td&gt;&lt;code&gt;TopicDisallowed&lt;/code&gt; / &lt;code&gt;BadTopic&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The topic isn't one your credential may push to&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;403 &lt;/td&gt;&lt;td&gt;&lt;code&gt;BadCertificateEnvironment&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Development certificate against the production host, or the reverse&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;403 &lt;/td&gt;&lt;td&gt;&lt;code&gt;ExpiredProviderToken&lt;/code&gt; / &lt;code&gt;InvalidProviderToken&lt;/code&gt; &lt;/td&gt;&lt;td&gt;JWT too old (over an hour), or signed with the wrong key, key ID or team&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;410 &lt;/td&gt;&lt;td&gt;&lt;code&gt;Unregistered&lt;/code&gt; / &lt;code&gt;ExpiredToken&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The token is no longer active: stop sending to it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;413 &lt;/td&gt;&lt;td&gt;&lt;code&gt;PayloadTooLarge&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Over 4 KB&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;429 &lt;/td&gt;&lt;td&gt;&lt;code&gt;TooManyRequests&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Too many pushes to the same token; back off&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Apple's docs are explicit that you shouldn't retry &lt;code&gt;BadDeviceToken&lt;/code&gt;, &lt;code&gt;DeviceTokenNotForTopic&lt;/code&gt;, &lt;code&gt;Forbidden&lt;/code&gt;, &lt;code&gt;ExpiredToken&lt;/code&gt;, &lt;code&gt;Unregistered&lt;/code&gt; or &lt;code&gt;PayloadTooLarge&lt;/code&gt;; only &lt;code&gt;TooManyRequests&lt;/code&gt; is worth retrying after a delay. And a &lt;code&gt;200&lt;/code&gt; means APNs &lt;em&gt;accepted&lt;/em&gt; the push, not that the device displayed it. That's precisely the gap a test client closes.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-push-notification-client&quot;&gt;8. Real-world example: Push Notification – Client&lt;/h3&gt;
&lt;p&gt;This is what &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-client/id1516378834&quot;&gt;Push Notification – Client&lt;/a&gt;&lt;/strong&gt; is for. The code above is the approach I'd use to build any receiver like it; I'm describing the technique rather than publishing the app's source.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/the-device-side-of-apns/push-notification-client-screenshot-1.jpg&quot; alt=&quot;Push Notification – Client App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Developer Tools&lt;/strong&gt; and &lt;strong&gt;Utilities&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;June 2020&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;58 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;4.1 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 17.6 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The listing describes it in one line: &quot;Test your server-side APNS implementation by sending push notifications to this testing app.&quot; Mapping that to the sections above:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Simply use the device token, bundle identifier, and certificate provided through the app&quot; &lt;/td&gt;&lt;td&gt;Token capture and display (section 3), the three identities (section 1), certificate-based &lt;code&gt;curl&lt;/code&gt; (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Guidelines with code examples… Java, Go, Python, Ruby, Erlang, Node.js&quot; &lt;/td&gt;&lt;td&gt;The request shape in section 6, which is identical across languages&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Perfect for backend and server-side developers… who want to test their code&quot; &lt;/td&gt;&lt;td&gt;Foreground presentation and raw-payload logging (section 4), the error table (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/the-device-side-of-apns/push-notification-client-screenshot-2.jpg&quot; alt=&quot;Push Notification – Client App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;It has a sibling on the Mac: &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-tester/id1516154713&quot;&gt;Push Notification – Tester&lt;/a&gt;&lt;/strong&gt; sends APNs pushes from your Mac using a certificate from Keychain, with no server at all. Between the two you can test both ends of the pipe. I'm writing up the Mac side, Keychain identities and client-certificate auth in &lt;code&gt;URLSession&lt;/code&gt;, as a separate article.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-ship-push-notifications-a-checklist&quot;&gt;9. Before you ship push notifications: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The capability and entitlement are present&lt;/strong&gt; in every target that needs them, and your build's &lt;code&gt;aps-environment&lt;/code&gt; matches the APNs host your server uses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The token is hex-encoded correctly&lt;/strong&gt;, sent to your server on every launch, and replaced when it changes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Foreground behaviour is deliberate.&lt;/strong&gt; &lt;code&gt;willPresent&lt;/code&gt; returns the options you actually want; don't leave it to the default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom data sits outside &lt;code&gt;aps&lt;/code&gt;&lt;/strong&gt;, and the whole payload is comfortably under 4 KB.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Silent pushes are silent.&lt;/strong&gt; &lt;code&gt;content-available: 1&lt;/code&gt;, no alert, badge or sound, &lt;code&gt;apns-push-type: background&lt;/code&gt;, priority 5. Don't rely on them for anything critical; the system may throttle them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server credentials are rotated safely.&lt;/strong&gt; JWTs are cached and refreshed every 20–60 minutes; certificates have a calendar reminder before they expire.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Errors drive cleanup.&lt;/strong&gt; On &lt;code&gt;410 Unregistered&lt;/code&gt;, delete the token. On &lt;code&gt;BadDeviceToken&lt;/code&gt;, check the environment before blaming the token.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You've tested on a real device in both environments&lt;/strong&gt;: a development build &lt;em&gt;and&lt;/em&gt; a TestFlight build.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-14&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;Push notifications are a three-party protocol, and when it breaks the problem is usually a mismatched identity, not your code. Build the device side once, properly: permission, registration, a correctly encoded token, foreground presentation and raw-payload logging. The server side then becomes a single, well-understood HTTP/2 request with a readable error table.&lt;/p&gt;
&lt;p&gt;If you'd rather skip building the receiver, the apps are free:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Push Notification – Client (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-client/id1516378834&quot;&gt;https://apps.apple.com/us/app/push-notification-client/id1516378834&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Push Notification – Tester (Mac):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-tester/id1516154713&quot;&gt;https://apps.apple.com/us/app/push-notification-tester/id1516154713&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What's the strangest APNs bug you've chased? The one I hear about most is a perfectly valid sandbox token sent to the production host. Tell me yours in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/the-device-side-of-apns/&quot;&gt;https://bestlatestapps.com/blog/the-device-side-of-apns/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32185/the-device-side-of-apns-building-a-push-notification-test-client-in-swift</guid>
<pubDate>Fri, 23 Oct 2026 12:43:45 +0000</pubDate>
</item>
<item>
<title>The Sending Side of APNs: A Mac Push Tester with Keychain Certificates and URLSession Client Auth</title>
<link>https://coderlegion.com/32175/the-sending-side-apns-mac-push-tester-with-keychain-certificates-urlsession-client-auth</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/the-sending-side-of-apns/&quot;&gt;https://bestlatestapps.com/blog/the-sending-side-of-apns/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Finding push certificates in the Keychain, importing .p12 files, answering APNs's client-certificate challenge in URLSession, building the exact request (path, headers, payload), sandbox vs production, silent pushes, and reading APNs's error responses, all without a server&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/the-sending-side-of-apns/push-notification-tester-icon-512.jpg&quot; alt=&quot;Push Notification&quot;&gt;&lt;/p&gt;
&lt;p&gt;In my article on &lt;strong&gt;Push Notification – Client&lt;/strong&gt;, I covered the device side of APNs: permission, registration, device tokens and showing what arrives. This is the other end of the pipe.&lt;/p&gt;
&lt;p&gt;When you're building push notifications, the slowest part of the loop is often the sending side. The backend isn't ready. Or it's owned by another team, or every test means deploying a change to a server just to see whether a payload renders. &lt;strong&gt;Push Notification – Tester&lt;/strong&gt; is my Mac app for skipping all of that. You choose a push certificate from Keychain, paste a device token, write a JSON payload, pick Development (sandbox) or Production, and send. There's no server dependency.&lt;/p&gt;
&lt;p&gt;This article shows how a tool like that works under the hood. It's all standard Apple API: the Security framework to find identities, &lt;code&gt;URLSession&lt;/code&gt; to authenticate with a client certificate over HTTP/2, and Apple's documented APNs request format. The protocol facts are taken from Apple's APNs documentation. Where I describe how I'd build something rather than the shipped app, I say so.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-two-ways-to-authenticate-with-apns-and-why-a-tester-uses-certificates&quot;&gt;1. Two ways to authenticate with APNs, and why a tester uses certificates&lt;/h3&gt;
&lt;p&gt;APNs accepts two kinds of provider authentication:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Token-based&lt;/strong&gt; (a &lt;code&gt;.p8&lt;/code&gt; signing key): you sign a short-lived JWT and send it in an &lt;code&gt;authorization: bearer …&lt;/code&gt; header. One key works for all your apps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Certificate-based&lt;/strong&gt; (a push certificate, often exported as &lt;code&gt;.p12&lt;/code&gt;): the TLS connection itself proves who you are, using a client certificate.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Apple's docs show both. For a desktop testing tool, certificates have a practical advantage: developers already have them in their &lt;strong&gt;Keychain&lt;/strong&gt;, and the whole authentication step happens in the TLS handshake. Nothing needs to be signed per request.&lt;/p&gt;
&lt;p&gt;One consequence, straight from Apple's documentation of the request headers: the &lt;code&gt;authorization: bearer &amp;lt;provider_token&amp;gt;&lt;/code&gt; header is for token auth only, and &quot;APNs ignores this header if you use certificate-based authentication.&quot; &lt;code&gt;apns-topic&lt;/code&gt; is still required: &quot;In general, the topic is your app's bundle ID/app ID.&quot; Keep the two modes clearly separate in your code.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-finding-push-identities-in-the-keychain&quot;&gt;2. Finding push identities in the Keychain&lt;/h3&gt;
&lt;p&gt;An &lt;strong&gt;identity&lt;/strong&gt; in Keychain terms is a certificate plus its private key, which is what TLS client auth needs. &lt;code&gt;SecItemCopyMatching&lt;/code&gt; with &lt;code&gt;kSecClassIdentity&lt;/code&gt; lists them. I'd filter to push certificates by their subject summary, which for APNs certificates names the push service and your bundle ID:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Security
import Foundation

struct PushIdentity: Identifiable, Hashable {
    let id: String              // subject summary, also used as the display name
    let identity: SecIdentity
    var isProbablyPush: Bool { id.localizedCaseInsensitiveContains(&quot;Push&quot;) }
}

enum KeychainIdentities {
    static func all() -&amp;gt; [PushIdentity] {
        let query: [String: Any] = [
            kSecClass as String: kSecClassIdentity,
            kSecReturnRef as String: true,
            kSecMatchLimit as String: kSecMatchLimitAll
        ]
        var result: CFTypeRef?
        guard SecItemCopyMatching(query as CFDictionary, &amp;amp;result) == errSecSuccess,
              let identities = result as? [SecIdentity] else { return [] }

        return identities.compactMap { identity in
            var cert: SecCertificate?
            guard SecIdentityCopyCertificate(identity, &amp;amp;cert) == errSecSuccess, let cert,
                  let summary = SecCertificateCopySubjectSummary(cert) as String? else { return nil }
            return PushIdentity(id: summary, identity: identity)
        }
        .sorted { ($0.isProbablyPush ? 0 : 1, $0.id) &amp;lt; ($1.isProbablyPush ? 0 : 1, $1.id) }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sorting push certificates to the top and keeping the others is deliberate. Names vary by certificate type and era, and a tester that hides the one certificate you need is worse than one that shows a few extras.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-importing-a-p12-file&quot;&gt;3. Importing a .p12 file&lt;/h3&gt;
&lt;p&gt;The listing mentions &quot;.p12 / .pem workflows&quot;. For a &lt;code&gt;.p12&lt;/code&gt; (PKCS #12), the Security framework does the parsing. &lt;code&gt;SecPKCS12Import&lt;/code&gt; returns the identity inside, given the export password:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum P12Import {
    enum Failure: Error { case wrongPassword, noIdentity, status(OSStatus) }

    static func identity(from data: Data, password: String) throws -&amp;gt; SecIdentity {
        let options = [kSecImportExportPassphrase as String: password] as CFDictionary
        var items: CFArray?
        let status = SecPKCS12Import(data as CFData, options, &amp;amp;items)
        switch status {
        case errSecSuccess: break
        case errSecAuthFailed: throw Failure.wrongPassword
        default: throw Failure.status(status)
        }
        guard let first = (items as? [[String: Any]])?.first,
              let identity = first[kSecImportItemIdentity as String] else { throw Failure.noIdentity }
        return identity as! SecIdentity      // CF type bridging: the dictionary value is a SecIdentity
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A clear &quot;wrong password&quot; message matters more than it looks. It's by far the most common failure when importing a &lt;code&gt;.p12&lt;/code&gt;, and a generic error code makes people think the certificate itself is broken.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;.pem&lt;/code&gt; files hold the certificate and key as text. The approach I'd recommend is to convert them to &lt;code&gt;.p12&lt;/code&gt; with &lt;code&gt;openssl pkcs12 -export&lt;/code&gt; and import that. The import path then stays the same either way.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-answering-the-client-certificate-challenge-in-urlsession&quot;&gt;4. Answering the client-certificate challenge in URLSession&lt;/h3&gt;
&lt;p&gt;When &lt;code&gt;URLSession&lt;/code&gt; connects to APNs, the server asks for a client certificate. You answer in a delegate method by building a &lt;code&gt;URLCredential&lt;/code&gt; from the identity:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Foundation

final class APNsClient: NSObject, URLSessionDelegate {
    private let identity: SecIdentity
    private lazy var session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil)

    init(identity: SecIdentity) { self.identity = identity }

    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -&amp;gt; Void) {
        switch challenge.protectionSpace.authenticationMethod {
        case NSURLAuthenticationMethodClientCertificate:
            let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession)
            completionHandler(.useCredential, credential)
        default:
            // Server trust etc.: let the system evaluate Apple's certificate normally.
            completionHandler(.performDefaultHandling, nil)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Use a session-level delegate.&lt;/strong&gt; The client-certificate challenge is a connection-level challenge, delivered to &lt;code&gt;urlSession(_:didReceive:completionHandler:)&lt;/code&gt;. There's also a task-level variant; implement the session one at minimum.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't override server trust.&lt;/strong&gt; &lt;code&gt;.performDefaultHandling&lt;/code&gt; for everything else means the system still validates Apple's server certificate. A testing tool is no excuse to disable TLS validation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;URLSession&lt;/code&gt; negotiates &lt;strong&gt;HTTP/2&lt;/strong&gt; automatically with servers that support it, and APNs requires HTTP/2 and TLS 1.2 or later. You don't need a custom HTTP/2 library.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-building-the-request-path-headers-payload&quot;&gt;5. Building the request: path, headers, payload&lt;/h3&gt;
&lt;p&gt;Apple's documentation spells out the request precisely: a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/3/device/&amp;lt;device token&amp;gt;&lt;/code&gt; on one of two hosts. I'd model it directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum APNsEnvironment: String, CaseIterable {
    case development, production
    var host: String {
        switch self {
        case .development: &quot;api.sandbox.push.apple.com&quot;
        case .production:  &quot;api.push.apple.com&quot;
        }
    }
}

enum PushType: String, CaseIterable { case alert, background }

struct PushRequest {
    var environment: APNsEnvironment
    var deviceToken: String
    var topic: String                 // usually your bundle ID
    var pushType: PushType
    var payloadJSON: String

    func urlRequest() throws -&amp;gt; URLRequest {
        let token = deviceToken.filter(\.isHexDigit).lowercased()   // tolerate pasted spaces and &amp;lt;&amp;gt;
        var request = URLRequest(url: URL(string: &quot;https://\(environment.host)/3/device/\(token)&quot;)!)
        request.httpMethod = &quot;POST&quot;
        request.setValue(topic, forHTTPHeaderField: &quot;apns-topic&quot;)
        request.setValue(pushType.rawValue, forHTTPHeaderField: &quot;apns-push-type&quot;)
        request.setValue(pushType == .background ? &quot;5&quot; : &quot;10&quot;, forHTTPHeaderField: &quot;apns-priority&quot;)
        request.setValue(UUID().uuidString.lowercased(), forHTTPHeaderField: &quot;apns-id&quot;)

        let body = Data(payloadJSON.utf8)
        _ = try JSONSerialization.jsonObject(with: body)          // fail fast on invalid JSON
        guard body.count &amp;lt;= 4096 else { throw PushError.payloadTooLarge(body.count) }
        request.httpBody = body
        return request
    }
}

enum PushError: Error { case payloadTooLarge(Int) }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each line maps to Apple's documentation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hosts.&lt;/strong&gt; &lt;code&gt;api.sandbox.push.apple.com&lt;/code&gt; is the development server and &lt;code&gt;api.push.apple.com&lt;/code&gt; is production. A device token from a debug build only works against the sandbox, which is the single most common &quot;my push never arrives&quot; cause.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;apns-push-type&lt;/code&gt;.&lt;/strong&gt; It must &quot;accurately reflect the contents of your notification's payload&quot;. It's required for watchOS and recommended for iOS, macOS, tvOS and iPadOS.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Payload size.&lt;/strong&gt; Apple's docs say it's limited to &quot;a maximum size of 4 KB (4096 bytes)&quot;, and must not be compressed. Checking it locally saves a round trip and a &lt;code&gt;413&lt;/code&gt; response.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;apns-id&lt;/code&gt;.&lt;/strong&gt; It's optional, but setting your own UUID makes it easy to match a response to a request in your log.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-alerts-badges-sounds-and-silent-pushes&quot;&gt;6. Alerts, badges, sounds and silent pushes&lt;/h3&gt;
&lt;p&gt;The listing promises &quot;Custom JSON payloads for alerts, badges, sounds, and silent content&quot;. I'd offer templates for each, since the shape of &lt;code&gt;aps&lt;/code&gt; is where most mistakes happen:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum PayloadTemplate: String, CaseIterable {
    case alert, badge, sound, silent

    var json: String {
        switch self {
        case .alert:  #&quot;{ &quot;aps&quot;: { &quot;alert&quot;: { &quot;title&quot;: &quot;Hello&quot;, &quot;body&quot;: &quot;From my Mac&quot; } } }&quot;#
        case .badge:  #&quot;{ &quot;aps&quot;: { &quot;badge&quot;: 3 } }&quot;#
        case .sound:  #&quot;{ &quot;aps&quot;: { &quot;alert&quot;: &quot;Ding&quot;, &quot;sound&quot;: &quot;default&quot; } }&quot;#
        case .silent: #&quot;{ &quot;aps&quot;: { &quot;content-available&quot;: 1 }, &quot;refresh&quot;: &quot;inbox&quot; }&quot;#
        }
    }
    var pushType: PushType { self == .silent ? .background : .alert }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The silent template follows Apple's &quot;Pushing background updates to your app&quot; guide: the &lt;code&gt;aps&lt;/code&gt; dictionary contains &lt;strong&gt;only&lt;/strong&gt; &lt;code&gt;content-available&lt;/code&gt;, with custom keys alongside it, and the request uses &lt;code&gt;apns-push-type: background&lt;/code&gt; with &lt;code&gt;apns-priority: 5&lt;/code&gt;. The same guide warns that the system treats background notifications as low priority and &quot;doesn't guarantee their delivery&quot;, and may throttle them. A tester should say that in the UI, so nobody spends an afternoon debugging a throttled silent push.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-sending-and-reading-apns-s-answer&quot;&gt;7. Sending, and reading APNs's answer&lt;/h3&gt;
&lt;p&gt;APNs replies with an HTTP status and, on failure, a small JSON body with a &lt;code&gt;reason&lt;/code&gt;. Surfacing that reason verbatim is the most useful thing a tester does:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension APNsClient {
    struct Outcome { let status: Int; let apnsID: String?; let reason: String? }

    func send(_ push: PushRequest) async throws -&amp;gt; Outcome {
        let (data, response) = try await session.data(for: try push.urlRequest())
        let http = response as! HTTPURLResponse
        let reason = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?[&quot;reason&quot;] as? String
        return Outcome(status: http.statusCode,
                       apnsID: http.value(forHTTPHeaderField: &quot;apns-id&quot;),
                       reason: reason)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And a human explanation for the common reasons, taken from Apple's &quot;Handling notification responses from APNs&quot;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Status / reason &lt;/th&gt;&lt;th&gt;What it means in practice&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;200&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Accepted by APNs. Delivery to the device is a separate step.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;400 BadDeviceToken&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The token is invalid. Usually a sandbox token sent to production, or the reverse; Apple says to check &quot;that the token matches the environment&quot;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;403 BadCertificate&lt;/code&gt; / &lt;code&gt;BadCertificateEnvironment&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The certificate is invalid, or doesn't match the server you chose.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;410 Unregistered&lt;/code&gt; / &lt;code&gt;ExpiredToken&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&quot;The device token is inactive for the specified topic&quot;, or has expired. Typically the app was deleted or the token changed; stop sending to it.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;413 PayloadTooLarge&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Over 4 KB.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;429 TooManyRequests&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Too many pushes to the same token. Apple says you can retry with a delay.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Apple's guidance is explicit that you shouldn't retry &lt;code&gt;BadDeviceToken&lt;/code&gt;, &lt;code&gt;DeviceTokenNotForTopic&lt;/code&gt;, &lt;code&gt;Forbidden&lt;/code&gt;, &lt;code&gt;ExpiredToken&lt;/code&gt;, &lt;code&gt;Unregistered&lt;/code&gt; or &lt;code&gt;PayloadTooLarge&lt;/code&gt;. A tester can turn that into a &quot;fix this first&quot; hint instead of a Retry button.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;8-real-world-example-push-notification---tester&quot;&gt;8. Real-world example: Push Notification - Tester&lt;/h3&gt;
&lt;p&gt;This is the approach behind &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-tester/id1516154713&quot;&gt;Push Notification - Tester&lt;/a&gt;&lt;/strong&gt;. The code above is written for this article, not the app's source.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/the-sending-side-of-apns/push-notification-tester-screenshot-1.jpg&quot; alt=&quot;Push Notification&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its Mac App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Developer Tools&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;June 2020&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;No ratings yet in the US store&lt;/li&gt;
&lt;li&gt;Requires &lt;strong&gt;macOS 14.6 or later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;How the listing maps to this article:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Select the push certificate directly from Keychain (.p12 / .pem workflows)&quot; &lt;/td&gt;&lt;td&gt;Keychain identities and PKCS #12 import (sections 2–3)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Send APNs pushes from your Mac with no server dependency&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;URLSession&lt;/code&gt; client-certificate auth over HTTP/2 (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Paste any device token and send in seconds&quot; &lt;/td&gt;&lt;td&gt;Token clean-up and the request path (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Target Development (sandbox) or Production environments&quot; &lt;/td&gt;&lt;td&gt;The two APNs hosts (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Custom JSON payloads for alerts, badges, sounds, and silent content&quot; &lt;/td&gt;&lt;td&gt;Payload templates and silent-push rules (section 6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Perfect for verifying device tokens, validating certificates…&quot; &lt;/td&gt;&lt;td&gt;Reading APNs's status and reason (section 7)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/the-sending-side-of-apns/push-notification-tester-screenshot-2.jpg&quot; alt=&quot;Push Notification&quot;&gt;&lt;/p&gt;
&lt;p&gt;Pair it with its iOS sibling, &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-client/id1516378834&quot;&gt;Push Notification – Client&lt;/a&gt;&lt;/strong&gt;, which shows the device token to paste and displays what arrives. Between the two, you can test both ends without a backend.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;9-before-you-trust-a-push-test-a-checklist&quot;&gt;9. Before you trust a push test: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;The environment matches the build.&lt;/strong&gt; Debug and development builds use the sandbox; TestFlight and App Store builds use production.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The topic is the bundle ID&lt;/strong&gt; the certificate was issued for.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;apns-push-type&lt;/code&gt; matches the payload&lt;/strong&gt;: &lt;code&gt;alert&lt;/code&gt; for visible pushes, &lt;code&gt;background&lt;/code&gt; (priority 5) for silent ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The payload is valid JSON, under 4,096 bytes&lt;/strong&gt;, and not compressed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server trust is never disabled.&lt;/strong&gt; Only answer the client-certificate challenge.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;reason&lt;/code&gt; string is read&lt;/strong&gt;, and non-retryable errors aren't retried.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Silent pushes aren't expected to be guaranteed.&lt;/strong&gt; Throttling is normal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Certificates expire.&lt;/strong&gt; Check the expiry date when you pick an identity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For production backends&lt;/strong&gt;, consider token-based (&lt;code&gt;.p8&lt;/code&gt;) auth. Certificates are handy for testing, but tokens don't need yearly renewal.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-15&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;Sending a push is less magic than it seems: an identity from the Keychain, a credential in a &lt;code&gt;URLSession&lt;/code&gt; delegate, and a &lt;code&gt;POST&lt;/code&gt; to &lt;code&gt;/3/device/&amp;lt;token&amp;gt;&lt;/code&gt; with four headers and a small JSON body. A good tester makes each step visible and turns APNs's terse reasons into plain advice, so the next time a push &quot;doesn't arrive&quot;, you know within a minute whether it's the token, the environment, the certificate or the payload.&lt;/p&gt;
&lt;p&gt;Try the app, or explore my other work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Push Notification - Tester (Mac):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-tester/id1516154713&quot;&gt;https://apps.apple.com/us/app/push-notification-tester/id1516154713&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Push Notification – Client (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/push-notification-client/id1516378834&quot;&gt;https://apps.apple.com/us/app/push-notification-client/id1516378834&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What's the strangest push-delivery bug you've chased? Share it in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/the-sending-side-of-apns/&quot;&gt;https://bestlatestapps.com/blog/the-sending-side-of-apns/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32175/the-sending-side-apns-mac-push-tester-with-keychain-certificates-urlsession-client-auth</guid>
<pubDate>Fri, 23 Oct 2026 12:31:01 +0000</pubDate>
</item>
<item>
<title>Tic Tac Toe in SwiftUI, Done Properly: Game State, Win Detection and Minimax AI</title>
<link>https://coderlegion.com/32174/tic-tac-toe-in-swiftui-done-properly-game-state-win-detection-and-minimax-ai</link>
<description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/tic-tac-toe-in-swiftui-done-properly/&quot;&gt;https://bestlatestapps.com/blog/tic-tac-toe-in-swiftui-done-properly/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The smallest game worth architecting: an immutable board, testable rules, a view that just renders state, and the classic AI you can bolt on in 40 lines&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/tic-tac-toe-in-swiftui-done-properly/tic-tac-toe-icon-512.jpg&quot; alt=&quot;Tic Tac Toe : Time Pass app icon&quot;&gt;&lt;/p&gt;
&lt;p&gt;Tic tac toe is the &quot;hello world&quot; of game development, which is exactly why it's worth doing properly. It's small enough to hold in your head, but it still has every piece a real game needs: state, rules, turns, an end condition, a UI that reflects state, and, if you want it, an opponent that thinks.&lt;/p&gt;
&lt;p&gt;One of my earliest App Store apps is &lt;strong&gt;Tic Tac Toe : Time Pass&lt;/strong&gt;, published in April 2020. Its listing is as short as the game: &quot;Put away your pencil and paper&quot;, with one-player and two-player modes, so you can play against another person or against yourself.&lt;/p&gt;
&lt;p&gt;This article uses tic tac toe to show an architecture I'd use for any turn-based game: a &lt;strong&gt;value-type model&lt;/strong&gt; that owns all the rules, a &lt;strong&gt;SwiftUI view&lt;/strong&gt; that only renders state and forwards taps, &lt;strong&gt;unit tests&lt;/strong&gt; for the rules, and a &lt;strong&gt;minimax opponent&lt;/strong&gt; as an optional extension. To be clear: the listing doesn't say the app has a computer opponent. The minimax section is the approach I'd add, not a description of the app. API names are checked against Apple's documentation.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-why-architecture-matters-even-here&quot;&gt;1. Why architecture matters even here&lt;/h3&gt;
&lt;p&gt;The quick version of tic tac toe puts nine &lt;code&gt;@State&lt;/code&gt; strings in a view and checks for a winner inside the button action. It works for an afternoon. Then you want an undo button, a computer player, a &quot;new game&quot; animation, a stats screen and tests, and every one of those features has to reach into the view.&lt;/p&gt;
&lt;p&gt;Instead, split it like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;Game (struct)      ← all rules: whose turn, legal moves, winner, draw
   ▲     │
   │     ▼
GameViewModel      ← owns a Game, applies moves, triggers AI, publishes state
   ▲     │
   │     ▼
GameView (SwiftUI) ← renders state, sends taps; no rules at all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The model is a plain struct with no UIKit, SwiftUI or timers, which means you can test it, copy it (undo is free), and hand a copy to an AI to explore future moves without touching the real game.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-the-model-a-board-that-can-t-be-wrong&quot;&gt;2. The model: a board that can't be wrong&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum Player: String, CaseIterable {
    case x = &quot;X&quot;, o = &quot;O&quot;
    var opponent: Player { self == .x ? .o : .x }
}

enum Outcome: Equatable {
    case inProgress
    case win(Player, line: [Int])
    case draw
}

struct Game: Equatable {
    private(set) var cells: [Player?] = Array(repeating: nil, count: 9)
    private(set) var current: Player = .x
    private(set) var moves: [Int] = []          // history, for undo and replays

    static let lines: [[Int]] = [
        [0, 1, 2], [3, 4, 5], [6, 7, 8],         // rows
        [0, 3, 6], [1, 4, 7], [2, 5, 8],         // columns
        [0, 4, 8], [2, 4, 6]                     // diagonals
    ]

    var outcome: Outcome {
        for line in Self.lines {
            if let p = cells[line[0]], cells[line[1]] == p, cells[line[2]] == p {
                return .win(p, line: line)
            }
        }
        return cells.allSatisfy { $0 != nil } ? .draw : .inProgress
    }

    var legalMoves: [Int] {
        guard outcome == .inProgress else { return [] }
        return cells.indices.filter { cells[$0] == nil }
    }

    /// Returns a new game with the move applied, or nil if the move is illegal.
    func playing(_ index: Int) -&amp;gt; Game? {
        guard legalMoves.contains(index) else { return nil }
        var next = self
        next.cells[index] = current
        next.moves.append(index)
        next.current = current.opponent
        return next
    }

    func undoing() -&amp;gt; Game {
        guard let last = moves.last else { return self }
        var previous = self
        previous.cells[last] = nil
        previous.moves.removeLast()
        previous.current = current.opponent
        return previous
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few deliberate choices:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;private(set)&lt;/code&gt; everywhere.&lt;/strong&gt; Nothing outside the model can put an X in an occupied square or skip a turn. The only way to change the game is &lt;code&gt;playing(_:)&lt;/code&gt;, and it validates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;playing(_:)&lt;/code&gt; returns a new value&lt;/strong&gt; instead of mutating. That makes the AI trivial, because it explores copies, and it makes &quot;what if?&quot; previews free.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Eight hard-coded lines beat a clever loop.&lt;/strong&gt; For a 3×3 board, a literal table is the most readable rules engine possible. (For an N×N variant, you'd generate the lines instead.)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-testing-the-rules&quot;&gt;3. Testing the rules&lt;/h3&gt;
&lt;p&gt;Because the model is pure, the tests read like the rules of the game:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import XCTest

final class GameTests: XCTestCase {

    private func play(_ moves: [Int]) -&amp;gt; Game {
        moves.reduce(Game()) { game, move in
            guard let next = game.playing(move) else {
                XCTFail(&quot;Illegal move \(move)&quot;); return game
            }
            return next
        }
    }

    func testRowWin() {
        // X: 0, 1, 2   O: 3, 4
        XCTAssertEqual(play([0, 3, 1, 4, 2]).outcome, .win(.x, line: [0, 1, 2]))
    }

    func testDiagonalWinForO() {
        // X: 0, 1, 5   O: 2, 4, 6
        XCTAssertEqual(play([0, 2, 1, 4, 5, 6]).outcome, .win(.o, line: [2, 4, 6]))
    }

    func testDraw() {
        // X O X / X O O / O X X
        XCTAssertEqual(play([0, 1, 2, 4, 3, 5, 7, 6, 8]).outcome, .draw)
    }

    func testCannotPlayOccupiedSquareOrAfterWin() {
        let game = play([0, 3, 1, 4, 2])          // X has won
        XCTAssertNil(Game().playing(0)?.playing(0))
        XCTAssertNil(game.playing(8))
    }

    func testUndoRestoresTurn() {
        let game = play([4]).undoing()
        XCTAssertEqual(game, Game())
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These run in milliseconds, with no simulator UI. When you later add an AI or a new mode, they're your safety net.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-the-view-render-state-forward-taps&quot;&gt;4. The view: render state, forward taps&lt;/h3&gt;
&lt;p&gt;The view model holds the game and the mode. The view is a grid:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import SwiftUI

@MainActor
final class GameViewModel: ObservableObject {
    enum Mode { case twoPlayer, onePlayer(computer: Player) }

    @Published private(set) var game = Game()
    @Published var mode: Mode = .twoPlayer
    @Published private(set) var isThinking = false

    func tap(_ index: Int) {
        if case .onePlayer(let computer) = mode, game.current == computer { return }
        guard let next = game.playing(index) else { return }
        game = next
        computerMoveIfNeeded()
    }

    func undo() { game = game.undoing() }
    func reset() { game = Game(); computerMoveIfNeeded() }

    private func computerMoveIfNeeded() {
        guard case .onePlayer(let computer) = mode,
              game.current == computer, game.outcome == .inProgress else { return }
        isThinking = true
        let snapshot = game
        Task {
            try? await Task.sleep(for: .milliseconds(350))   // feels less robotic
            let move = Minimax.bestMove(in: snapshot)
            if let move, let next = game.playing(move) { game = next }
            isThinking = false
        }
    }
}

struct GameView: View {
    @StateObject private var model = GameViewModel()
    private let columns = Array(repeating: GridItem(.flexible(), spacing: 8), count: 3)

    var body: some View {
        VStack(spacing: 20) {
            Text(status).font(.title2.bold())

            LazyVGrid(columns: columns, spacing: 8) {
                ForEach(0..&amp;lt;9, id: \.self) { index in
                    let mark = model.game.cells[index]
                    Button { model.tap(index) } label: {
                        Text(mark?.rawValue ?? &quot; &quot;)
                            .font(.system(size: 56, weight: .heavy, design: .rounded))
                            .frame(maxWidth: .infinity, minHeight: 96)
                            .background(background(for: index),
                                        in: RoundedRectangle(cornerRadius: 12))
                    }
                    .buttonStyle(.plain)
                    .disabled(mark != nil || model.game.outcome != .inProgress)
                    .accessibilityLabel(label(for: index, mark: mark))
                }
            }
            .padding(.horizontal)

            HStack {
                Button(&quot;Undo&quot;, action: model.undo).disabled(model.game.moves.isEmpty)
                Spacer()
                Button(&quot;New Game&quot;, action: model.reset)
            }
            .padding(.horizontal)
        }
        .animation(.easeOut(duration: 0.2), value: model.game)
        .sensoryFeedback(.selection, trigger: model.game.moves.count)   // iOS 17+
    }

    private var status: String {
        switch model.game.outcome {
        case .inProgress: return model.isThinking ? &quot;Thinking…&quot; : &quot;\(model.game.current.rawValue) to play&quot;
        case .win(let p, _): return &quot;\(p.rawValue) wins!&quot;
        case .draw: return &quot;It's a draw&quot;
        }
    }

    private func background(for index: Int) -&amp;gt; Color {
        if case .win(_, let line) = model.game.outcome, line.contains(index) {
            return .green.opacity(0.3)
        }
        return Color(.secondarySystemBackground)
    }

    private func label(for index: Int, mark: Player?) -&amp;gt; String {
        let row = index / 3 + 1, column = index % 3 + 1
        return &quot;Row \(row), column \(column), \(mark.map { $0.rawValue } ?? &quot;empty&quot;)&quot;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice what the view &lt;em&gt;doesn't&lt;/em&gt; do: it never decides who won or whether a move is legal. Even the disabled state just reads the model.&lt;/p&gt;
&lt;p&gt;Version notes, since the app supports iOS 12: &lt;code&gt;LazyVGrid&lt;/code&gt; is iOS 14+, &lt;code&gt;.sensoryFeedback&lt;/code&gt; is iOS 17+, and &lt;code&gt;Task.sleep(for:)&lt;/code&gt; is iOS 16+. On an older target, use nested &lt;code&gt;HStack&lt;/code&gt;s, &lt;code&gt;UIImpactFeedbackGenerator&lt;/code&gt; and &lt;code&gt;Task.sleep(nanoseconds:)&lt;/code&gt;, or build the same view model behind a UIKit grid. SwiftUI itself starts at iOS 13, so a genuinely iOS 12-compatible app is UIKit. The architecture doesn't change; only the view layer does.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-adding-a-computer-opponent-minimax&quot;&gt;5. Adding a computer opponent: minimax&lt;/h3&gt;
&lt;p&gt;Tic tac toe is small enough to search &lt;em&gt;completely&lt;/em&gt;. Minimax explores every possible continuation and assumes both players play perfectly: the computer picks the move with the best guaranteed outcome, and assumes you'll pick the one that's worst for it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum Minimax {
    /// Best move for `game.current`, or nil if the game is over.
    static func bestMove(in game: Game) -&amp;gt; Int? {
        let me = game.current
        var best: (move: Int, score: Int)?
        for move in game.legalMoves {
            guard let next = game.playing(move) else { continue }
            let score = minimax(next, maximizing: false, me: me, depth: 1,
                                alpha: Int.min, beta: Int.max)
            if best == nil || score &amp;gt; best!.score { best = (move, score) }
        }
        return best?.move
    }

    private static func minimax(_ game: Game, maximizing: Bool, me: Player,
                                depth: Int, alpha: Int, beta: Int) -&amp;gt; Int {
        switch game.outcome {
        case .win(let p, _): return p == me ? 10 - depth : depth - 10   // win sooner, lose later
        case .draw: return 0
        case .inProgress: break
        }
        var alpha = alpha, beta = beta
        var best = maximizing ? Int.min : Int.max
        for move in game.legalMoves {
            guard let next = game.playing(move) else { continue }
            let score = minimax(next, maximizing: !maximizing, me: me,
                                depth: depth + 1, alpha: alpha, beta: beta)
            if maximizing {
                best = max(best, score); alpha = max(alpha, best)
            } else {
                best = min(best, score); beta = min(beta, best)
            }
            if beta &amp;lt;= alpha { break }      // alpha-beta pruning: this branch can't matter
        }
        return best
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details make this feel right:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Depth in the score.&lt;/strong&gt; &lt;code&gt;10 - depth&lt;/code&gt; makes the AI prefer winning &lt;em&gt;now&lt;/em&gt; over winning later, and losing later over losing now. Without it, a perfect AI sometimes &quot;plays with its food&quot; and looks broken.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alpha-beta pruning&lt;/strong&gt; skips branches that can't change the result. The full game tree is only a few hundred thousand positions, so the AI is instant either way, but pruning is a habit worth building for bigger games.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;making-it-beatable&quot;&gt;Making it beatable&lt;/h4&gt;
&lt;p&gt;A perfect tic tac toe opponent never loses, which makes it boring quickly. A simple difficulty dial:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;extension Minimax {
    /// skill 0.0 = random, 1.0 = perfect
    static func move(in game: Game, skill: Double) -&amp;gt; Int? {
        if Double.random(in: 0..&amp;lt;1) &amp;gt; skill { return game.legalMoves.randomElement() }
        return bestMove(in: game)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&quot;Easy&quot; around 0.3, &quot;medium&quot; around 0.7 and &quot;hard&quot; at 1.0 gives three genuinely different games from one function.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-real-world-example-tic-tac-toe-time-pass&quot;&gt;6. Real-world example: Tic Tac Toe : Time Pass&lt;/h3&gt;
&lt;p&gt;The patterns above are how I'd structure &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/tic-tac-toe-time-pass/id1509555889&quot;&gt;Tic Tac Toe : Time Pass&lt;/a&gt;&lt;/strong&gt; today, or any small turn-based game.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/tic-tac-toe-in-swiftui-done-properly/tic-tac-toe-screenshot-1.jpg&quot; alt=&quot;Tic Tac Toe App Store screenshot 1&quot;&gt;&lt;/p&gt;
&lt;p&gt;At the time of writing, its App Store listing shows:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free&lt;/strong&gt;, in &lt;strong&gt;Games&lt;/strong&gt; (Strategy, Casual) and &lt;strong&gt;Entertainment&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;First released in &lt;strong&gt;April 2020&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;13 ratings&lt;/strong&gt; in the US store, averaging about &lt;strong&gt;3.9 stars&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;iOS 12.0 and later&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The listing says: &quot;Tic Tac Toe supports one player and two player gameplay, so you can play against another human or against yourself.&quot;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What the listing says &lt;/th&gt;&lt;th&gt;Where it's covered&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&quot;Put away your pencil and paper&quot; &lt;/td&gt;&lt;td&gt;A value-type &lt;code&gt;Game&lt;/code&gt; model that enforces the rules (section 2)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;Two player gameplay… against another human&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;.twoPlayer&lt;/code&gt; mode: one device, alternating turns (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&quot;One player… against yourself&quot; &lt;/td&gt;&lt;td&gt;&lt;code&gt;.onePlayer&lt;/code&gt; mode; minimax is the extension I'd add for a computer opponent (section 5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;iOS 12.0 and later &lt;/td&gt;&lt;td&gt;UIKit is required below iOS 13; the model is identical either way (section 4)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://bestlatestapps.com/assets/blog/tic-tac-toe-in-swiftui-done-properly/tic-tac-toe-screenshot-2.jpg&quot; alt=&quot;Tic Tac Toe App Store screenshot 2&quot;&gt;&lt;/p&gt;
&lt;p&gt;A 3.9 average on a game this simple tells me something too. Casual players expect polish even from &quot;time pass&quot; games: clear win highlighting, a satisfying tap, an easy rematch. The architecture above is what makes that polish cheap to add.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;7-before-you-ship-a-small-game-a-checklist&quot;&gt;7. Before you ship a small game: a checklist&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Rules live in one pure model&lt;/strong&gt;, with no UI framework imports.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Illegal states are impossible&lt;/strong&gt;: &lt;code&gt;private(set)&lt;/code&gt; state, and validated moves that return new values.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Every rule has a test&lt;/strong&gt;: each kind of win, a draw, illegal moves, moves after a win, and undo.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The view only renders and forwards.&lt;/strong&gt; If a view contains &lt;code&gt;if winner…&lt;/code&gt; logic, move it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The AI works on copies&lt;/strong&gt; and never on the live game. It's tested too: it should never lose from any position where a draw is possible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Difficulty is a dial, not three AIs.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accessibility:&lt;/strong&gt; every cell has a spoken label with row, column and contents, and the result is announced.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rematch is one tap.&lt;/strong&gt; The fastest way to a better rating in a casual game.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wrapping-up-16&quot;&gt;Wrapping up&lt;/h3&gt;
&lt;p&gt;Tic tac toe is small, but the lessons scale. Put the rules in a value type that can't be put into an illegal state, test it like a spec, keep the view dumb, and treat the AI as just another consumer of the model, one that explores copies. The same shape works for chess, card games and anything else turn-based.&lt;/p&gt;
&lt;p&gt;Play it, or explore my other apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tic Tac Toe : Time Pass (iOS):&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/app/tic-tac-toe-time-pass/id1509555889&quot;&gt;https://apps.apple.com/us/app/tic-tac-toe-time-pass/id1509555889&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;All of my free iOS apps:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/us/developer/alok-singh/id1508795536&quot;&gt;https://apps.apple.com/us/developer/alok-singh/id1508795536&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;My portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/alok/&quot;&gt;https://bestlatestapps.com/alok/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What's your favourite &quot;tiny game, big lesson&quot; project? Tell me in the comments.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;— Alok Kumar Singh&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://bestlatestapps.com/blog/tic-tac-toe-in-swiftui-done-properly/&quot;&gt;https://bestlatestapps.com/blog/tic-tac-toe-in-swiftui-done-properly/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32174/tic-tac-toe-in-swiftui-done-properly-game-state-win-detection-and-minimax-ai</guid>
<pubDate>Fri, 23 Oct 2026 12:02:18 +0000</pubDate>
</item>
<item>
<title>Two native apps into one React Native codebase in six months</title>
<link>https://coderlegion.com/30734/two-native-apps-into-one-react-native-codebase-in-six-months</link>
<description>&lt;p&gt;I owned mobile for Pulse, an offline-first inspection app for auditors, on Android and iOS, from an early prototype to 100+ B2B enterprise clients. For most of that time it was two native apps: Kotlin on Android, Swift on iOS, kept in feature parity. In 2025 I led a full rewrite of both into a single React Native codebase. I architected it and built it alongside the team, and we completed it in six months, before I left in May 2025.&lt;/p&gt;
&lt;p&gt;This isn’t a “native versus React Native” post. The native apps worked. The earlier posts onPulse’s sync engine and ona local-only app cover why they were built the way they were. This one is about the rewrite: why we did it, what we kept, and how it reached users.&lt;/p&gt;
&lt;p&gt;Why rewrite two apps that worked&lt;br&gt;
Keeping two native apps in feature parity has a simple cost: every feature is built twice, once in Kotlin and once in Swift, and every fix has to land in both. That was the reason for the rewrite, in two parts:&lt;/p&gt;
&lt;p&gt;One codebase to maintain instead of two.&lt;br&gt;
Shipping faster , because a feature or a fix reaches Android and iOS at the same time.&lt;br&gt;
Neither reason says the native apps were bad. They say that keeping two of them had a cost, and one codebase removes it.&lt;/p&gt;
&lt;p&gt;Replicate the behaviour, don’t redesign it&lt;br&gt;
The native apps’ behaviour had years of field use behind it: records that get a temporary local ID and are remapped to a server ID when they sync, photos uploaded before the form that references them, upload progress the auditor can see, and inspection forms built dynamically. So the React Native app replicated that behaviour rather than redesigning it.&lt;/p&gt;
&lt;p&gt;The alternative is harder than it looks. A rewrite that also changes how sync works is two projects at once, and every difference between the old app and the new one is something to explain to an auditor in the field. Replicating proven behaviour keeps the rewrite to one question: does the new app do what the old one did?&lt;/p&gt;
&lt;p&gt;I built the core, the team built on top&lt;br&gt;
I set the architecture and built the core myself: sync, offline storage and uploads. The team built the screens and features on top of it.&lt;/p&gt;
&lt;p&gt;In an offline-first app, that core is where the risk sits. Everything on screen depends on the core behaving the way the native apps did, and in an offline-first app the core is where data gets lost if something is wrong. Screens are easier to see, test and fix; a sync bug can hide until an auditor’s work goes missing.&lt;/p&gt;
&lt;p&gt;Rolling out gradually&lt;br&gt;
The new app didn’t replace the native ones for everyone at once. It rolled out gradually, with the native app still available while users moved over.&lt;/p&gt;
&lt;p&gt;For an offline-first app that isn’t caution for its own sake. Auditors work without signal, so at any moment someone may have work on the phone that hasn’t reached the server yet. A gradual rollout means a problem in the new app reaches a few users rather than all of them, and there is still a working app to fall back on while it’s fixed.&lt;/p&gt;
&lt;p&gt;What I’d tell another team&lt;br&gt;
If you’re rewriting an offline-first app, roll it out gradually. Keep the old app available and move users over in stages. A big-bang switch bets every auditor’s unsynced work on the new app being right on day one.&lt;/p&gt;
&lt;p&gt;More on Pulse is in the case study.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30734/two-native-apps-into-one-react-native-codebase-in-six-months</guid>
<pubDate>Sun, 11 Oct 2026 18:35:56 +0000</pubDate>
</item>
<item>
<title>The bug that survived being recycled: automaticallyAdjustKeyboardInsets and Fabric view recycling on iOS</title>
<link>https://coderlegion.com/30246/that-survived-being-recycled-automaticallyadjustkeyboardinsets-fabric-view-recycling</link>
<description>&lt;p&gt;After logging a meal in &lt;a rel=&quot;nofollow&quot; href=&quot;https://pragathijayaram.com/portfolio/&quot;&gt;Anovi&lt;/a&gt;, the nutrition app I build, the next screen opened normally. The content was there. Then the first scroll ran past the end of it, into empty space that should not have existed, and stopped.&lt;/p&gt;
&lt;p&gt;Dragging in that space did nothing. Not a slow scroll, not a bounce. Nothing moved, because there was nothing under my finger. The content was somewhere above me and there was no way to reach it. The only way out was to kill the app.&lt;/p&gt;
&lt;p&gt;It was not every screen and not every time, which is what kept it alive through earlier rounds of testing. It produced no crash, no error, and no failed request.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;In short.&lt;/strong&gt; On iOS with the New Architecture, a scroll view that opted into keyboard inset handling can leave that behaviour switched on when React Native recycles it. The next screen to inherit that view gets a phantom inset it never asked for. Filed as &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/facebook/react-native/issues/57755&quot;&gt;react-native#57755&lt;/a&gt;. &lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;&lt;p&gt;If you just want to check and fix your own app, that is &lt;a rel=&quot;nofollow&quot; href=&quot;https://pragathijayaram.com/articles/react-native-keyboard-insets-leaking/&quot;&gt;a separate, shorter piece&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;the-evidence-that-was-missing&quot;&gt;The evidence that was missing&lt;/h3&gt;
&lt;p&gt;Every request in the sequence returned what it should, in the time it should. Nothing failed, nothing timed out, nothing appeared in the logs at the moment things went wrong. The app had not crashed and was not in an error state.&lt;/p&gt;
&lt;p&gt;Healthy logs are usually read as the absence of a finding. Here they were the finding, because they eliminated the entire category I had been searching in. That is what promoted the next detail from background noise to the only lead.&lt;/p&gt;
&lt;p&gt;The next detail was the keyboard. On the broken screens it appeared, briefly, and went away again. On a screen with no text input on it and nothing focused.&lt;/p&gt;
&lt;p&gt;A keyboard reacting where there is nothing to type into is not a rendering bug. Something was still listening.&lt;/p&gt;
&lt;h3 id=&quot;the-screen-that-broke-was-not-the-screen-at-fault&quot;&gt;The screen that broke was not the screen at fault&lt;/h3&gt;
&lt;p&gt;React Native's new architecture recycles native views. Rather than destroying a component's native view and building a fresh one, it resets the existing view and hands it to the next component that needs the same kind. On iOS, that reset happens in a method called &lt;code&gt;prepareForRecycle&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Recycling is only safe if the reset is complete. Anything left behind belongs to whoever inherits the view next.&lt;/p&gt;
&lt;p&gt;A scroll view can opt into having iOS adjust its insets when the keyboard moves, with a prop called &lt;code&gt;automaticallyAdjustKeyboardInsets&lt;/code&gt;. Underneath, that sets a flag on the native view and gates a keyboard observer that lives for as long as the view does. &lt;code&gt;prepareForRecycle&lt;/code&gt; does not clear that flag.&lt;/p&gt;
&lt;p&gt;So the broken screen was innocent. It had no text input, no keyboard props, and nothing to do with any of this. It had simply been handed a view that came out of the pool still holding someone else's settings.&lt;/p&gt;
&lt;h4 id=&quot;where-the-keyboard-came-from&quot;&gt;Where the keyboard came from&lt;/h4&gt;
&lt;p&gt;The view came from the meal logging modal, which does have a scroll view with the prop set and a search field that focuses itself.&lt;/p&gt;
&lt;p&gt;The interesting part is what happened when I closed it. Tapping Done dismissed the keyboard, reset the modal's internal flow back to its search step, and hid the modal, all in the same render batch. Resetting the flow remounted the search field, its &lt;code&gt;autoFocus&lt;/code&gt; fired, and &lt;strong&gt;the app opened a keyboard inside a modal that was already closing.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That is the flicker. Not a leftover from typing, but a keyboard my own code summoned into a dying screen. Its scroll view then went into the recycle pool while that keyboard was still up.&lt;/p&gt;
&lt;p&gt;None of that is a bug. A keyboard being up while a screen goes away is ordinary, and the framework is meant to survive it. What it did instead was hand the next screen a view that was still holding the last one's settings.&lt;/p&gt;
&lt;h3 id=&quot;building-something-that-fails-on-demand&quot;&gt;Building something that fails on demand&lt;/h3&gt;
&lt;p&gt;An explanation that only reproduces inside a nutrition app is not much use to anyone, and it is not something a framework maintainer can act on. So the next step was the smallest thing that still fails: a modal with the prop and a focused input that kills itself mid-animation, then a plain scroll view that mounts straight afterwards and reports its own geometry.&lt;/p&gt;
&lt;h4 id=&quot;the-detector-that-cried-wolf&quot;&gt;The detector that cried wolf&lt;/h4&gt;
&lt;p&gt;Getting the measurement right took longer than finding the bug.&lt;/p&gt;
&lt;p&gt;The first version reported failures that were not failures. A scroll view settling after a bounce moves on its own, and sampling at the wrong moment records that jitter as displacement. Several iterations went into separating &quot;this view is still settling&quot; from &quot;this view has been moved by something else&quot;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A detector that reports a bug which is not there is worse than no detector, because it makes the real signal unfalsifiable.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Everything downstream of it, including the controls, would have been noise interpreted as evidence.&lt;/p&gt;
&lt;p&gt;Once it was honest, the numbers were unambiguous. A scroll view that never asked for keyboard insets, with the keyboard hidden, reporting a bottom inset of 217.3 points. Its content was 840 points tall in a 234 point window, so the furthest it should ever scroll is 606. It went to 823.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pragathijayaram.com/media/react-native-scrollview-recycling-bug/harness-pool-dirty.png&quot; alt=&quot;The reproducer on React Native 0.86.2 showing a victim scroll view reporting a bottom inset of 217.3 with the keyboard hidden, content 840 and viewport 234, alongside counters for cycles, rogue keyboard events and corrupt readings.&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Physical iPhone, iOS 26.5.2, Release build, React Native 0.86.2, New Architecture. The victim sets no keyboard props of any kind.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-controls-are-the-argument&quot;&gt;The controls are the argument&lt;/h3&gt;
&lt;p&gt;A reproducer that fails is a claim. A reproducer that fails, and stops failing when you remove exactly one ingredient, is evidence.&lt;/p&gt;
&lt;p&gt;Two controls, each from a freshly killed process. Turn off the inset prop: nothing. Dismiss the keyboard and let it settle before the modal closes: nothing. Each ingredient is necessary and neither is sufficient. The failure needs a view configured for keyboard insets to be recycled while its observer is still live.&lt;/p&gt;
&lt;h3 id=&quot;measuring-it-instead-of-reasoning-about-it&quot;&gt;Measuring it instead of reasoning about it&lt;/h3&gt;
&lt;p&gt;Everything so far was inference, from reading source and watching behaviour. The mechanism deserved a measurement, so I instrumented the framework itself: every keyboard delivery logged with the view's address and whether it was on screen or in the recycle pool.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;rec-in  view=0x108118c00 ivar=1          entering prepareForRecycle
rec-out view=0x108118c00 ivar=1          leaving it, flag still set
kbd     view=0x108118c00 ivar=1 POOLED
write   view=0x108118c00 bottom=217.3 POOLED    ‚Üê inset written while pooled
props   view=0x108118c00 old=1 new=0 ivar=0     ‚Üê next screen mounts, flag cleared
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The write lands &lt;strong&gt;while the view is sitting in the pool&lt;/strong&gt;, 105ms before anything mounts on it. When a screen does mount, the flag is cleared immediately, so the screen you can see is never written to at all.&lt;/p&gt;
&lt;p&gt;That ruled out the explanation I had been carrying. An earlier test, where a poisoned view's scroll position moved after I focused an unrelated text field, had looked like proof that the recycled view was still listening while mounted. It was not. The keyboard changed the layout, the window grew from 234 points to 299, the furthest legal scroll position dropped by 65, and iOS pulled the offset back to the new limit. Arithmetic, not a live listener.&lt;/p&gt;
&lt;h3 id=&quot;what-is-actually-happening&quot;&gt;What is actually happening&lt;/h3&gt;
&lt;p&gt;The pooled view keeps receiving keyboard notifications and keeps writing insets onto itself. It is not stale; it is current. It tracks the keyboard continuously, taking a real value when a keyboard rises and zero when it falls.&lt;/p&gt;
&lt;p&gt;Whatever value it happens to be holding at the moment someone inherits it is what that screen is stuck with, because the inset is only reassigned when a component's props change it, and a component that never set the prop has nothing to reassign.&lt;/p&gt;
&lt;p&gt;Which means the whole thing turns on timing. Sweeping the delay before the modal tears itself down, ten cycles each from a fresh process:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;modal torn down after &lt;/th&gt;&lt;th&gt;corrupt readings &lt;/th&gt;&lt;th&gt;phantom inset&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;~120ms &lt;/td&gt;&lt;td&gt;36 &lt;/td&gt;&lt;td&gt;217.3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~220ms &lt;/td&gt;&lt;td&gt;0 &lt;/td&gt;&lt;td&gt;0.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~320ms &lt;/td&gt;&lt;td&gt;0 &lt;/td&gt;&lt;td&gt;0.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~520ms &lt;/td&gt;&lt;td&gt;0 &lt;/td&gt;&lt;td&gt;0.0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;keyboard dismissed first &lt;/td&gt;&lt;td&gt;0 &lt;/td&gt;&lt;td&gt;0.0&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Only the earliest teardown reproduces. Wait longer and the keyboard's own disappearance reaches the pooled view first, zeroes the inset, and the pool quietly cleans itself. The bug needs a screen to mount during the narrow window while a keyboard is up.&lt;/p&gt;
&lt;p&gt;That is also why it was intermittent in production, and why it survived earlier testing rounds. Most of the time nothing mounts inside that window.&lt;/p&gt;
&lt;h4 id=&quot;why-it-could-not-be-scrolled-back&quot;&gt;Why it could not be scrolled back&lt;/h4&gt;
&lt;p&gt;The phantom inset extends the scrollable range past the end of the content, and that extra region has no view underneath it. So it takes no touches. A drag there produces no scroll events at all.&lt;/p&gt;
&lt;p&gt;In the reproducer a sliver of the last row stays visible, so you can grab that and pull yourself back. In the app the scroll views were full screen. Once the content had left the viewport there was nothing to grab, and no gesture in the app could recover it.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A screen recording of the reproducer on a physical device is on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://pragathijayaram.com/articles/react-native-scrollview-recycling-bug/&quot;&gt;original article&lt;/a&gt;: several poison cycles, a scroll up into the space that should not exist, then taps on an unrelated input.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That is the difference between a layout glitch and a screen that is finished until you kill the process.&lt;/p&gt;
&lt;h3 id=&quot;what-happened-after-filing&quot;&gt;What happened after filing&lt;/h3&gt;
&lt;p&gt;I reproduced it unchanged on React Native 0.86.2, the latest release at the time, and filed it as &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/facebook/react-native/issues/57755&quot;&gt;react-native#57755&lt;/a&gt; with a &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/PragathiJ/rn-aaki-recycle-repro&quot;&gt;public reproducer&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Two pull requests followed, from people I have never met. The second carries a regression test covering the whole lifecycle, and a Meta engineer imported it for internal review the same morning it appeared.&lt;/p&gt;
&lt;p&gt;Its two lines are small: clear the flag when the view is recycled, and stop guarding the assignment behind a props comparison so a recycled view can be re-armed properly. I applied them to the reproducer and ran it on the phone that found the bug in the first place. Baseline immediately before: 36 corrupt readings in 10 cycles. With the patch: 49 cycles, zero.&lt;/p&gt;
&lt;h3 id=&quot;what-i-would-take-from-this&quot;&gt;What I would take from this&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The healthy logs were the finding.&lt;/strong&gt; They eliminated a whole category, which is what turned a flicker into the only lead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Controls before claims.&lt;/strong&gt; Any bug can be explained by a plausible story. The question is which single ingredient you can remove to make it stop, and whether you have actually tried removing it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A detector that lies is worse than no detector.&lt;/strong&gt; Most of the reproducer work went into eliminating false positives, not into finding the failure.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Instrument the explanation, not just the bug.&lt;/strong&gt; A story that fits every observation is not the same as a correct one. Mine was in the right place and the wrong moment, and only logging the framework showed the difference.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If you think your own app has this, the checks and the three ways to fix it are in &lt;a rel=&quot;nofollow&quot; href=&quot;https://pragathijayaram.com/articles/react-native-keyboard-insets-leaking/&quot;&gt;a shorter companion piece&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;The reproducer is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/PragathiJ/rn-aaki-recycle-repro&quot;&gt;github.com/PragathiJ/rn-aaki-recycle-repro&lt;/a&gt;. &lt;/p&gt;
&lt;p&gt;I write about production engineering and applied AI at &lt;a rel=&quot;nofollow&quot; href=&quot;https://pragathijayaram.com/articles/&quot;&gt;pragathijayaram.com&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30246/that-survived-being-recycled-automaticallyadjustkeyboardinsets-fabric-view-recycling</guid>
<pubDate>Thu, 08 Oct 2026 09:12:17 +0000</pubDate>
</item>
<item>
<title>automaticallyAdjustKeyboardInsets is leaking into your other screens: how to check and fix it</title>
<link>https://coderlegion.com/30241/automaticallyadjustkeyboardinsets-is-leaking-into-your-other-screens-how-check-and-fix</link>
<description>&lt;p&gt;A screen opens normally, then scrolls past the end of its content into empty&lt;br&gt;
space, and dragging in that space does nothing at all. Restarting the app is the&lt;br&gt;
only way out.&lt;/p&gt;
&lt;p&gt;If that sounds familiar, this is a React Native framework defect, not your code.&lt;br&gt;
This page is the operational version: how to confirm it, how to find every source&lt;br&gt;
in your project, and what to do. The investigation behind it, including why it&lt;br&gt;
took a while to understand, &lt;a rel=&quot;nofollow&quot; href=&quot;https://pragathijayaram.com/articles/react-native-scrollview-recycling-bug/&quot;&gt;is here&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Scope.&lt;/strong&gt; iOS only, New Architecture only. The code involved is the Fabric iOS&lt;br&gt;
scroll view, so the old architecture and Android take different paths.&lt;br&gt;
Reproduced on React Native 0.81.4 and 0.86.2. Filed as&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/facebook/react-native/issues/57755&quot;&gt;react-native#57755&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;what-is-going-wrong&quot;&gt;What is going wrong&lt;/h3&gt;
&lt;p&gt;A scroll view that sets &lt;code&gt;automaticallyAdjustKeyboardInsets&lt;/code&gt; keeps that behaviour&lt;br&gt;
switched on when React Native recycles its native view. While the view sits in the&lt;br&gt;
recycle pool it carries on receiving keyboard notifications and writing insets&lt;br&gt;
onto itself. The next component to be handed that view inherits whatever value was&lt;br&gt;
there, and nothing puts it back, because a component that never set the prop has&lt;br&gt;
no inset of its own to assign.&lt;/p&gt;
&lt;p&gt;The result is a scroll view with extra scrollable space below its content. That&lt;br&gt;
space has no view underneath it, so it takes no touches, which is why you cannot&lt;br&gt;
drag your way back out of it.&lt;/p&gt;
&lt;h3 id=&quot;confirm-it&quot;&gt;Confirm it&lt;/h3&gt;
&lt;p&gt;The first sign is a keyboard appearing on a screen with nothing focused. That is&lt;br&gt;
the tell, not the bug.&lt;/p&gt;
&lt;p&gt;The test is to read the inset on a scroll view that should not have one. Put this&lt;br&gt;
on a screen that sets no insets of its own, scroll it with the keyboard hidden,&lt;br&gt;
and watch the console:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;const reported = React.useRef(false);

&amp;lt;ScrollView
  scrollEventThrottle={16}
  onScroll={(e) =&amp;gt; {
    // contentInset is iOS-only; on Android nativeEvent has no such field.
    const bottom = e.nativeEvent.contentInset?.bottom ?? 0;
    if (bottom &amp;gt; 0 &amp;amp;&amp;amp; !reported.current) {
      reported.current = true;
      console.warn('phantom bottom inset', bottom);
    }
  }}
&amp;gt;
  &amp;lt;View style={{ height: 2000 }} /&amp;gt;
&amp;lt;/ScrollView&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details decide whether this test is worth anything. The scroll view needs&lt;br&gt;
content taller than itself, or it never scrolls, &lt;code&gt;onScroll&lt;/code&gt; never fires, and you&lt;br&gt;
will conclude you are clean without having tested anything. And the reading has to&lt;br&gt;
be gated, because at a 16ms throttle an ungated &lt;code&gt;console.warn&lt;/code&gt; buries your log.&lt;/p&gt;
&lt;p&gt;A scroll view that never opted in should report zero. Anything else means it is&lt;br&gt;
sitting on a view that came out of the pool still armed.&lt;/p&gt;
&lt;h3 id=&quot;find-every-source&quot;&gt;Find every source&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -rn &quot;automaticallyAdjustKeyboardInsets&quot; src/
grep -rn &quot;contentInset&quot; src/   # also catches contentInsetAdjustmentBehavior
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second search matters more than it looks. Any prop that writes native insets&lt;br&gt;
belongs to the native view, and therefore to the recycling pool, so a static&lt;br&gt;
&lt;code&gt;contentInset&lt;/code&gt; combined with an automatic &lt;code&gt;contentInsetAdjustmentBehavior&lt;/code&gt; is the&lt;br&gt;
same class of risk even though it has nothing to do with keyboards. In my own&lt;br&gt;
sweep that turned up a grid component the first search had missed.&lt;/p&gt;
&lt;p&gt;Then run both again against &lt;code&gt;node_modules&lt;/code&gt;. The pool is per process, not per&lt;br&gt;
module, so a scroll view inside a dependency arms it exactly as well as one of&lt;br&gt;
yours. You can clean your own code completely and still be handed a poisoned view by a library you did not write.&lt;/p&gt;
&lt;h3 id=&quot;fix-it&quot;&gt;Fix it&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Option 1, remove the prop.&lt;/strong&gt; Handle keyboard layout explicitly instead. This is&lt;br&gt;
what I shipped.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// Before: the flag does the layout, and the native view keeps listening
&amp;lt;Modal visible={visible}&amp;gt;
  &amp;lt;ScrollView automaticallyAdjustKeyboardInsets&amp;gt;
    &amp;lt;TextInput autoFocus /&amp;gt;
  &amp;lt;/ScrollView&amp;gt;
&amp;lt;/Modal&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-tsx&quot;&gt;// After: explicit layout, nothing left armed on the native view
&amp;lt;Modal visible={visible}&amp;gt;
  &amp;lt;KeyboardAvoidingView behavior=&quot;padding&quot; style={{ flex: 1 }}&amp;gt;
    &amp;lt;ScrollView contentContainerStyle={{ paddingBottom: 24 }}&amp;gt;
      &amp;lt;TextInput autoFocus={visible} /&amp;gt;
    &amp;lt;/ScrollView&amp;gt;
  &amp;lt;/KeyboardAvoidingView&amp;gt;
&amp;lt;/Modal&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;autoFocus={visible}&lt;/code&gt; change matters more than it looks. Focusing on mount is&lt;br&gt;
what starts a keyboard animation during modal presentation, which is exactly the&lt;br&gt;
window the defect needs. Watch for the same shape anywhere a modal resets its&lt;br&gt;
internal state as it closes: that can remount a focused field and open a keyboard&lt;br&gt;
inside a screen that is already going away.&lt;/p&gt;
&lt;p&gt;One caveat: &lt;code&gt;KeyboardAvoidingView&lt;/code&gt; with &lt;code&gt;behavior=&quot;padding&quot;&lt;/code&gt; inside a &lt;code&gt;Modal&lt;/code&gt;&lt;br&gt;
behaves differently depending on presentation style and often wants a&lt;br&gt;
&lt;code&gt;keyboardVerticalOffset&lt;/code&gt;. Treat it as a shape to adapt, not a drop-in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option 2, keep the prop but dismiss the keyboard first.&lt;/strong&gt; Dismiss it and let it&lt;br&gt;
settle before the screen unmounts. This came back at zero in the reproducer. It is&lt;br&gt;
narrower than option 1, because it depends on every exit path from that screen&lt;br&gt;
doing the same thing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option 3, patch the framework locally.&lt;/strong&gt; Until the upstream fix ships you can&lt;br&gt;
carry it as a &lt;code&gt;patch-package&lt;/code&gt; patch. In &lt;code&gt;RCTScrollViewComponentView.mm&lt;/code&gt;, clear the&lt;br&gt;
flag on recycle and assign it on every update instead of only when it changes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-objc&quot;&gt;// prepareForRecycle: disarm before the view goes back in the pool
_automaticallyAdjustKeyboardInsets = NO;

// updateProps: drop the old-vs-new condition around the assignment
_automaticallyAdjustKeyboardInsets = newScrollViewProps.automaticallyAdjustKeyboardInsets;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both lines are needed. The first stops the leak. The second matters because&lt;br&gt;
&lt;code&gt;prepareForRecycle&lt;/code&gt; does not reset the stored props, so once the flag is cleared a&lt;br&gt;
view being reused by another component that &lt;em&gt;also&lt;/em&gt; wants keyboard insets would&lt;br&gt;
skip the assignment and silently lose the behaviour.&lt;/p&gt;
&lt;p&gt;This is the substance of &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/facebook/react-native/pull/57811&quot;&gt;react-native#57811&lt;/a&gt;, which a Meta engineer has imported for internal review. I verified those changes against the reproducer on a physical device: 49 cycles, zero failures, where the unpatched build produced 36 failures in 10 cycles.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you are on 0.86.2 and patching the framework&lt;/strong&gt;, note that React core ships&lt;br&gt;
prebuilt, so edits to &lt;code&gt;RCTScrollViewComponentView.mm&lt;/code&gt; in &lt;code&gt;node_modules&lt;/code&gt; never&lt;br&gt;
reach the binary. You need &lt;code&gt;RCT_USE_PREBUILT_RNCORE=0&lt;/code&gt; and a fresh &lt;code&gt;pod install&lt;/code&gt;,&lt;br&gt;
or the patch silently does nothing.&lt;/p&gt;
&lt;h3 id=&quot;what-does-not-work&quot;&gt;What does not work&lt;/h3&gt;
&lt;p&gt;Scrolling the screen back to the top when it opens. I shipped that early and it&lt;br&gt;
does clear the wrong opening position, which is enough to make it look like a&lt;br&gt;
cure. It does not remove the phantom inset, so the dead space is still waiting at&lt;br&gt;
the bottom.&lt;/p&gt;
&lt;h3 id=&quot;how-to-tell-you-have-fixed-it&quot;&gt;How to tell you have fixed it&lt;/h3&gt;
&lt;p&gt;Run the confirmation check again after the flow that used to break things. Zero&lt;br&gt;
inset, and a scroll to the bottom that stops at the end of your content with&lt;br&gt;
nothing beneath it.&lt;/p&gt;
&lt;p&gt;The reproducer is at&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/PragathiJ/rn-aaki-recycle-repro&quot;&gt;github.com/PragathiJ/rn-aaki-recycle-repro&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I write about production engineering and applied AI at&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://pragathijayaram.com/articles/&quot;&gt;pragathijayaram.com&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30241/automaticallyadjustkeyboardinsets-is-leaking-into-your-other-screens-how-check-and-fix</guid>
<pubDate>Thu, 08 Oct 2026 08:56:07 +0000</pubDate>
</item>
<item>
<title>Native, Kotlin Multiplatform or React Native: how I'd choose</title>
<link>https://coderlegion.com/29953/native-kotlin-multiplatform-or-react-native-how-id-choose</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Native, Kotlin Multiplatform or React Native: how I'd choose&lt;br&gt;
published: true&lt;br&gt;
date: 2026-09-25 00:00:00 UTC&lt;br&gt;
tags: kotlinmultiplatform,reactnative,android,ios&lt;/p&gt;
&lt;h3 id=&quot;canonical-url-https-abhishekgoyal-me-writing-native-kmp-or-react-native&quot;&gt;canonical_url: &lt;a rel=&quot;nofollow&quot; href=&quot;https://abhishekgoyal.me/writing/native-kmp-or-react-native&quot;&gt;https://abhishekgoyal.me/writing/native-kmp-or-react-native&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;“Should we build this in Kotlin Multiplatform or React Native?” is a question teams often ask before anyone has written down what the app has to do. That’s backwards. I’ve shipped all three approaches to production: native Android and iOS apps, a shared Kotlin Multiplatform data layer in HelperBook, and a React Native rewrite of two native apps at Pulse. The framework has never been the first decision. The features are.&lt;/p&gt;
&lt;h3 id=&quot;start-with-the-features-not-the-framework&quot;&gt;Start with the features, not the framework&lt;/h3&gt;
&lt;p&gt;Before choosing anything, I list what the app actually has to do, and which of those things lean on the platform. Camera capture, video playback, background work, offline data, sensors, OS integrations. Then I weigh each option against that list, along with the team that will maintain it and the time and money available.&lt;/p&gt;
&lt;p&gt;Most of the heat in “Kotlin Multiplatform vs React Native” debates comes from people comparing the tools in the abstract. For a specific app, the answer is usually much clearer than the debate suggests.&lt;/p&gt;
&lt;h3 id=&quot;when-i-d-stay-fully-native&quot;&gt;When I’d stay fully native&lt;/h3&gt;
&lt;p&gt;Sharing code is not free, so sometimes the right amount to share is none.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The market is on one platform.&lt;/strong&gt; HelperBook is for Indian households, where Android is the phone most people carry, so it shipped on Android first. Its records still live in a shared Kotlin Multiplatform module with SQLDelight, so an iOS app could reuse that data layer rather than rewrite it, but the app itself is native Android.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The app leans hard on platform APIs.&lt;/strong&gt; On a video-first hiring platform I built 100% of the Android client, and the core of it was CameraX capture, Media3 playback and on-device speech transcription. That is exactly the kind of work where you want the platform’s own APIs directly, not through a layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The two apps look and behave differently.&lt;/strong&gt; If the Android and iOS versions don’t share much UI or flow, a shared UI codebase saves less than it costs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-kotlin-multiplatform-fits&quot;&gt;When Kotlin Multiplatform fits&lt;/h3&gt;
&lt;p&gt;Kotlin Multiplatform shares business logic and data while each platform keeps a fully native UI. I reach for it when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Native apps already exist.&lt;/strong&gt; You can add a shared module to the apps you have and move code into it gradually, instead of rewriting both.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The team writes Kotlin.&lt;/strong&gt; Android developers can own the shared code without learning a new stack.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The business logic is complex and must match on both platforms.&lt;/strong&gt; Rules, calculations and offline data that behave slightly differently on Android and iOS are a bug factory. Writing them once removes a whole class of problems.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Native feel matters.&lt;/strong&gt; The UI stays Jetpack Compose on Android and SwiftUI or UIKit on iOS.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-react-native-fits&quot;&gt;When React Native fits&lt;/h3&gt;
&lt;p&gt;React Native gives you one codebase for the UI as well as the logic. I recommend it when the timeline is short and the budget is small, and one team has to ship both platforms quickly. It also makes more sense when the screens are the same on both platforms, and when there’s already a React web team whose skills carry over.&lt;/p&gt;
&lt;h3 id=&quot;performance-honestly&quot;&gt;Performance, honestly&lt;/h3&gt;
&lt;p&gt;People search for “Kotlin Multiplatform vs React Native performance” a lot, so here’s my view. Kotlin Multiplatform keeps the UI fully native, and native UI is smoother. For most business apps (forms, lists, dashboards) React Native is fine, and the difference users feel comes far more from the architecture than from the framework.&lt;/p&gt;
&lt;p&gt;I haven’t shipped a Flutter app, so I won’t argue for or against it from experience. It’s a real option; it’s just not one I can speak to first-hand.&lt;/p&gt;
&lt;h3 id=&quot;what-pulse-taught-me&quot;&gt;What Pulse taught me&lt;/h3&gt;
&lt;p&gt;At Pulse I owned mobile, Android and iOS, from an early prototype to 100+ B2B enterprise clients. For years it ran as two native apps, Kotlin on Android and Swift on iOS, kept at feature parity. Keeping two native apps at parity meant building every feature twice.&lt;/p&gt;
&lt;p&gt;In 2025 I led the rewrite of both apps into one React Native codebase, architected and co-built with the team in six months, and it replaced both native apps in production. React Native fitted the situation: one codebase suited the timeline and budget, the web app was already built in React, and the screens were the same on both platforms.&lt;/p&gt;
&lt;p&gt;The hardest part of the port was reliable background uploads, which the offline engine depended on. The inspections could run to 500+ questions and 1,000+ photos, captured offline and uploaded in the background once a connection came back, so the upload behaviour had to be as dependable after the rewrite as before it.&lt;/p&gt;
&lt;p&gt;Looking back, React Native was a reasonable call for that timeline, budget and team. Knowing what I know now, I’d weigh Kotlin Multiplatform harder for an app with that much offline logic. The sync rules and the upload queue are exactly the kind of code you want written once and shared, while each platform keeps its own native background APIs.&lt;/p&gt;
&lt;h3 id=&quot;how-i-d-decide-for-your-app&quot;&gt;How I’d decide for your app&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;If your app… &lt;/th&gt;&lt;th&gt;I’d lean towards&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Targets one platform first, or leans hard on camera, video, sensors &lt;/td&gt;&lt;td&gt;Native&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Already has native apps, or complex logic that must match on both &lt;/td&gt;&lt;td&gt;Kotlin Multiplatform&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Needs both platforms fast, on a small budget, with the same screens &lt;/td&gt;&lt;td&gt;React Native&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;None of these is a rule. They’re where I start, and the feature list decides the rest. If you’re weighing this for a real app, &lt;a rel=&quot;nofollow&quot; href=&quot;https://abhishekgoyal.me/services/kmp&quot;&gt;here’s how I help teams adopt Kotlin Multiplatform&lt;/a&gt;, and every engagement starts with a paid discovery sprint that ends in a written recommendation you can use with anyone.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29953/native-kotlin-multiplatform-or-react-native-how-id-choose</guid>
<pubDate>Tue, 06 Oct 2026 08:23:00 +0000</pubDate>
</item>
<item>
<title>The Pitfalls of Migrating from Xamarin.Forms to .NET MAUI</title>
<link>https://coderlegion.com/29655/the-pitfalls-of-migrating-from-xamarin-forms-to-net-maui</link>
<description>&lt;h3 id=&quot;why-the-migration-is-not-a-simple-upgrade&quot;&gt;Why the migration is not a simple upgrade&lt;/h3&gt;
&lt;p&gt;On paper, migrating from Xamarin.Forms to .NET MAUI looks like a namespace change. In practice, the time goes into three areas: custom renderers, navigation, and plugins that are no longer maintained. Microsoft's support for Xamarin ended in May 2024, and the apps still on it are getting harder to publish as Apple and Google raise their minimum SDK requirements.&lt;br&gt;
The mechanical part is relatively easy. The project moves to the single-project format, &lt;code&gt;Xamarin.Forms&lt;/code&gt; becomes &lt;code&gt;Microsoft.Maui.Controls&lt;/code&gt;, and &lt;code&gt;Xamarin.Essentials&lt;/code&gt; is built directly into the framework. Automated upgrade tools and AI assistants handle this part well.&lt;br&gt;
What they don't handle well is understanding why a custom renderer existed, what the code assumed about the order in which pages load, or what hidden behavior an old plugin provided. This article walks through those pitfalls, with code examples and solutions that work in real projects.&lt;/p&gt;
&lt;h3 id=&quot;from-renderers-to-handlers&quot;&gt;From renderers to handlers&lt;/h3&gt;
&lt;p&gt;The biggest conceptual change is that MAUI no longer customizes controls through inheritance, but through mappers: dictionaries that link each property of a cross-platform control to the native code that applies it.&lt;br&gt;
In Xamarin.Forms, removing the underline from an &lt;code&gt;Entry&lt;/code&gt; on Android meant writing an entire renderer:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[assembly: ExportRenderer(typeof(BorderlessEntry), typeof(BorderlessEntryRenderer))]
public class BorderlessEntryRenderer : EntryRenderer
{
    public BorderlessEntryRenderer(Context context) : base(context) { }

    protected override void OnElementChanged(ElementChangedEventArgs&amp;lt;Entry&amp;gt; e)
    {
        base.OnElementChanged(e);
        if (Control != null)
            Control.Background = null;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In MAUI, the same effect comes from adding an entry to the existing mapper, usually in &lt;code&gt;MauiProgram.cs&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping(&quot;Borderless&quot;, (handler, view) =&amp;gt;
{
    if (view is not BorderlessEntry)
        return;
#if ANDROID
    handler.PlatformView.Background = null;
#elif IOS || MACCATALYST
    handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None;
#endif
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;pitfall-1-mappers-are-global&quot;&gt;Pitfall 1: mappers are global&lt;/h2&gt;
&lt;p&gt;A renderer applied only to the type registered with &lt;code&gt;ExportRenderer&lt;/code&gt;. An entry added to &lt;code&gt;EntryHandler.Mapper&lt;/code&gt; applies to every &lt;code&gt;Entry&lt;/code&gt; in the app. The &lt;code&gt;if (view is not BorderlessEntry) return;&lt;/code&gt; check in the example above is not optional. Without it, every form in the app loses its border, and the bug only shows up during testing, on screens you weren't looking at.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-2-onelementchanged-has-no-direct-equivalent&quot;&gt;Pitfall 2: OnElementChanged has no direct equivalent&lt;/h2&gt;
&lt;p&gt;Old renderers mixed three things in &lt;code&gt;OnElementChanged&lt;/code&gt;: creating the native control, subscribing to events, and unsubscribing. In MAUI these are separate. For a custom handler, the native control is created in &lt;code&gt;CreatePlatformView&lt;/code&gt;, subscriptions go in &lt;code&gt;ConnectHandler&lt;/code&gt;, and cleanup goes in &lt;code&gt;DisconnectHandler&lt;/code&gt;. Moving the old code without splitting it across these three methods usually leads to memory leaks or events that fire twice.&lt;br&gt;
Also watch when &lt;code&gt;DisconnectHandler&lt;/code&gt; gets called. In early MAUI versions it had to be called manually, while newer versions have a configurable policy via &lt;code&gt;HandlerProperties.DisconnectPolicy&lt;/code&gt;. Check the documentation for the .NET version you're using.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-3-the-temptation-of-compatibility-renderers&quot;&gt;Pitfall 3: the temptation of compatibility renderers&lt;/h2&gt;
&lt;p&gt;MAUI lets you register old renderers through &lt;code&gt;AddCompatibilityRenderer&lt;/code&gt;. That's useful for getting the app running quickly, but it's technical debt: the compatibility package is marked obsolete and doesn't benefit from the new pipeline's optimizations. My recommendation is to use it only as an intermediate step, with a clear ticket for each remaining renderer.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-4-the-native-control-type-has-changed&quot;&gt;Pitfall 4: the native control type has changed&lt;/h2&gt;
&lt;p&gt;Old code often cast to specific native classes. In MAUI, &lt;code&gt;handler.PlatformView&lt;/code&gt; may be a MAUI-derived class (for example &lt;code&gt;MauiTextField&lt;/code&gt; on iOS), not the UIKit or Android class you knew. A wrong cast doesn't produce a compile error but an &lt;code&gt;InvalidCastException&lt;/code&gt; at runtime, usually on only one platform.&lt;/p&gt;
&lt;h3 id=&quot;shell-and-navigation&quot;&gt;Shell and navigation&lt;/h3&gt;
&lt;p&gt;Shell existed in Xamarin.Forms too, but many older apps used &lt;code&gt;NavigationPage&lt;/code&gt;, &lt;code&gt;TabbedPage&lt;/code&gt;, and &lt;code&gt;MasterDetailPage&lt;/code&gt;. In MAUI, Shell becomes the recommended approach, and moving to it changes more than the syntax.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-5-shell-doesn-t-accept-every-page-type&quot;&gt;Pitfall 5: Shell doesn't accept every page type&lt;/h2&gt;
&lt;p&gt;A &lt;code&gt;ShellContent&lt;/code&gt; must contain a &lt;code&gt;ContentPage&lt;/code&gt;. A &lt;code&gt;TabbedPage&lt;/code&gt; or &lt;code&gt;FlyoutPage&lt;/code&gt; (formerly &lt;code&gt;MasterDetailPage&lt;/code&gt;) placed inside Shell is not supported. Tabs and the side menu are defined through the Shell structure (&lt;code&gt;TabBar&lt;/code&gt;, &lt;code&gt;Tab&lt;/code&gt;, &lt;code&gt;FlyoutItem&lt;/code&gt;). If the app had tabs nested inside detail pages, the structure has to be rethought, not just moved.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-6-routes-and-parameters&quot;&gt;Pitfall 6: routes and parameters&lt;/h2&gt;
&lt;p&gt;Pages that don't appear in the Shell visual hierarchy must be registered explicitly, otherwise navigation fails at runtime:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Routing.RegisterRoute(nameof(PatientDetailPage), typeof(PatientDetailPage));

await Shell.Current.GoToAsync($&quot;{nameof(PatientDetailPage)}?id={patient.Id}&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details take the most time. First, the &lt;code&gt;//&lt;/code&gt; prefix means an absolute route and resets the navigation stack, which is right after login but wrong in a list-detail flow. Second, query string parameters are text. For complex objects, use the parameter dictionary (or &lt;code&gt;ShellNavigationQueryParameters&lt;/code&gt; in recent versions, valid for a single navigation) and receive them through &lt;code&gt;IQueryAttributable&lt;/code&gt;, not through manual serialization in the URL.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-7-page-lifecycle&quot;&gt;Pitfall 7: page lifecycle&lt;/h2&gt;
&lt;p&gt;With &lt;code&gt;ContentTemplate&lt;/code&gt;, Shell creates pages the first time they are shown, not when the app starts. Code that assumed a ViewModel already existed (for example, subscribing in a constructor to messages sent by another page) can miss events. In addition, pages in tabs usually stay in memory, so &lt;code&gt;OnAppearing&lt;/code&gt; is called every time you return, not just once. Data-loading logic needs to be idempotent.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-8-dependency-injection-in-pages&quot;&gt;Pitfall 8: dependency injection in pages&lt;/h2&gt;
&lt;p&gt;MAUI has built-in DI, and Shell can resolve pages and ViewModels from the container if they are registered in &lt;code&gt;MauiProgram.cs&lt;/code&gt;. Pay attention to the lifetime you choose: a page registered as a singleton keeps its state between navigations, while a transient one loses it. Code that used &lt;code&gt;DependencyService.Get&amp;lt;T&amp;gt;()&lt;/code&gt; still works, but this is a good moment to move it to constructor injection.&lt;/p&gt;
&lt;h3 id=&quot;plugins-that-no-longer-exist&quot;&gt;Plugins that no longer exist&lt;/h3&gt;
&lt;p&gt;In many projects, plugins block the migration more than your own code does. The Xamarin ecosystem was built on community-maintained packages: some were abandoned, some were folded into the framework, and others were rewritten under a different name. The first step is an inventory of every NuGet package, with a replacement for each one.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Xamarin.Essentials - built into MAUI - namespaces change (&lt;code&gt;Microsoft.Maui.Devices&lt;/code&gt;, &lt;code&gt;Microsoft.Maui.Storage&lt;/code&gt;, &lt;code&gt;Microsoft.Maui.ApplicationModel&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Plugin.Permissions - &lt;code&gt;Permissions&lt;/code&gt; (built in) - similar API, but manifest/Info.plist declarations are still required&lt;/li&gt;
&lt;li&gt;Plugin.Connectivity - &lt;code&gt;Connectivity&lt;/code&gt; (built in)&lt;/li&gt;
&lt;li&gt;Plugin.Media - MediaPicker (built in) - for full camera control, CommunityToolkit.Maui.Camera&lt;/li&gt;
&lt;li&gt;Xamarin.CommunityToolkit - CommunityToolkit.Maui - many APIs renamed; check behaviors and converters one by one&lt;/li&gt;
&lt;li&gt;Rg.Plugins.Popup - Mopups or Popup from CommunityToolkit.Maui - Mopups keeps a similar API, faster migration&lt;/li&gt;
&lt;li&gt;FFImageLoading - standard &lt;code&gt;Image&lt;/code&gt; or community forks for MAUI - the original package is no longer maintained&lt;/li&gt;
&lt;li&gt;Xamarin.Forms.Maps - Microsoft.Maui.Controls.Maps - requires &lt;code&gt;UseMauiMaps()&lt;/code&gt; in &lt;code&gt;MauiProgram.cs&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;SkiaSharp.Views.Forms - SkiaSharp.Views.Maui.Controls - requires UseSkiaSharp()&lt;/li&gt;
&lt;li&gt;Lottie (Xamarin) - &lt;code&gt;SKLottieView&lt;/code&gt; from SkiaSharp.Extended.UI.Maui&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MessagingCenter&lt;/code&gt; - &lt;code&gt;WeakReferenceMessenger&lt;/code&gt; from CommunityToolkit.Mvvm - &lt;code&gt;MessagingCenter&lt;/code&gt; is marked obsolete&lt;/li&gt;
&lt;li&gt;Visual Studio App Center - Sentry, Firebase Crashlytics, etc. - App Center was retired in March 2025&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;pitfall-9-the-plugin-works-but-only-on-one-platform&quot;&gt;Pitfall 9: the plugin “works”, but only on one platform&lt;/h2&gt;
&lt;p&gt;Some packages have versions that install without errors in a MAUI project but cover only some platforms or depend on old native libraries. The build passes, and the problem appears in the iOS build, at publishing time, or on the first run on a real device. Check on NuGet which frameworks each package targets (for example &lt;code&gt;net8.0-android&lt;/code&gt;, &lt;code&gt;net8.0-ios&lt;/code&gt;) and the date of its latest release.&lt;/p&gt;
&lt;h2 id=&quot;pitfall-10-hidden-behaviors&quot;&gt;Pitfall 10: hidden behaviors&lt;/h2&gt;
&lt;p&gt;Old plugins often did things you couldn't see in your code. Plugin.Media, for example, requested permissions on its own and could compress or rotate the image. &lt;code&gt;MediaPicker&lt;/code&gt; doesn't do all of that, so after migration you may get photos several MB in size or with the wrong orientation. Every replacement has to be tested for behavior, not just for compilation.&lt;/p&gt;
&lt;h3 id=&quot;small-pitfalls-that-eat-up-time&quot;&gt;Small pitfalls that eat up time&lt;/h3&gt;
&lt;p&gt;None of the issues below is hard, but together they can add whole days to an estimate.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Image resources&lt;/strong&gt;. In the single project, images live in &lt;code&gt;Resources/Images&lt;/code&gt; and must follow Android rules: lowercase letters, digits and underscores, no hyphens. SVG files are converted to PNG at build time and are referenced in XAML with the &lt;code&gt;.png&lt;/code&gt; extension.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Layouts&lt;/strong&gt;. Default spacing values have changed, &lt;code&gt;RelativeLayout&lt;/code&gt; has moved to the compatibility package, and &lt;code&gt;Frame&lt;/code&gt; is replaced by &lt;code&gt;Border&lt;/code&gt;. Screens look “almost” the same, which is more dangerous than looking obviously broken.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Device APIs&lt;/strong&gt;. &lt;code&gt;Device.RuntimePlatform&lt;/code&gt; becomes &lt;code&gt;DeviceInfo.Platform&lt;/code&gt;, and &lt;code&gt;Device.BeginInvokeOnMainThread&lt;/code&gt; becomes &lt;code&gt;MainThread.BeginInvokeOnMainThread&lt;/code&gt; or &lt;code&gt;Dispatcher&lt;/code&gt;. The old code compiles with warnings, so it's easy to ignore.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ListView vs. CollectionView&lt;/strong&gt;. &lt;code&gt;ListView&lt;/code&gt; still exists, but &lt;code&gt;CollectionView&lt;/code&gt; is the recommended option and behaves differently for selection, separators and row height.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compiled bindings&lt;/strong&gt;. Adding x:DataType in XAML brings a visible performance gain and catches, at compile time, wrong bindings that failed silently in Xamarin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trimming and AOT&lt;/strong&gt;. Release builds can strip code accessed through reflection (serialization, dynamically configured DI). An app that works perfectly in Debug can stop at startup in Release. Test the Release build on a real device in the first week, not at the end.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;conclusion-a-lower-risk-strategy&quot;&gt;Conclusion: a lower-risk strategy&lt;/h3&gt;
&lt;p&gt;A successful migration starts with the inventory, not the code. The order that has worked best in practice is:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Inventory every NuGet package, custom renderer, effect and DependencyService call, with a replacement decided for each.&lt;/li&gt;
&lt;li&gt;Do the mechanical part (single project, namespaces, Essentials) and get the app compiling, temporarily using compatibility renderers where needed.&lt;/li&gt;
&lt;li&gt;Build and run the Release build on real devices, on both platforms, before any other change.&lt;/li&gt;
&lt;li&gt;Rewrite renderers as handlers or mappers, one at a time, with visual tests on every affected screen.&lt;/li&gt;
&lt;li&gt;Move navigation to Shell only after everything else is stable. It's the change with the biggest impact on behavior.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;MAUI is a cleaner and faster framework than Xamarin.Forms, and the handler model makes customization much simpler once you understand it. The real cost of the migration lies in what the old code did implicitly. If you identify those things early, the estimate stays realistic and the app reaches the stores without surprises.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29655/the-pitfalls-of-migrating-from-xamarin-forms-to-net-maui</guid>
<pubDate>Sat, 03 Oct 2026 17:04:00 +0000</pubDate>
</item>
<item>
<title>I built a habit tracker that doesn’t track anything</title>
<link>https://coderlegion.com/29067/i-built-a-habit-tracker-that-doesnt-track-anything</link>
<description>&lt;p&gt;I get distracted easily.&lt;/p&gt;
&lt;p&gt;And I have exactly the same problem with habits.&lt;/p&gt;
&lt;p&gt;Every day brings a new urgent task. By the end of the day, there’s often neither the time nor the energy left for the things I actually wanted to do consistently.&lt;/p&gt;
&lt;p&gt;At some point, I realized I didn’t need yet another habit tracker with a giant calendar, streaks, and progress charts.&lt;/p&gt;
&lt;p&gt;I just needed to see one thing:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;what matters today.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For example, I want to go to the gym three times a week.&lt;/p&gt;
&lt;p&gt;If I’ve already been three times, why should the app keep showing me that workout for the rest of the week?&lt;/p&gt;
&lt;p&gt;Or Portuguese. I only have time to study on Wednesdays and Fridays. So why would I want to see that task on Monday?&lt;/p&gt;
&lt;p&gt;That’s why I built &lt;strong&gt;Yet Another Habit&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The app only shows the activities that are relevant &lt;strong&gt;today&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Not today? Don’t show it.&lt;/p&gt;
&lt;p&gt;Already completed the required number of times this week? Don’t show it anymore.&lt;/p&gt;
&lt;p&gt;Didn’t get to it today? Just skip it. The activity will come back on the next day when it actually matters.&lt;/p&gt;
&lt;p&gt;And there’s one more thing I left out on purpose:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;there are no stats.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;No fancy charts, no streaks, no reports reminding you what a terrible person you’ve been for the last 27 days.&lt;/p&gt;
&lt;p&gt;I think that when you’re building a habit, the process matters more than constantly measuring the result.&lt;/p&gt;
&lt;p&gt;You open the app, see what matters right now, and focus on that.&lt;/p&gt;
&lt;p&gt;I originally built Yet Another Habit just for myself, but eventually decided to share it and released it on the App Store.&lt;/p&gt;
&lt;p&gt;🌱 Website: &lt;a rel=&quot;nofollow&quot; href=&quot;https://izebit.com/yet-another-habit/&quot;&gt;https://izebit.com/yet-another-habit/&lt;/a&gt;&lt;br&gt;
 App Store: &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/br/app/yet-another-habit/id6809048979&quot;&gt;https://apps.apple.com/br/app/yet-another-habit/id6809048979&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Track. Don’t judge. Repeat.&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/29067/i-built-a-habit-tracker-that-doesnt-track-anything</guid>
<pubDate>Tue, 29 Sep 2026 12:51:03 +0000</pubDate>
</item>
<item>
<title>Mantra Meditation Timer (iOS)</title>
<link>https://coderlegion.com/28595/mantra-meditation-timer-ios</link>
<description>&lt;p&gt;MANTRATimer is a minimalist, privacy-first meditation timer specifically designed for practitioners of mantra-based meditation techniques on iOS.   It offers a distraction-free environment, ensuring your focus remains solely on your practice without intrusive features common in other apps. This app is ideal for individuals seeking a simple, reliable, and respectful tool for their daily mantra meditation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Features&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Opens directly to your timer for immediate practice.&lt;/li&gt;
&lt;li&gt;Completely silent notifications that respect your meditation.&lt;/li&gt;
&lt;li&gt;No tracking, social features, or distractions.&lt;/li&gt;
&lt;li&gt;Configurable warm-up and cool-down phases with tricolour progress arc.&lt;/li&gt;
&lt;li&gt;Optional post-session reflections and notes for personal insights.&lt;/li&gt;
&lt;li&gt;One-time purchase for Pro features, offering lifetime access with no subscriptions. &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Use Cases&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;MANTRATimer is perfectly suited for individuals engaged in mantra-based meditation practices, such as Transcendental Meditation, who require a dedicated and uncluttered timer.   If you've found other popular meditation apps like Headspace or Calm to be overly complex, filled with guided meditations you don't use, or disruptive with streaks and upgrade prompts, MANTRATimer provides the absolute simplicity you need. It serves as a dedicated space for your daily ritual, ensuring a seamless transition from activity to silence.&lt;/p&gt;
&lt;p&gt;It's also beneficial for meditators who prioritize privacy and wish to keep their practice data local and secure. The app's design caters to those who understand that mantra meditation thrives on silence and focus, offering a tool that truly 'gets it' by removing all unnecessary elements that break concentration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pricing Information&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;MANTRATimer operates on a freemium model. The Free Version provides all essentials for daily practice, including built-in timers (Standard, Extended, plus one custom), configurable warm-up/cool-down, session reflections, notes, local history, and basic statistics.&lt;/p&gt;
&lt;p&gt;For advanced practitioners, 'Mantra+' is available as a one-time purchase for lifetime access to all features and future updates, including unlimited custom timers, advanced statistics, iCloud sync, Apple Family Sharing, and more, with no subscription ever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;User Experience and Support&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The user experience is centered around pure simplicity and focus. The app opens directly to your timer, eliminating menus and choices to ensure a seamless transition into meditation. Notifications are completely silent, respecting your practice. Crafted for iPhone, it offers smooth, quiet, native interactions. While extensive tutorials aren't explicitly mentioned, the app's minimalist design aims for intuitive use. Contact information is provided for support.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Technical Details&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;MANTRATimer is an iOS native application, meticulously crafted for iPhone from day one. It leverages Apple's ecosystem, including iCloud for private and encrypted data synchronization across your devices. This design ensures that your personal meditation history, timers, and settings remain private and never touch external servers, upholding a strong privacy-first commitment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pros and Cons&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Pros:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Purpose-built for mantra-based meditation, ensuring absolute simplicity.&lt;/li&gt;
&lt;li&gt;Privacy-first approach with no ad trackers, social features, or data collection on external servers.&lt;/li&gt;
&lt;li&gt;One-time purchase for Pro features, avoiding recurring subscriptions.&lt;/li&gt;
&lt;li&gt;Seamless, distraction-free user experience with silent notifications.&lt;/li&gt;
&lt;li&gt;iOS native design for smooth and quiet interactions.&lt;/li&gt;
&lt;li&gt;Configurable timers, warm-up/cool-down phases, and optional reflections.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Cons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Exclusively available on iOS and iPadOS (Apple Watch integration coming in 2026).&lt;/li&gt;
&lt;li&gt;Not designed for guided meditations or other non-mantra practices.&lt;/li&gt;
&lt;li&gt;Lacks social or community features (intentional, but a con for some users).&lt;/li&gt;
&lt;li&gt;No explicit mention of extensive in-app tutorials, though simplicity aims to mitigate this. &lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;In summary&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;MANTRATimer is the definitive meditation timer for practitioners of mantra-based techniques, offering an unparalleled blend of simplicity, focus, and privacy. It eliminates common distractions found in other apps, providing a truly dedicated space for your daily practice. If you seek a reliable, respectful, and distraction-free tool to support your silent meditation journey, explore MANTRATimer today and experience the difference.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.apple.com/app/id6781308416&quot;&gt;Available on the Apple App Store&lt;/a&gt;&lt;/p&gt;
</description>
<category>Launches</category>
<guid isPermaLink="true">https://coderlegion.com/28595/mantra-meditation-timer-ios</guid>
<pubDate>Fri, 25 Sep 2026 12:30:00 +0000</pubDate>
</item>
<item>
<title>How to Fix Apple Silicon CocoaPods Errors Without Messy Terminal Aliases</title>
<link>https://coderlegion.com/22001/how-to-fix-apple-silicon-cocoapods-errors-without-messy-terminal-aliases</link>
<description>&lt;h3 id=&quot;the-crash&quot;&gt;The Crash&lt;/h3&gt;
&lt;p&gt;You pull down a fresh React Native repo, or you upgrade your Expo project, navigate to your ios directory, and run your standard installation command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pod install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead of a clean build, your terminal drops a wall of text pointing to a compilation or architecture conflict:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;LoadError - dylib library not found for ffi_c - /Library/Ruby/Gems/2.6.0/gems/ffi-1.15.5/lib/ffi_c.bundle
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or you are hit with an explicit architecture clash during compilation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Ignoring ffi-1.15.5 because its extensions are not built.
Arch mismatch: x86_64 vs arm64
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-the-common-internet-advice-is-a-trap&quot;&gt;Why the Common Internet Advice is a Trap&lt;/h3&gt;
&lt;p&gt;If you search for this error on Stack Overflow or Reddit, the top answers almost always tell you to run your terminal in Rosetta mode or force compilation with an architecture flag:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo arch -x86_64 gem install ffi
arch -x86_64 pod install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Do not do this.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;While this might bypass the error temporarily, it introduces a highly unstable configuration to your machine. It forces an Apple Silicon Mac to use Intel x86 emulation for specific Ruby gems while running an underlying system interpreter that expects native ARM (arm64) code.&lt;/p&gt;
&lt;p&gt;This mixed-architecture setup will inevitably break your bundler, conflict with Node native modules, and cause random, hard-to-diagnose failures during local builds.&lt;/p&gt;
&lt;p&gt;The real problem isn't your hardware; it's that your environment is attempting to load global, system-level Intel configurations inside a modern ARM environment.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-fix-resolving-the-conflict-natively&quot;&gt;The Fix: Resolving the Conflict Natively&lt;/h3&gt;
&lt;p&gt;To resolve the issue permanently, you need to purge the conflicting configurations and ensure your dependency manager runs natively on Apple Silicon.&lt;/p&gt;
&lt;h4 id=&quot;step-1-purge-the-broken-system-gems&quot;&gt;Step 1: Purge the Broken System Gems&lt;/h4&gt;
&lt;p&gt;First, clear out any global, system-level gems that were compiled incorrectly under mixed architectures.&lt;/p&gt;
&lt;p&gt;Run the following command from your root directory to remove CocoaPods configurations managed by the system Ruby installation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo gem uninstall cocoapods
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-2-install-the-native-arm-version-of-cocoapods-via-homebrew&quot;&gt;Step 2: Install the Native ARM Version of CocoaPods via Homebrew&lt;/h4&gt;
&lt;p&gt;Instead of relying on the default macOS system Ruby interpreter to manage your gems, install CocoaPods through Homebrew.&lt;/p&gt;
&lt;p&gt;Homebrew automatically detects your M-series architecture and fetches the dedicated, optimized native arm64 binary.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;brew install cocoapods
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-3-isolate-your-ruby-environment-optional-but-highly-recommended-&quot;&gt;Step 3: Isolate Your Ruby Environment (Optional but Highly Recommended)&lt;/h4&gt;
&lt;p&gt;Using the default pre-installed macOS system Ruby (/usr/bin/ruby) frequently causes permissions and architecture locks.&lt;/p&gt;
&lt;p&gt;For a stable environment, switch to a local Ruby version manager like rbenv or chruby to handle your project dependencies cleanly.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Install rbenv via Homebrew
brew install rbenv ruby-build

# Initialize rbenv in your shell configuration
rbenv init

# Install and set a modern native Ruby version
rbenv install 3.2.2
rbenv global 3.2.2
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-4-clear-the-local-state-and-install-natively&quot;&gt;Step 4: Clear the Local State and Install Natively&lt;/h4&gt;
&lt;p&gt;Now that your environment relies on native binaries, clear your project's cached build configurations to ensure no stale artifacts remain:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd ios
pod deintegrate
rm -rf Podfile.lock
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, run your installation command normally.&lt;/p&gt;
&lt;p&gt;It will complete natively without requiring any arch -x86_64 prefixes or Rosetta terminal wrappers:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pod install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wrestling with local environment drift, corrupted build paths, and conflicting architectures can pull you out of the development loop for hours.&lt;/p&gt;
&lt;p&gt;If you are tired of decoding cryptic terminal logs and tracking down configuration bugs by hand, take a look at &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.fixmyerrorapp.com&quot;&gt;FixMyError&lt;/a&gt;. It is a clean diagnostic workspace built to parse your build outputs instantly, giving you clear solutions so you can get right back to building your app.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/22001/how-to-fix-apple-silicon-cocoapods-errors-without-messy-terminal-aliases</guid>
<pubDate>Mon, 06 Jul 2026 12:55:19 +0000</pubDate>
</item>
<item>
<title>How to Fix &quot;Module Could Not Be Found&quot; in React Native &amp; Expo</title>
<link>https://coderlegion.com/20703/how-to-fix-module-could-not-be-found-in-react-native-expo</link>
<description>&lt;p&gt;We’ve all been there.&lt;/p&gt;
&lt;p&gt;You find an awesome library, you run &lt;code&gt;npx expo install&lt;/code&gt;, you import it into your code, and you start your development server.&lt;/p&gt;
&lt;p&gt;You expect magic.&lt;/p&gt;
&lt;p&gt;Instead, your simulator turns blindingly red with an error that looks something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;ERROR Invariant Violation: TurboModuleRegistry.getEnforcing(...):
'RNGestureHandlerModule' could not be found.

Verify that your native modules are linked correctly.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your app is completely bricked, your Metro bundler is acting confused, and you’re left wondering why a package you just installed is allegedly missing from the face of the earth.&lt;/p&gt;
&lt;p&gt;Let's look at exactly why this happens and the 3-step checklist to clear it up without losing your sanity.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-root-cause-javascript-vs-native-code&quot;&gt;The Root Cause: JavaScript vs. Native Code&lt;/h3&gt;
&lt;p&gt;Modern Expo apps are beautifully split into two worlds:&lt;/p&gt;
&lt;h2 id=&quot;the-js-bundle-&quot;&gt;The JS Bundle:&lt;/h2&gt;
&lt;p&gt;Your components, logic, and regular styles. Metro can hot-reload this in milliseconds.&lt;/p&gt;
&lt;h2 id=&quot;the-native-layer-&quot;&gt;The Native Layer:&lt;/h2&gt;
&lt;p&gt;The underlying Kotlin/Java and Swift/Objective-C code that actually talks to the phone's hardware.&lt;/p&gt;
&lt;p&gt;When you install a package that uses native code (like &lt;code&gt;react-native-gesture-handler&lt;/code&gt;, &lt;code&gt;react-native-reanimated&lt;/code&gt;, or a map library), Metro cannot hot-reload native code into an active app binary.&lt;/p&gt;
&lt;p&gt;If you are running a pre-built Development Client or using Expo Go, it only knows about the native modules that were compiled the last time you built the app.&lt;/p&gt;
&lt;p&gt;It has no idea this new native module exists yet, so the registry panics and throws an &lt;code&gt;Invariant Violation&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-ultimate-un-brick-my-app-checklist&quot;&gt;The Ultimate &quot;Un-Brick My App&quot; Checklist&lt;/h2&gt;
&lt;p&gt;Next time this red screen of death pops up, run through these three steps in order.&lt;/p&gt;
&lt;h3 id=&quot;1-rebuild-the-app-binary-the-absolute-must-&quot;&gt;1. Rebuild the App Binary (The Absolute Must)&lt;/h3&gt;
&lt;p&gt;Simply restarting the Metro bundler won’t cut it.&lt;/p&gt;
&lt;p&gt;You need to recompile your native code so the new library gets bundled into the actual simulator app.&lt;/p&gt;
&lt;p&gt;Stop your server and run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# For Android Simulators / Devices
npx expo run:android

# For iOS Simulators
npx expo run:ios
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-force-a-clean-prebuild-if-using-custom-native-directories-&quot;&gt;2. Force a Clean Prebuild (If Using Custom Native Directories)&lt;/h3&gt;
&lt;p&gt;If you are managing your own &lt;code&gt;android&lt;/code&gt; or &lt;code&gt;ios&lt;/code&gt; directories and things get desynced, force Expo to regenerate them with the new native dependencies linked:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo prebuild --clean
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This removes the generated native projects and recreates them from your Expo configuration, ensuring newly installed native packages are correctly integrated.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-nuke-the-metro-cache&quot;&gt;3. Nuke the Metro Cache&lt;/h3&gt;
&lt;p&gt;Sometimes Metro holds onto a stale dependency graph like a grudge.&lt;/p&gt;
&lt;p&gt;If you’ve rebuilt the binary and it still complains, start your project while forcing a total cache clearance:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo start -c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This clears Metro's cache and forces it to rebuild the dependency graph from scratch.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;Native linking issues can feel incredibly frustrating because nothing appears wrong in your JavaScript code.&lt;/p&gt;
&lt;p&gt;The problem is usually that your app binary and your JavaScript bundle have fallen out of sync.&lt;/p&gt;
&lt;p&gt;Whenever you see errors like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;RNGestureHandlerModule could not be found&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Native module cannot be null&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TurboModuleRegistry.getEnforcing(...) failed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Module has not been registered&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Run through this checklist:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Rebuild the app binary.&lt;/li&gt;
&lt;li&gt;Run a clean prebuild if you're using native directories.&lt;/li&gt;
&lt;li&gt;Clear the Metro cache.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Most of the time, one of those three steps will get you back up and running.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;a-quick-sidebar-for-tired-developers&quot;&gt;A Quick Sidebar for Tired Developers&lt;/h2&gt;
&lt;p&gt;If you're reading this at 2:00 AM while violently copy-pasting cryptic mobile stack traces into search engines, I feel your pain deeply.&lt;/p&gt;
&lt;p&gt;I got so tired of hunting down hidden Gradle errors and obscure CocoaPods issues that I decided to build a tool to automate the headache.&lt;/p&gt;
&lt;p&gt;It's called &lt;a rel=&quot;nofollow&quot; href=&quot;https://fixmyerrorapp.com&quot;&gt;Fix My Error&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I set up a completely registration-free sandbox right on the homepage using this exact native linkage error so you can see how it works in real time without handing over your data.&lt;/p&gt;
&lt;p&gt;If you like how it parses the logs, you can drop your own daily errors into the core engine with a free account (which gives you 5 free fixes per day), or check out the Pro tier if you're working in a heavy production environment.&lt;/p&gt;
&lt;p&gt;Give the sandbox a spin next time Expo decides to ruin your afternoon, and let me know if it helps your workflow.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;How do you usually handle these types of native linking bugs?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Drop a comment if this checklist worked for you, or let me know if there's an obscure Expo error that's currently driving you crazy.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/20703/how-to-fix-module-could-not-be-found-in-react-native-expo</guid>
<pubDate>Tue, 16 Jun 2026 11:45:20 +0000</pubDate>
</item>
<item>
<title>Why Your Mobile App Works Perfectly Locally But Crashes Instantly in TestFlight</title>
<link>https://coderlegion.com/20265/why-your-mobile-app-works-perfectly-locally-but-crashes-instantly-in-testflight</link>
<description>&lt;p&gt;We’ve all been there. You spend weeks building a feature, testing it on your local simulator or a physical device running a development stream. Everything is butter. No lag, no warnings.&lt;/p&gt;
&lt;p&gt;You confidently bundle the app, ship it to TestFlight, wait for Apple to finish processing, download it... and the moment you tap the app icon, it instantly flashes and closes. Hard crash.&lt;/p&gt;
&lt;p&gt;Before you lose your mind or start blindly tweaking code, remember that production builds behave entirely differently than development environments.&lt;/p&gt;
&lt;p&gt;Here are the four most common reasons your app dies the literal second it hits production, and exactly how to fix them.&lt;/p&gt;
&lt;h2 id=&quot;1-the-missing-privacy-keys-string-info-plist-&quot;&gt;1. The Missing Privacy Keys String (&lt;code&gt;Info.plist&lt;/code&gt;)&lt;/h2&gt;
&lt;p&gt;If your app requests permissions for features like Location Services (maps, background location), the Camera, or the Photo Library, Apple requires you to explicitly state why in your &lt;code&gt;Info.plist&lt;/code&gt; file using usage description keys (like &lt;code&gt;NSCameraUsageDescription&lt;/code&gt; or &lt;code&gt;NSLocationWhenInUseUsageDescription&lt;/code&gt;).&lt;/p&gt;
&lt;h4 id=&quot;the-gotcha&quot;&gt;The Gotcha&lt;/h4&gt;
&lt;p&gt;In a local development environment, sometimes a framework will let a missing string slide, fallback to a default, or catch the exception gracefully.&lt;/p&gt;
&lt;h4 id=&quot;the-production-reality&quot;&gt;The Production Reality&lt;/h4&gt;
&lt;p&gt;Apple’s iOS security layer will ruthlessly and instantly terminate the application binary on launch if your production code attempts to initialize a library requiring these permissions without the matching text string configured.&lt;/p&gt;
&lt;h4 id=&quot;the-fix&quot;&gt;The Fix&lt;/h4&gt;
&lt;p&gt;Double-check your &lt;code&gt;Info.plist&lt;/code&gt; (or your Expo &lt;code&gt;app.json&lt;/code&gt; plugins). Make sure every single hardware or permission API your code imports has a clear, user-facing explanation string attached.&lt;/p&gt;
&lt;h2 id=&quot;2-missing-push-notification-entitlements&quot;&gt;2. Missing Push Notification Entitlements&lt;/h2&gt;
&lt;p&gt;If your application includes code for push notifications (even if you haven’t fully wired up the backend yet), your binary needs specific clearance to launch.&lt;/p&gt;
&lt;h4 id=&quot;the-gotcha-1&quot;&gt;The Gotcha&lt;/h4&gt;
&lt;p&gt;Local builds often bypass strict entitlement checks, or run using a wildcard development provisioning profile that covers everything loosely.&lt;/p&gt;
&lt;h4 id=&quot;the-production-reality-1&quot;&gt;The Production Reality&lt;/h4&gt;
&lt;p&gt;When built for distribution, if your app contains code for handling remote notifications but the App Store Provisioning Profile doesn't explicitly have the &lt;strong&gt;Push Notifications&lt;/strong&gt; entitlement enabled, the OS will trigger a fatal launch mismatch exception.&lt;/p&gt;
&lt;h4 id=&quot;the-fix-1&quot;&gt;The Fix&lt;/h4&gt;
&lt;p&gt;Head to your Apple Developer Account under &lt;strong&gt;Identifiers&lt;/strong&gt;, verify that your App ID has &lt;strong&gt;Push Notifications&lt;/strong&gt; checked, and regenerate your production profile.&lt;/p&gt;
&lt;h2 id=&quot;3-dead-code-elimination-amp-aggressive-minification&quot;&gt;3. Dead Code Elimination &amp;amp; Aggressive Minification&lt;/h2&gt;
&lt;p&gt;When building locally, your JS bundling or native compilation keeps debug code, metadata, and helper functions intact.&lt;/p&gt;
&lt;p&gt;When you build for production, optimization tools like ProGuard/R8 (for Android native engines) or aggressive tree-shaking strip away &quot;unused&quot; code to shrink the binary size.&lt;/p&gt;
&lt;h4 id=&quot;the-gotcha-2&quot;&gt;The Gotcha&lt;/h4&gt;
&lt;p&gt;Sometimes, these optimization tools accidentally strip away native modules or reflection classes used by third-party packages, assuming they are dead code because they aren't explicitly referenced in the main thread.&lt;/p&gt;
&lt;h4 id=&quot;the-production-reality-2&quot;&gt;The Production Reality&lt;/h4&gt;
&lt;p&gt;The app boots up, looks for a compiled native library or native method bridge, finds a missing reference, and triggers a fatal crash right during initialization.&lt;/p&gt;
&lt;h4 id=&quot;the-fix-2&quot;&gt;The Fix&lt;/h4&gt;
&lt;p&gt;If you are using native dependencies, make sure your obfuscation/minify configuration files explicitly include rules to keep specific third-party library paths intact.&lt;/p&gt;
&lt;h2 id=&quot;4-broken-initialization-flow-environment-variables-&quot;&gt;4. Broken Initialization Flow (Environment Variables)&lt;/h2&gt;
&lt;p&gt;How does your app determine its backend URL or third-party service tokens?&lt;/p&gt;
&lt;p&gt;If you are relying on a local &lt;code&gt;.env&lt;/code&gt; file that is git-ignored, those values might not be making it into your production build machine or CI/CD pipeline.&lt;/p&gt;
&lt;h4 id=&quot;the-gotcha-3&quot;&gt;The Gotcha&lt;/h4&gt;
&lt;p&gt;The app builds successfully because the compiler doesn't care if a string variable is blank or &lt;code&gt;null&lt;/code&gt; at build time.&lt;/p&gt;
&lt;h4 id=&quot;the-production-reality-3&quot;&gt;The Production Reality&lt;/h4&gt;
&lt;p&gt;On launch, your application's root mounting sequence tries to parse an undefined API key or a &lt;code&gt;null&lt;/code&gt; base URL during setup. If your code doesn't have a fallback check, it throws a fatal JavaScript or runtime error before the first screen even renders.&lt;/p&gt;
&lt;h4 id=&quot;the-fix-3&quot;&gt;The Fix&lt;/h4&gt;
&lt;p&gt;Always verify that your build dashboard (like Expo Application Services, GitHub Actions, or local production scripts) has your production environment variables explicitly mapped before hitting compile.&lt;/p&gt;
&lt;h2 id=&quot;how-to-stop-guessing-and-find-the-proof&quot;&gt;How to Stop Guessing and Find the Proof&lt;/h2&gt;
&lt;p&gt;Stop guessing and changing random lines of code hoping for a miracle. Apple leaves an exact paper trail for immediate launch crashes.&lt;/p&gt;
&lt;h3 id=&quot;on-your-test-device&quot;&gt;On Your Test Device&lt;/h3&gt;
&lt;p&gt;Open up the TestFlight app on your iPhone, tap on the application name, scroll down to &lt;strong&gt;Crash Logs&lt;/strong&gt;, and you can view or share the exact file.&lt;/p&gt;
&lt;h3 id=&quot;inside-xcode&quot;&gt;Inside Xcode&lt;/h3&gt;
&lt;p&gt;Navigate to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Xcode &amp;gt; Window &amp;gt; Organizer &amp;gt; Crashes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apple aggregates logs directly from TestFlight users here, often highlighting the exact line of compiled code that triggered the crash (look for terms like &lt;code&gt;SIGABRT&lt;/code&gt; or &lt;code&gt;EXC_CRASH&lt;/code&gt;).&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;What's the absolute strangest &quot;works locally, breaks in production&quot; bug you've ever had to hunt down?&lt;/p&gt;
&lt;p&gt;Let's talk in the comments!&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/20265/why-your-mobile-app-works-perfectly-locally-but-crashes-instantly-in-testflight</guid>
<pubDate>Thu, 11 Jun 2026 09:01:21 +0000</pubDate>
</item>
<item>
<title>The React Native Native-Dependency Trap: How to Fix Demanding Build Failures Without Nuking node_mod</title>
<link>https://coderlegion.com/20039/the-react-native-native-dependency-trap-demanding-build-failures-without-nuking-nodemod</link>
<description>&lt;p&gt;It’s the same story every time. You run a simple package upgrade, or you decide it's time to bump your Expo SDK version. Locally, JavaScript compiled perfectly. But the second you run a native build, your terminal explodes with hundreds of lines of red text.&lt;/p&gt;
&lt;p&gt;On iOS, it’s a cryptic &lt;code&gt;CocoaPods could not find compatible versions for pod&lt;/code&gt; or a sudden compilation failure in &lt;code&gt;AppDelegate.mm&lt;/code&gt;. On Android, it’s a fatal Gradle lifecycle error or a missing namespace exception.&lt;/p&gt;
&lt;p&gt;When native dependencies break, most developers fall back on the classic loop: delete &lt;code&gt;node_modules&lt;/code&gt;, delete &lt;code&gt;package-lock.json&lt;/code&gt;, clear cache, reinstall, and pray.&lt;/p&gt;
&lt;p&gt;But blind-nuking your files rarely fixes the underlying architectural conflict. Here is how to actually diagnose and surgically resolve native dependency hell in modern React Native and Expo apps.&lt;/p&gt;
&lt;h2 id=&quot;1-the-root-cause-transitive-dependency-syncing&quot;&gt;1. The Root Cause: Transitive Dependency Syncing&lt;/h2&gt;
&lt;p&gt;When you install a library like &lt;code&gt;react-native-reanimated&lt;/code&gt; or a native camera module, that library relies on specific versions of underlying native libraries (Pods or Android libraries).&lt;/p&gt;
&lt;p&gt;If two different third-party packages require the same native dependency but expect completely different versions, your package manager forces a compromise in JavaScript. But when the native build tool (Xcode or Gradle) steps in, it sees two conflicting native frameworks trying to occupy the same space.&lt;/p&gt;
&lt;h4 id=&quot;for-expo-users-always-prioritize-the-pinned-versions&quot;&gt;For Expo Users: Always Prioritize the Pinned Versions&lt;/h4&gt;
&lt;p&gt;Running &lt;code&gt;npm install&lt;/code&gt; can bypass Expo's guardrails. Always use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npx expo install --fix
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This forces Expo to look at your current SDK version and automatically downgrade or upgrade conflicting community packages to their exact validated native counterparts.&lt;/p&gt;
&lt;h2 id=&quot;2-the-ios-podfile-lock-paradox&quot;&gt;2. The iOS Podfile.lock Paradox&lt;/h2&gt;
&lt;p&gt;If your team introduces a package or you pull down &lt;code&gt;main&lt;/code&gt; and suddenly iOS won't build, the culprit is usually an out-of-sync &lt;code&gt;Podfile.lock&lt;/code&gt;. Running &lt;code&gt;pod install&lt;/code&gt; blindly sometimes isn't enough if cached pods are conflicting.&lt;/p&gt;
&lt;h4 id=&quot;the-surgical-fix&quot;&gt;The Surgical Fix&lt;/h4&gt;
&lt;p&gt;Instead of deleting your whole project configuration, clear the native iOS build cache specifically:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd ios &amp;amp;&amp;amp; pod cache clean --all
rm -rf Pods Podfile.lock
pod install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This forces CocoaPods to re-evaluate the dependency tree from absolute scratch based on your current &lt;code&gt;package.json&lt;/code&gt;, without losing your local JS settings.&lt;/p&gt;
&lt;h2 id=&quot;3-the-android-gradle-namespace-meltdown&quot;&gt;3. The Android Gradle Namespace Meltdown&lt;/h2&gt;
&lt;p&gt;With newer versions of Gradle and React Native, the way native Android modules declare their packages has changed (moving entirely to &lt;code&gt;namespace&lt;/code&gt; inside &lt;code&gt;build.gradle&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;If you are using an older, unmaintained community package, Gradle will completely fail to compile the app on launch.&lt;/p&gt;
&lt;h4 id=&quot;the-surgical-fix-1&quot;&gt;The Surgical Fix&lt;/h4&gt;
&lt;p&gt;Instead of waiting for an open-source maintainer to update a dead repository, you can use &lt;code&gt;patch-package&lt;/code&gt; or Expo Config Plugins to alter the third-party library’s &lt;code&gt;build.gradle&lt;/code&gt; file locally.&lt;/p&gt;
&lt;p&gt;Alternatively, ensure your &lt;code&gt;android/gradle.properties&lt;/code&gt; has the proper architecture properties enabled:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;android.useAndroidX=true
android.enableJetifier=true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;triage-your-terminal-output&quot;&gt;Triage Your Terminal Output&lt;/h2&gt;
&lt;p&gt;The biggest mistake developers make is trying to read the very bottom of a failed build log.&lt;/p&gt;
&lt;p&gt;Xcode and Gradle put the actual error at the beginning of the failure block, while the bottom lines are just the generic system telling you the process exited with code &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Always scroll back up to locate the first root error flag.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I'm currently tracking down these weird native compilation bugs and building a database of exact solutions for them. If you are currently fighting a messy React Native or Expo native stack trace that makes absolutely no sense, I built a live beta engine to parse them and spit out precise resolutions over at &lt;a rel=&quot;nofollow&quot; href=&quot;https://fixmyerrorapp.com&quot;&gt;fix-my-error-app.com&lt;/a&gt;. Drop your errors in there if you're stuck, and let me know in the comments what your most hated native build error is!&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Drop your errors in there if you're stuck, and let me know in the comments what your most hated native build error is!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20039/the-react-native-native-dependency-trap-demanding-build-failures-without-nuking-nodemod</guid>
<pubDate>Tue, 09 Jun 2026 09:26:33 +0000</pubDate>
</item>
<item>
<title>WWDC26 Highlights</title>
<link>https://coderlegion.com/20016/wwdc26-highlights</link>
<description>&lt;h3 id=&quot;apple-intelligence-2-0-and-siri-ai&quot;&gt;Apple Intelligence 2.0 and Siri AI&lt;/h3&gt;
&lt;p&gt;Apple used the WWDC26 keynote to introduce a new generation of Apple Intelligence that expands on last year's generative features. The upgraded system runs on a privacy‑preserving architecture that uses on‑device models and Private Cloud Compute. The new Siri AI is deeply integrated across iPhone, iPad, Mac, Apple Watch and Vision Pro. Siri can now understand what's on screen and draw on personal context to search across messages, photos, emails and third‑party apps. When needed, it reaches out to the web to provide up‑to‑date answers. A dedicated Siri app lets users revisit past conversations and syncs history across devices via iCloud.&lt;/p&gt;
&lt;p&gt;Apple Intelligence also powers new experiences across the system. Spatial Reframing in Photos lets users re‑compose pictures after they are taken. Image Playground can generate high‑quality images, and Messages and Mail offer AI‑driven suggestions for replies and reminders. The intelligence features support multiple languages, run on devices with A17 Pro or M‑series chips and will be available in the fall with iOS 27.&lt;/p&gt;
&lt;h3 id=&quot;foundation-models-framework&quot;&gt;Foundation Models framework&lt;/h3&gt;
&lt;p&gt;For developers, the most transformative announcement is the Foundation Models framework in iOS 27. It is a native Swift API that gives apps direct access to the same on‑device language model powering Apple Intelligence. Developers can switch between Apple's model, cloud models (such as Claude or Google Gemini) or any provider that implements the new Language Model protocol. The framework supports multimodal prompts, allowing apps to pass images alongside text and use Vision tools like OCR and barcode readers. Dynamic Profiles let developers swap models, tools and instructions during a session so their app's behavior adapts to the user's needs.&lt;/p&gt;
&lt;p&gt;Smaller teams benefit from cost‑free access: developers enrolled in the App Store Small Business Program and with fewer than 2 million first‑time downloads can use Apple's next‑generation models on Private Cloud Compute at no cloud API cost. A new Evaluations framework helps verify that AI features behave correctly across dynamic conditions.&lt;/p&gt;
&lt;h3 id=&quot;core-ai&quot;&gt;Core AI&lt;/h3&gt;
&lt;p&gt;Where Foundation Models provides access to generative models, the new Core AI framework lets developers bring their own models on‑device. Core AI offers a memory‑safe Swift API and accompanying Python tools to load, specialize and run models entirely on Apple hardware. It delivers zero server dependencies and zero token costs while supporting everything from compact vision models on iPhone to multi‑billion‑parameter language models on Mac. Core AI integrates with ahead‑of‑time compilation, a visual tensor debugger and optimization tools to convert PyTorch models for the Core AI runtime. Apple noted that Core AI is optimized for Apple Silicon and also powers Apple Intelligence experiences across the system.&lt;/p&gt;
&lt;h3 id=&quot;app-intents-and-system-wide-intelligence&quot;&gt;App Intents and System‑wide Intelligence&lt;/h3&gt;
&lt;p&gt;The App Intents framework has been expanded so Siri and Apple Intelligence can perform more actions with third‑party apps. Entity schemas allow an app's content to be indexed in the Spotlight semantic index, making it discoverable by Siri. Intent schemas define actions that users can take naturally without pre‑defined phrases. A new View Annotations API maps views to entities so users can reference on‑screen content conversationally. Apple also introduced an App Intents Testing framework to validate integrations without UI automation.&lt;/p&gt;
&lt;p&gt;The Platforms State of the Union elaborated on how App Intents works with the System Orchestrator, Spotlight semantic index and the App Toolbox to route user requests across apps. Siri AI can now surface your app's content and actions through widgets, the Action button and voice interactions, and these integrations automatically improve as Siri learns new languages or dialects.&lt;/p&gt;
&lt;h3 id=&quot;liquid-glass-refinements&quot;&gt;Liquid Glass refinements&lt;/h3&gt;
&lt;p&gt;Last year's Liquid Glass design language unified Apple's platforms. For 2026, Apple tuned it to improve readability and depth. Liquid Glass now diffuses complex content more effectively and adds a darkened edge with brighter specular highlights. Users can personalize the effect with a slider ranging from ultra‑clear to fully tinted. Apps already using Liquid Glass get these improvements automatically without recompiling. The design adapts to accessibility settings (e.g., reduced transparency and increased contrast) and introduces colored sidebars, refined toolbar behavior and improved icon rendering with refraction effects.&lt;/p&gt;
&lt;h3 id=&quot;resizability-and-swiftui&quot;&gt;Resizability and SwiftUI&lt;/h3&gt;
&lt;p&gt;With iOS apps appearing on iPad and Mac via iPhone Mirroring, Apple introduced resizable iOS apps. Rebuilding with the latest SDK automatically opts an app into resizability. SwiftUI and auto layout provide basic support, while the new Resizable iOS Simulator and Previews let developers test across sizes. A built‑in agentic coding skill helps find and fix resizability issues.&lt;/p&gt;
&lt;p&gt;SwiftUI itself gains richer interactions such as reorderable containers, allowing drag‑to‑reorder lists and grids with minimal code. SwiftUI's improvements target speed, dynamic interactions and new capabilities, making it easier for developers and encouraging migration from cross‑platform technologies.&lt;/p&gt;
&lt;h3 id=&quot;xcode-27-enhanced-productivity-with-agentic-coding&quot;&gt;Xcode 27: Enhanced Productivity with Agentic Coding&lt;/h3&gt;
&lt;p&gt;Apple’s flagship IDE saw major updates. The new Xcode 27 focuses on two pillars: intelligence and daily usability. Performance improvements make projects load faster, debug sessions more reliable and the console capable of handling heavy logging. Xcode 27 is 30 % smaller, now runs only on Apple Silicon and downloads agents and documentation in the background to stay up‑to‑date.&lt;/p&gt;
&lt;p&gt;Personalization options now sync settings via iCloud. When setting up a new Mac, Xcode imports preferences and Git configuration automatically. Creating a new project jumps straight into the editor, deferring file names and bundle IDs until later. Developers can customize the toolbar and choose from new color themes such as Emerald, Neon Noir and Coral Reef. Xcode Cloud builds are up to twice as fast and require no App Store Connect setup, supporting Apple Vision Pro and Metal apps.&lt;/p&gt;
&lt;p&gt;A Device Hub merges the simulator and physical device management into one interface. It supports pinch‑to‑zoom, two‑finger scrolling, dynamic resizing and testing of various system settings like dark mode and text size.&lt;/p&gt;
&lt;h3 id=&quot;agentic-coding&quot;&gt;Agentic Coding&lt;/h3&gt;
&lt;p&gt;Xcode 27 weaves coding agents into every layer. Agents can plan, implement and test features. Developers start by describing their idea; the agent generates a plan, asks clarifying questions and produces code and UI previews. Agents can run tests in the simulator, localize strings, and even identify and fix crashes based on crash logs. Xcode 27 includes specialized skills—for SwiftUI, accessibility and performance—that help agents produce high‑quality code.&lt;/p&gt;
&lt;h3 id=&quot;other-notable-frameworks-and-tools&quot;&gt;Other Notable Frameworks and Tools&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Platform improvements: The new SDK makes apps launch faster and adds enhancements across frameworks, media, search and accessibility.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Game Porting Toolkit 4: Features agentic coding skills for Metal and game development, speeding up ports to Apple platforms.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Music Understanding framework: Enables six‑dimensional audio analysis and works with NowPlaying to integrate playback with Lock Screen, Control Center, Dynamic Island and CarPlay.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Photo and camera APIs: Core Image RAW processing APIs (version 9) provide improved sharpness and color, and new APIs support high‑resolution photo capture and Center Stage front‑camera features&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/20016/wwdc26-highlights</guid>
<pubDate>Tue, 09 Jun 2026 07:41:20 +0000</pubDate>
</item>
<item>
<title>How to Fix Xcode C++ Compiler Errors in React Native (Yoga / glog / constexpr)</title>
<link>https://coderlegion.com/19813/how-to-fix-xcode-c-compiler-errors-in-react-native-yoga-glog-constexpr</link>
<description>&lt;p&gt;You just upgraded your local development environment, updated Xcode, or bumped a minor patch version in your React Native project. You open your terminal, run your standard iOS build command, and instead of a clean bundling process, the terminal throws a massive wall of raw C++ compiler text at you.&lt;/p&gt;
&lt;p&gt;The error logs likely point to internal framework files like &lt;code&gt;Yoga.cpp&lt;/code&gt;, &lt;code&gt;glog&lt;/code&gt;, or the &lt;code&gt;fmt&lt;/code&gt; library, screaming about &lt;code&gt;constexpr&lt;/code&gt; or &lt;code&gt;consteval&lt;/code&gt; issues, or complaining that a specific language standard identifier is missing.&lt;/p&gt;
&lt;p&gt;This is one of the single most frustrating traps in mobile development. Your JavaScript is perfect. Your React components are clean. But your entire build is dead because an underlying C++ compilation standard is fighting your new Xcode tooling.&lt;/p&gt;
&lt;h2 id=&quot;why-this-happens-to-javascript-developers&quot;&gt;Why This Happens to JavaScript Developers&lt;/h2&gt;
&lt;p&gt;React Native relies heavily on a core C++ layout engine called Yoga under the hood. When Apple updates Xcode, they also update the underlying compiler tools (Clang) and shift the default C++ language dialect standard forward (for example, enforcing strict C++20 or C++23 rules).&lt;/p&gt;
&lt;p&gt;If an older version of a library in your &lt;code&gt;node_modules&lt;/code&gt; uses a syntax decoration that the new compiler now considers illegal or deprecated, the native compilation step fails instantly. Because most mobile developers do not actively write pure C++, looking at a raw Clang compiler failure feels like looking at alien code.&lt;/p&gt;
&lt;p&gt;Nuking &lt;code&gt;DerivedData&lt;/code&gt; or running &lt;code&gt;pod install&lt;/code&gt; twenty times will not change the fact that the compiler rules have changed.&lt;/p&gt;
&lt;h2 id=&quot;the-solution-force-the-c-language-standard-backwards&quot;&gt;The Solution: Force the C++ Language Standard Backwards&lt;/h2&gt;
&lt;p&gt;Instead of attempting to manually modify source code deep inside your native &lt;code&gt;node_modules&lt;/code&gt; dependencies (which will just get overwritten the next time you install packages), you can use a CocoaPods post-install hook to force the compiler to accept the specific language dialect your dependencies need to pass the check.&lt;/p&gt;
&lt;p&gt;Open the &lt;code&gt;Podfile&lt;/code&gt; located inside your project's native &lt;code&gt;ios&lt;/code&gt; directory. Scroll down to the bottom where your &lt;code&gt;post_install&lt;/code&gt; loop lives, and inject this compiler flag override block:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ruby&quot;&gt;post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      # Force the compiler to use the stable C++17 standard for all sub-dependencies
      config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17'
    end
  end
end
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By adding this configuration script, CocoaPods will automatically modify the native build files for every single sub-dependency when you run your installation pipeline, forcing Xcode to evaluate the underlying C++ libraries under a compatible standard framework.&lt;/p&gt;
&lt;h2 id=&quot;clear-the-native-caches-and-rebuild&quot;&gt;Clear the Native Caches and Rebuild&lt;/h2&gt;
&lt;p&gt;Once the project configurations are updated, you must completely destroy the old build artifacts that were compiled under the conflicting settings, or Xcode will continue to throw the same syntax exception.&lt;/p&gt;
&lt;p&gt;Run this quick command chain in your project terminal to wipe the native cache layers and execute a clean compilation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Clear Xcode's local compilation cache
rm -rf ~/Library/Developer/Xcode/DerivedData

# Re-evaluate the Podfile with the new compiler hook
cd ios
pod install --repo-update

# Return to root and boot the iOS simulator
cd ..
npx react-native run-ios
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As soon as the native build pipelines evaluate your sub-dependencies through the consistent compiler standard, the cryptic layout exceptions will vanish and your bundle will build cleanly.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/19813/how-to-fix-xcode-c-compiler-errors-in-react-native-yoga-glog-constexpr</guid>
<pubDate>Sat, 06 Jun 2026 09:39:12 +0000</pubDate>
</item>
<item>
<title>Essential Claude Code Skills for Mobile Developers</title>
<link>https://coderlegion.com/13848/essential-claude-code-skills-for-mobile-developers</link>
<description>&lt;p&gt;Claude Code skills are packaged folders of instructions, scripts and resources that teach the AI coding agent how to perform specialised tasks.&lt;/p&gt;
&lt;p&gt;Each skill has a &lt;code&gt;SKILL.md&lt;/code&gt; file containing YAML metadata and step by step instructions. Claude scans the names and descriptions of installed skills and loads the full content only when your request matches.&lt;/p&gt;
&lt;p&gt;Because skills are part of an open standard adopted by multiple agent frameworks, a single skill can work across Claude Code, OpenAI Codex, Cursor and other tools.&lt;/p&gt;
&lt;h3 id=&quot;installing-claude-code-skills&quot;&gt;Installing Claude Code skills&lt;/h3&gt;
&lt;p&gt;Skills can be installed globally or per project. Personal skills go into &lt;code&gt;~/.claude/skills/&lt;/code&gt; and become available across all your projects, while project specific skills live in &lt;code&gt;.claude/skills/&lt;/code&gt; inside your repository so everyone who clones the repo can use them.&lt;/p&gt;
&lt;p&gt;Most skills are plain Markdown folders: clone the repository and copy the skill directory into the appropriate folder. For example, the Swift Development skill’s README shows that you can install it by copying the Swift Development skill directory into &lt;code&gt;~/.claude/skills/&lt;/code&gt; and then invoking it via &lt;code&gt;/swift-development&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;recommended-skills-for-mobile-developers&quot;&gt;Recommended skills for mobile developers&lt;/h3&gt;
&lt;p&gt;Below is a curated list of Claude Code skills that are particularly valuable for mobile developers working with native iOS, Android or Kotlin Multiplatform (KMP). The list covers a range of design and development tasks. Each skill is linked to its repository or marketplace page along with a brief description.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Swift Development&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/hmohamed01/swift-development&quot;&gt;https://github.com/hmohamed01/swift-development&lt;/a&gt;&lt;br&gt;
This comprehensive skill turns Claude into a competent Swift engineer. It packages patterns for SwiftUI navigation, concurrency (async/await, actors) and testing frameworks, along with helper scripts for building, running tests, formatting code and managing simulators. Installation is simple: copy the Swift Development directory into your personal skills folder and invoke it with &lt;code&gt;/swift-development&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;iOS Mobile Design&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://mcpmarket.com/tools/skills/ios-mobile-design&quot;&gt;https://mcpmarket.com/tools/skills/ios-mobile-design&lt;/a&gt;&lt;br&gt;
For designers and developers building polished iOS apps, this skill provides expert guidance on Apple’s Human Interface Guidelines. It teaches Claude how to apply SwiftUI layout systems, dynamic type, SF Symbols and modern navigation patterns so interfaces feel native to iPhone, iPad and visionOS. Key features include accessible design with dynamic type, responsive layouts, Material You style theming and Dark Mode optimisation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;iOS App Builder&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://mcpmarket.com/tools/skills/ios-app-builder&quot;&gt;https://mcpmarket.com/tools/skills/ios-app-builder&lt;/a&gt;&lt;br&gt;
This skill transforms Claude into a senior iOS developer who builds and ships native apps using a command line only workflow. It uses Xcode’s xcodebuild and simctl tools to scaffold projects, run tests and launch apps without opening Xcode. The skill emphasises a “prove, don’t promise” methodology: it verifies logic through tests and simulator screenshots, manages the full app life cycle from scaffolding to App Store readiness and includes features like performance profiling and memory leak detection&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SwiftUI agent skill&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/twostraws/swiftui-agent-skill&quot;&gt;https://github.com/twostraws/swiftui-agent-skill&lt;/a&gt;&lt;br&gt;
A SwiftUI skill create by Paul Hudson (hacking with swift).&lt;br&gt;
This skill helps identify and fix common mistakes AI agents make when writing SwiftUI – things like modern API usage, performance, accessibility, and more.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Android Development&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/dpconde/claude-android-skill&quot;&gt;https://github.com/dpconde/claude-android-skill&lt;/a&gt;&lt;br&gt;
Designed for modern Android projects, this skill teaches Claude clean architecture with separate UI, domain and data layers. It covers Jetpack Compose patterns and best practices, multi module project structures, offline first architecture using Room and reactive streams, dependency injection with Hilt and comprehensive testing strategies. After cloning the repository into your skills folder, you can ask the agent to scaffold features, create Compose screens or set up repositories following Android best practices.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Android Kotlin Development&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://fastmcp.me/skills/details/241/android-kotlin-development&quot;&gt;https://fastmcp.me/skills/details/241/android-kotlin-development&lt;/a&gt;&lt;br&gt;
This skill focuses on building native Android apps with Kotlin. The description explains that it covers Model-View-ViewModel architecture with Jetpack, Compose for declarative user interfaces, Retrofit for API calls, Room for local storage and navigation patterns. It includes detailed instructions and code samples for models, API services and MVVM view models, making it a practical guide for Kotlin developers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mobile Android Design&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://fastmcp.me/skills/details/644/mobile-android-design&quot;&gt;https://fastmcp.me/skills/details/644/mobile-android-design&lt;/a&gt;&lt;br&gt;
When you need to design Android interfaces that follow Google’s Material Design 3 guidelines, this skill is indispensable. It teaches Claude how to build adaptive layouts with Jetpack Compose, implement navigation patterns like bottom navigation and drawers, use dynamic colour and Material You theming, and create accessible UI for phones, tablets and foldables. It is especially helpful for modernising existing apps or ensuring new designs comply with Material Design principles.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Jetpack Compose Expert&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aldefy/compose-skill&quot;&gt;https://github.com/aldefy/compose-skill&lt;/a&gt;&lt;br&gt;
This skill provides Claude with real source code and extensive reference guides for Jetpack Compose. Without it, AI tools often misuse remember, generate inefficient recompositions or misorder modifiers; with the skill, Claude picks the right state primitive, applies stability annotations and orders modifiers correctly. The reference guides cover state management, view composition, performance, navigation, animation, lists, side effects, modifiers, theming, accessibility and deprecated patterns. Installation requires cloning the repository and copying the Jetpack Compose expert skill directory into your skills folder; Claude will automatically activate it when you mention Compose or related API names.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kotlin Multiplatform (KMP) Abstraction Guide&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://fastmcp.me/skills/details/2190/kotlin-multiplatform&quot;&gt;https://fastmcp.me/skills/details/2190/kotlin-multiplatform&lt;/a&gt;&lt;br&gt;
KMP projects often involve tough decisions about what to share between platforms and what to keep platform specific. This skill guides those decisions by providing a platform abstraction decision tree. It explains when to use &lt;code&gt;commonMain&lt;/code&gt;, &lt;code&gt;jvmAndroid&lt;/code&gt; or &lt;code&gt;expect/actual&lt;/code&gt; patterns, covers targets like Android, JVM/Desktop and iOS, and offers triggers for questions such as &quot;should I share this ViewModel?&quot;. It helps developers place code correctly and prepare for future web or WebAssembly targets.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13848/essential-claude-code-skills-for-mobile-developers</guid>
<pubDate>Thu, 02 Apr 2026 07:22:03 +0000</pubDate>
</item>
<item>
<title>I open-sourced 5 tiny SwiftUI utilities I use in every project</title>
<link>https://coderlegion.com/13389/i-open-sourced-5-tiny-swiftui-utilities-i-use-in-every-project</link>
<description>&lt;p&gt;Sharing 5 lightweight SwiftUI packages I built — keyboard avoider, scroll offset tracker, shimmer effect, flow layout, and App Store review link&lt;/p&gt;
&lt;p&gt;Hey everyone! I've been building iOS apps for a while and kept copying the same utilities across projects, so I finally packaged them up as SPM libraries.&lt;/p&gt;
&lt;h4 id=&quot;1-swiftui-keyboard-avoider&quot;&gt;1. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tjdrhs90/swiftui-keyboard-avoider&quot;&gt;swiftui-keyboard-avoider&lt;/a&gt;&lt;/h4&gt;
&lt;p&gt;One-line modifier that moves your view when the keyboard appears.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;TextField(&quot;Email&quot;, text: $email)
  .keyboardAvoider()
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;2-swiftui-scroll-offset&quot;&gt;2. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tjdrhs90/swiftui-scroll-offset&quot;&gt;swiftui-scroll-offset&lt;/a&gt;&lt;/h4&gt;
&lt;p&gt;Track ScrollView offset — great for collapsing headers.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;OffsetTrackingScrollView { offset in
  print(offset.y)
} content: {
  // your content
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;3-swiftui-shimmer-loading&quot;&gt;3. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tjdrhs90/swiftui-shimmer-loading&quot;&gt;swiftui-shimmer-loading&lt;/a&gt;&lt;/h4&gt;
&lt;p&gt;Shimmer / skeleton loading effect for any view.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;Text(&quot;Loading...&quot;)
  .shimmer()
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;4-swiftui-flow-layout&quot;&gt;4. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tjdrhs90/swiftui-flow-layout&quot;&gt;swiftui-flow-layout&lt;/a&gt;&lt;/h4&gt;
&lt;p&gt;Wrapping HStack for tags and chips. Uses the Layout protocol.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;FlowLayout(spacing: 8) {
  ForEach(tags, id: \.self) { Text($0) }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;5-ios-appstore-review-link&quot;&gt;5. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tjdrhs90/ios-appstore-review-link&quot;&gt;ios-appstore-review-link&lt;/a&gt;&lt;/h4&gt;
&lt;p&gt;Open App Store review page with one line.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;AppStoreReview.open(appID: &quot;123456789&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or grab them all at once: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/tjdrhs90/swiftui-essentials&quot;&gt;SwiftUI Essentials&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;All MIT licensed, zero dependencies. Would love any feedback or suggestions!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/13389/i-open-sourced-5-tiny-swiftui-utilities-i-use-in-every-project</guid>
<pubDate>Mon, 23 Mar 2026 14:37:01 +0000</pubDate>
</item>
<item>
<title>How a Bug Report Led Me to Give Flutter Developers Full Control of Crisp Chat Modals on iOS</title>
<link>https://coderlegion.com/12886/how-a-bug-report-led-me-to-give-flutter-developers-full-control-of-crisp-chat-modals-on-ios</link>
<description>&lt;p&gt;One of the things I enjoy most about maintaining open-source software is that sometimes the best improvements start with something very small.&lt;/p&gt;
&lt;p&gt;A simple bug report.&lt;br&gt;
A curious investigation.&lt;br&gt;
And sometimes… a new feature idea.&lt;/p&gt;
&lt;p&gt;This story started exactly like that.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-bug-report-that-started-everything&quot;&gt;The Bug Report That Started Everything&lt;/h3&gt;
&lt;p&gt;While reviewing issues for the Flutter Crisp Chat plugin, I noticed a &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/alamin-karno/flutter-crisp-chat/issues/123&quot;&gt;report&lt;/a&gt; opened by &lt;strong&gt;Nikita Alexeev&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The title immediately caught my attention:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;“Taps pass through chat modal to underlying UI on iOS 13+.”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That sounded strange.&lt;/p&gt;
&lt;p&gt;A modal should block interactions with the UI behind it. So how could taps reach the Flutter app underneath?&lt;/p&gt;
&lt;p&gt;I opened the issue and read the details carefully.&lt;/p&gt;
&lt;p&gt;The behavior was simple but frustrating:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open the Crisp chat on an iPhone&lt;/li&gt;
&lt;li&gt;Tap near the top area of the chat&lt;/li&gt;
&lt;li&gt;Suddenly the Flutter UI behind it reacts&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Buttons could be pressed. Navigation could trigger. All while the chat was still visible.&lt;/p&gt;
&lt;p&gt;That’s definitely not the experience anyone wants when they open a support chat.&lt;/p&gt;
&lt;p&gt;So I started digging.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;learning-something-interesting-about-ios&quot;&gt;Learning Something Interesting About iOS&lt;/h3&gt;
&lt;p&gt;While reading the issue discussion and the explanation from Nikita Alexeev, I discovered something interesting about iOS.&lt;/p&gt;
&lt;p&gt;Starting with &lt;strong&gt;iOS 13&lt;/strong&gt;, Apple changed how modal presentations behave by default.&lt;/p&gt;
&lt;p&gt;Before iOS 13, most modals were presented using:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;.fullScreen&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Which means the modal covers the entire screen. Nothing behind it is visible, and more importantly, nothing behind it can receive touches.&lt;/p&gt;
&lt;p&gt;But iOS 13 introduced a new default style:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;.pageSheet&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This creates the modern sheet-style UI where the modal slides up from the bottom and leaves part of the background visible.&lt;/p&gt;
&lt;p&gt;It looks elegant and more native to modern iOS design.&lt;/p&gt;
&lt;p&gt;But there was a catch.&lt;/p&gt;
&lt;p&gt;In this case, the dimmed background area was still allowing touch events to reach the Flutter UI underneath.&lt;/p&gt;
&lt;p&gt;That explained exactly why users were accidentally triggering buttons behind the chat window.&lt;/p&gt;
&lt;p&gt;Suddenly the bug made perfect sense.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-pull-request-that-fixed-it&quot;&gt;The Pull Request That Fixed It&lt;/h3&gt;
&lt;p&gt;Soon after reporting the issue, Nikita Alexeev submitted a &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/alamin-karno/flutter-crisp-chat/pull/124&quot;&gt;pull request&lt;/a&gt; with a very straightforward fix.&lt;/p&gt;
&lt;p&gt;Instead of relying on the default modal style, he forced the chat view controller to use full screen presentation.&lt;/p&gt;
&lt;p&gt;The change looked like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;let chatVC = ChatViewController()
chatVC.modalPresentationStyle = .fullScreen
viewController.present(chatVC, animated: true)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It was a very small change, but it solved the problem completely.&lt;/p&gt;
&lt;p&gt;By forcing &lt;code&gt;.fullScreen&lt;/code&gt;, the chat now covered the entire screen again, which meant no touches could leak through to the Flutter UI behind it.&lt;/p&gt;
&lt;p&gt;The issue was fixed.&lt;/p&gt;
&lt;p&gt;But while reviewing the pull request, a new idea came to my mind.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-moment-the-idea-appeared&quot;&gt;The Moment the Idea Appeared&lt;/h3&gt;
&lt;p&gt;Yes, &lt;code&gt;.fullScreen&lt;/code&gt; solved the issue.&lt;/p&gt;
&lt;p&gt;But iOS actually supports several modal presentation styles.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.fullScreen&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.pageSheet&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.formSheet&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.overFullScreen&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.overCurrentContext&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.popover&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Different apps use different modal styles depending on their design.&lt;/p&gt;
&lt;p&gt;Some apps prefer full screen.&lt;/p&gt;
&lt;p&gt;Some prefer sheet-style modals.&lt;/p&gt;
&lt;p&gt;Some prefer overlays.&lt;/p&gt;
&lt;p&gt;So instead of hardcoding &lt;code&gt;.fullScreen&lt;/code&gt;, I thought:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What if Flutter developers could choose the modal style themselves?&lt;br&gt;
&lt;/strong&gt;&lt;br&gt;
That way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The touch-through bug stays fixed&lt;/li&gt;
&lt;li&gt;Developers get flexibility&lt;/li&gt;
&lt;li&gt;The plugin becomes more powerful&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s when the idea shifted from a &lt;strong&gt;bug fix&lt;/strong&gt; to a &lt;strong&gt;developer-focused feature&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;turning-the-idea-into-a-feature&quot;&gt;Turning the Idea into a Feature&lt;/h3&gt;
&lt;p&gt;To make this possible, I needed to expose the modal presentation style all the way from Flutter to the native iOS implementation.&lt;/p&gt;
&lt;p&gt;So I started with the Flutter configuration.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-extending-the-flutter-configuration&quot;&gt;Step 1 — Extending the Flutter Configuration&lt;/h3&gt;
&lt;p&gt;First, I added a new enum to represent the iOS modal styles.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum ModalPresentationStyle {
  fullScreen,
  pageSheet,
  formSheet,
  overFullScreen,
  overCurrentContext,
  popover,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I added it to the &lt;code&gt;CrispConfig&lt;/code&gt; class.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;class CrispConfig {
  final String websiteID;
  final String? tokenId;
  final ModalPresentationStyle? modalPresentationStyle;

  CrispConfig({
    required this.websiteID,
    this.tokenId,
    this.modalPresentationStyle,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now Flutter developers could specify how the chat modal should appear.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-bridging-the-setting-to-ios&quot;&gt;Step 2 — Bridging the Setting to iOS&lt;/h3&gt;
&lt;p&gt;Next, I needed to convert that Flutter value into a native iOS presentation style.&lt;/p&gt;
&lt;p&gt;Inside the Swift plugin code, I mapped the configuration to &lt;code&gt;UIModalPresentationStyle&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;switch modalPresentationStyleString {
case &quot;fullScreen&quot;:
    modalPresentationStyle = .fullScreen
case &quot;pageSheet&quot;:
    modalPresentationStyle = .pageSheet
case &quot;formSheet&quot;:
    modalPresentationStyle = .formSheet
case &quot;overFullScreen&quot;:
    modalPresentationStyle = .overFullScreen
case &quot;overCurrentContext&quot;:
    modalPresentationStyle = .overCurrentContext
case &quot;popover&quot;:
    modalPresentationStyle = .popover
default:
    modalPresentationStyle = .fullScreen
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This ensured a safe default while still allowing flexibility.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-applying-the-configuration&quot;&gt;Step 3 — Applying the Configuration&lt;/h3&gt;
&lt;p&gt;Finally, instead of hardcoding &lt;code&gt;.fullScreen&lt;/code&gt;, the plugin now uses the configured style.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;let chatVC = ChatViewController()
chatVC.modalPresentationStyle = crispConfig.modalPresentationStyle

viewController.present(chatVC, animated: true)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now Flutter developers have full control over the presentation style.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;making-the-feature-easy-to-discover&quot;&gt;Making the Feature Easy to Discover&lt;/h3&gt;
&lt;p&gt;After implementing the improvements, I wanted to make sure developers could easily explore and understand them.&lt;/p&gt;
&lt;p&gt;So I also improved the developer experience around the plugin.&lt;/p&gt;
&lt;p&gt;I added an &lt;strong&gt;interactive demo inside the example app&lt;/strong&gt; where developers can test different iOS modal styles.&lt;/p&gt;
&lt;p&gt;There’s a button called “&lt;strong&gt;iOS Modal Style Demo&lt;/strong&gt;” that opens the chat using different presentation styles so developers can see how each one behaves.&lt;/p&gt;
&lt;p&gt;I also updated the documentation to clearly explain:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How iOS modal presentation works&lt;/li&gt;
&lt;li&gt;Why .fullScreen fixes the touch-through issue&lt;/li&gt;
&lt;li&gt;How to configure the modal style in Flutter&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-developers-gain-from-this&quot;&gt;What Developers Gain From This&lt;/h3&gt;
&lt;p&gt;What started as a small bug report now gives Flutter developers several benefits.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Better User Experience&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;No more accidental taps on the UI behind the chat.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Flexible Modal Presentation&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Developers can choose the modal style that fits their app design.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Platform Awareness&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;These changes only affect iOS behavior, while Android remains unchanged.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-love-about-open-source&quot;&gt;What I Love About Open Source&lt;/h3&gt;
&lt;p&gt;This experience reminded me why I enjoy maintaining open-source projects.&lt;/p&gt;
&lt;p&gt;Sometimes improvements don’t start with big feature ideas.&lt;/p&gt;
&lt;p&gt;They start with a simple issue report.&lt;/p&gt;
&lt;p&gt;A developer noticing something strange.&lt;/p&gt;
&lt;p&gt;A pull request proposing a fix.&lt;/p&gt;
&lt;p&gt;And a moment of curiosity that turns a bug fix into something better.&lt;/p&gt;
&lt;p&gt;So huge thanks to &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/aspid168&quot;&gt;Nikita Alexeev&lt;/a&gt; for reporting the issue and submitting the original fix that inspired this improvement.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-it-yourself-1&quot;&gt;Try It Yourself&lt;/h3&gt;
&lt;p&gt;If you’re using the &lt;a rel=&quot;nofollow&quot; href=&quot;https://pub.dev/packages/crisp_chat&quot;&gt;Flutter Crisp Chat&lt;/a&gt; plugin, you can try the new configuration like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dart&quot;&gt;final config = CrispConfig(
  websiteID: 'YOUR_WEBSITE_ID',
  modalPresentationStyle: ModalPresentationStyle.pageSheet,
  enableNotifications: false,
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And if you’re curious to see how each modal style behaves, check out the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/alamin-karno/flutter-crisp-chat&quot;&gt;example&lt;/a&gt; app in the repository.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Sometimes the best features don’t start with a roadmap.&lt;/p&gt;
&lt;p&gt;They start with a bug report…&lt;br&gt;
and a little curiosity.&lt;/p&gt;
</description>
<guid isPermaLink="true">https://coderlegion.com/12886/how-a-bug-report-led-me-to-give-flutter-developers-full-control-of-crisp-chat-modals-on-ios</guid>
<pubDate>Sun, 15 Mar 2026 07:25:41 +0000</pubDate>
</item>
<item>
<title>React Native Quote Audit - USA</title>
<link>https://coderlegion.com/12312/react-native-quote-audit-usa</link>
<description>&lt;p&gt;&lt;strong&gt;A Quick Test to See if your Quote is Missing Money.&lt;/strong&gt;&lt;br&gt;
If you are building a React Native app in the USA, a quote can look fine and still be missing big items.&lt;/p&gt;
&lt;p&gt;Missing items do not stay missing.&lt;br&gt;
They show up later as extra invoices, delays, and stress.&lt;/p&gt;
&lt;p&gt;This post is a simple quote audit.&lt;br&gt;
You can use it in 10 minutes before you sign.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Download the printable checklist PDF&lt;/strong&gt;&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.budventure.technology/blog/react-native-app-development-cost-usa&quot;&gt;React Native Budget Checklist USA 2026&lt;/a&gt;&lt;br&gt;
Use it while reading the quote. Use it again during kickoff.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Section 1&lt;br&gt;
---------&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A Quick Test&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Is your quote missing money?&lt;/strong&gt;&lt;br&gt;
Answer yes or no.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Does the quote show a clear list of what is included&lt;/li&gt;
&lt;li&gt;Does it show what is not included&lt;/li&gt;
&lt;li&gt;Does it include testing time and bug fixing time&lt;/li&gt;
&lt;li&gt;Does it include store release work for iOS and Android&lt;/li&gt;
&lt;li&gt;Does it include payment failure cases and double order prevention&lt;/li&gt;
&lt;li&gt;Does it include device testing on more than two phones&lt;/li&gt;
&lt;li&gt;Does it include who owns accounts, code, and access&lt;/li&gt;
&lt;li&gt;Does it include change request rules and pricing&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you said no to three or more, the quote is likely missing money.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Section 2&lt;br&gt;
---------&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;The 5 line items that are usually missing from quotes&lt;/strong&gt;&lt;br&gt;
These five items are where cost and time jump.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Testing and bug fixing&lt;/strong&gt;&lt;br&gt;
Many quotes say development, but they hide testing.&lt;/p&gt;
&lt;p&gt;What this should include&lt;br&gt;
QA testing time&lt;br&gt;
Bug fixing time&lt;br&gt;
Retesting time&lt;br&gt;
Basic regression testing before release&lt;/p&gt;
&lt;p&gt;If it is missing, you will pay later.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Device list and device testing&lt;/strong&gt;&lt;br&gt;
Testing on one iPhone and one Android is not real testing.&lt;/p&gt;
&lt;p&gt;What this should include&lt;br&gt;
A short device list&lt;br&gt;
At least one mid range Android phone&lt;br&gt;
A plan for bugs that happen on one device only&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Store release work&lt;/strong&gt;&lt;br&gt;
Many quotes end at build ready.&lt;br&gt;
Real work still remains.&lt;/p&gt;
&lt;p&gt;What this should include&lt;br&gt;
Signing and build setup&lt;br&gt;
Store listing details&lt;br&gt;
Screenshots&lt;br&gt;
Privacy forms&lt;br&gt;
Review notes&lt;br&gt;
Buffer time for rejection and resubmission&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Payments and failure cases&lt;/strong&gt;&lt;br&gt;
If your app has payments, the happy path is not enough.&lt;/p&gt;
&lt;p&gt;What this should include&lt;br&gt;
Payment failed handling&lt;br&gt;
Payment success but order not confirmed&lt;br&gt;
User taps pay again&lt;br&gt;
Status checks to stop duplicate orders&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Upload failure handling&lt;/strong&gt;&lt;br&gt;
If your app has uploads, failures will happen.&lt;/p&gt;
&lt;p&gt;What this should include&lt;br&gt;
Slow internet handling&lt;br&gt;
Retry steps&lt;br&gt;
Clear error messages&lt;br&gt;
Safe continue steps so users do not get stuck&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Section 3&lt;br&gt;
---------&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Questions to ask your developer before you sign&lt;/strong&gt;&lt;br&gt;
Ask these questions in a call.&lt;br&gt;
If answers are vague, expect trouble.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scope and plan&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What is out of scope for version one&lt;/li&gt;
&lt;li&gt;What are the hard screens in this app &lt;/li&gt;
&lt;li&gt;Which parts have the most risk&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;QA and devices&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What is your test device list&lt;/li&gt;
&lt;li&gt;How many rounds of QA are included&lt;/li&gt;
&lt;li&gt;Is bug fixing included or billed extra&lt;/li&gt;
&lt;li&gt;What happens when a bug only happens on one Android model&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Payments and uploads&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;How will you stop duplicate orders if payment is slow&lt;/li&gt;
&lt;li&gt;How will you handle payment success but order not updated&lt;/li&gt;
&lt;li&gt;How will uploads work on slow internet&lt;/li&gt;
&lt;li&gt;What happens when users deny camera or photo access&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Release and ownership&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Who owns the Apple and Google store accounts&lt;/li&gt;
&lt;li&gt;Who owns the code repo&lt;/li&gt;
&lt;li&gt;Will you hand over all keys and access at the end&lt;/li&gt;
&lt;li&gt;How many days do you keep for store review and fixes&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Change control&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;How do you price a change request&lt;/li&gt;
&lt;li&gt;What counts as a change&lt;/li&gt;
&lt;li&gt;Who approves changes and how fast&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If the team cannot answer these clearly, the quote is not ready.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Section 4&lt;br&gt;
---------&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Red flags checklist&lt;/strong&gt;&lt;br&gt;
Here are the red flags. If you see these, pause.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Red flag 1&lt;/strong&gt;&lt;br&gt;
One total number with no phases&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Red flag 2&lt;/strong&gt;&lt;br&gt;
No QA time mentioned&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Red flag 3&lt;/strong&gt;&lt;br&gt;
No device list mentioned&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Red flag 4&lt;/strong&gt;&lt;br&gt;
No store release work mentioned&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Red flag 5&lt;/strong&gt;&lt;br&gt;
Payments included but no talk about failure cases&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Red flag 6&lt;/strong&gt;&lt;br&gt;
Uploads included but no talk about slow internet failures&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Red flag 7&lt;/strong&gt;&lt;br&gt;
No clear list of what is included and not included&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Red flag 8&lt;/strong&gt;&lt;br&gt;
No mention of who owns the repo and accounts&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Red flag 9&lt;/strong&gt;&lt;br&gt;
No change request rules&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Section 5&lt;br&gt;
---------&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Example&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Medicine delivery app quote audit&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Example app&lt;br&gt;
A healthcare shopping app that sells medicines and daily use products.&lt;/p&gt;
&lt;p&gt;Core features&lt;br&gt;
Product list&lt;br&gt;
Product page&lt;br&gt;
Cart&lt;br&gt;
Checkout&lt;br&gt;
Order tracking&lt;br&gt;
Prescription upload for some medicines&lt;br&gt;
Notifications for order updates&lt;/p&gt;
&lt;p&gt;Now the audit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What many quotes cover&lt;/strong&gt;&lt;br&gt;
Screens and basic flows&lt;br&gt;
Basic backend endpoints&lt;br&gt;
Basic login&lt;br&gt;
Basic checkout screen&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What many quotes miss&lt;/strong&gt;&lt;br&gt;
Prescription upload details&lt;br&gt;
Camera and photo permissions&lt;br&gt;
Users deny access&lt;br&gt;
Slow internet upload failures&lt;br&gt;
Retry steps and clear messages&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Payment edge cases&lt;/strong&gt;&lt;br&gt;
Payment success but order status not updated&lt;br&gt;
Users retry and create duplicate orders&lt;br&gt;
Need status checks and duplicate order prevention&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Device testing&lt;/strong&gt;&lt;br&gt;
Android device differences&lt;br&gt;
Notifications blocked by battery settings&lt;br&gt;
Camera issues on certain models&lt;/p&gt;
&lt;p&gt;Store release&lt;br&gt;
Privacy forms&lt;br&gt;
Permission text&lt;br&gt;
Store review changes&lt;br&gt;
Resubmission buffer&lt;/p&gt;
&lt;p&gt;If the quote does not mention these items, the quote is missing money.&lt;/p&gt;
&lt;p&gt;This is why a simple app can move from 8 weeks to 14 weeks.&lt;br&gt;
Not because of new screens.&lt;br&gt;
Because missing items show up late.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Section 6&lt;br&gt;
---------&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;What a good quote looks like&lt;/strong&gt;&lt;br&gt;
A good quote is split into clear parts. Each part has a line item and a clear output.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Discovery&lt;/strong&gt;&lt;br&gt;
What it should include&lt;br&gt;
Requirements list&lt;br&gt;
User flows&lt;br&gt;
Out of scope list&lt;br&gt;
Hard screens list&lt;br&gt;
Risk list&lt;/p&gt;
&lt;p&gt;What you should receive&lt;br&gt;
A written scope document&lt;br&gt;
A clear list of what is included and not included&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Design&lt;/strong&gt;&lt;br&gt;
What it should include&lt;br&gt;
Key screens&lt;br&gt;
All states for key screens&lt;br&gt;
Error messages&lt;br&gt;
Permission screens&lt;/p&gt;
&lt;p&gt;What you should receive&lt;br&gt;
Final screen designs&lt;br&gt;
A simple flow for each key feature&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Development&lt;/strong&gt;&lt;br&gt;
What it should include&lt;br&gt;
App build work&lt;br&gt;
Backend work&lt;br&gt;
Admin work if needed&lt;/p&gt;
&lt;p&gt;What you should receive&lt;br&gt;
Working builds you can test&lt;br&gt;
Access to the code repo&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Integrations&lt;/strong&gt;&lt;br&gt;
What it should include&lt;br&gt;
Payments&lt;br&gt;
Push notifications&lt;br&gt;
Analytics&lt;br&gt;
Uploads&lt;br&gt;
Maps or other tools&lt;/p&gt;
&lt;p&gt;What you should receive&lt;br&gt;
A list of all tools used&lt;br&gt;
Proof that each tool works on iOS and Android&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. QA&lt;/strong&gt;&lt;br&gt;
What it should include&lt;br&gt;
Device list&lt;br&gt;
Test plan for key flows&lt;br&gt;
Bug fixing time&lt;br&gt;
Retesting time&lt;/p&gt;
&lt;p&gt;What you should receive&lt;br&gt;
A test report or bug list&lt;br&gt;
A stable build before release&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Release&lt;/strong&gt;&lt;br&gt;
What it should include&lt;br&gt;
Signing and build setup&lt;br&gt;
Store listing tasks&lt;br&gt;
Screenshots&lt;br&gt;
Privacy forms&lt;br&gt;
Time buffer for review and resubmission&lt;/p&gt;
&lt;p&gt;What you should receive&lt;br&gt;
Store ready builds&lt;br&gt;
A release checklist&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;7. Handover&lt;/strong&gt;&lt;br&gt;
What it should include&lt;br&gt;
Repo access&lt;br&gt;
Accounts access&lt;br&gt;
Keys&lt;br&gt;
Basic documentation&lt;/p&gt;
&lt;p&gt;What you should receive&lt;br&gt;
Full ownership and access&lt;br&gt;
A handover checklist&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;8. Change rules&lt;/strong&gt;&lt;br&gt;
What it should include&lt;br&gt;
What counts as a change&lt;br&gt;
How changes are priced&lt;br&gt;
Who approves changes&lt;/p&gt;
&lt;p&gt;What you should receive&lt;br&gt;
Clear change request rules in writing&lt;/p&gt;
&lt;p&gt;If your quote does not look like this, ask for a rewrite.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/12312/react-native-quote-audit-usa</guid>
<pubDate>Mon, 02 Mar 2026 14:30:00 +0000</pubDate>
</item>
<item>
<title>App Maintenance In The USA (2026): $500 To $25,000 Per Month Depending On Stage</title>
<link>https://coderlegion.com/11878/app-maintenance-in-the-usa-2026-500-to-25-000-per-month-depending-on-stage</link>
<description>&lt;p&gt;Most founders budget the build. Then launch happens, and the monthly bills start.&lt;/p&gt;
&lt;p&gt;In the United States, app maintenance is not just bug fixes. These include updates, store submissions, OS changes, payments, crashes, analytics cleanup, and support issues that only appear with real users.&lt;/p&gt;
&lt;p&gt;Here are a few numbers from our 2026 benchmark page that surprised people on calls.&lt;/p&gt;
&lt;h3 id=&quot;what-most-us-startups-pay-per-month&quot;&gt;&lt;strong&gt;What Most US Startups Pay Per Month&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;These are planning ranges, not quotes, but they are useful when you are setting budgets.&lt;/p&gt;
&lt;p&gt;Small MVP with light traffic:&lt;br&gt;
500 to 2,000 USD per month&lt;br&gt;
6,000 to 24,000 USD per year&lt;/p&gt;
&lt;p&gt;Growing app with regular releases:&lt;br&gt;
2,000 to 7,000 USD per month&lt;br&gt;
24,000 to 84,000 USD per year&lt;/p&gt;
&lt;p&gt;Complex app with revenue and multiple integrations:&lt;br&gt;
7,000 to 25,000 USD per month&lt;br&gt;
84,000 to 300,000 USD per year or more&lt;/p&gt;
&lt;p&gt;A quick rule many teams use: maintenance often lands around &lt;strong&gt;15 to 25 percent of the original build cost per year&lt;/strong&gt;, depending on complexity and release frequency.&lt;/p&gt;
&lt;h3 id=&quot;the-hidden-calendar-cost-nobody-mentions&quot;&gt;&lt;strong&gt;The Hidden Calendar Cost Nobody Mentions&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;Even if your fix is ready today, store review cycles still add time.&lt;/p&gt;
&lt;p&gt;Apple’s own guidance says about &lt;strong&gt;50 percent of apps are reviewed within 24 hours&lt;/strong&gt; and &lt;strong&gt;90 percent within 48 hours&lt;/strong&gt; on average. That sounds fast until you hit resubmissions, missing test accounts, or policy changes. So a small fix can take days on the calendar.&lt;/p&gt;
&lt;h3 id=&quot;we-looked-at-30-agency-proposals-and-found-the-same-gaps&quot;&gt;&lt;strong&gt;We Looked At 30 Agency Proposals And Found The Same Gaps&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;We did a small review of 30 proposals and statements of work. It's not a perfect study, but the pattern was loud.&lt;/p&gt;
&lt;p&gt;These were missing or unclear most often:&lt;br&gt;
Dependency and library upgrade cadence&lt;br&gt;
Missing in 24 of 30 proposals, about 80 percent&lt;/p&gt;
&lt;p&gt;QA device and OS test matrix&lt;br&gt;
Missing in 23 of 30 proposals, about 77 percent&lt;/p&gt;
&lt;p&gt;Push notification rules like quiet hours and caps&lt;br&gt;
Missing in 22 of 30 proposals, about 73 percent&lt;/p&gt;
&lt;p&gt;Bug severity definitions and response time&lt;br&gt;
Missing in 21 of 30 proposals, about 70 percent&lt;/p&gt;
&lt;p&gt;Analytics scope and event naming&lt;br&gt;
Missing in 20 of 30 proposals, about 67 percent&lt;/p&gt;
&lt;p&gt;These gaps are exactly where surprise fees show up after launch.&lt;/p&gt;
&lt;h3 id=&quot;if-you-are-budgeting-for-2026-this-page-helps-you&quot;&gt;&lt;strong&gt;If You Are Budgeting For 2026, This Page Helps You&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;The full blog includes:&lt;br&gt;
Monthly retainer tier you can use for vendor calls&lt;br&gt;
A contract ready maintenance line items table&lt;br&gt;
A copy paste maintenance checklist for weekly, monthly, quarterly&lt;br&gt;
When it's smarter to rebuild instead of keep patching&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.budventure.technology/blog/app-maintenance-cost-usa-2026&quot;&gt;Read it here!&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If you are a founder or dev, what was the most annoying after launch cost you ran into?&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/11878/app-maintenance-in-the-usa-2026-500-to-25-000-per-month-depending-on-stage</guid>
<pubDate>Wed, 18 Feb 2026 09:42:29 +0000</pubDate>
</item>
<item>
<title>Magic Beyond Hogwarts: How to Build &quot;Expensive&quot; UI Without Overloading the GPU</title>
<link>https://coderlegion.com/11225/magic-beyond-hogwarts-how-to-build-expensive-ui-without-overloading-the-gpu</link>
<description>&lt;p&gt;You've probably seen this scenario before: a developer falls in love with a concept from Dribbble, spends two days implementing the most complex transition chain, and then wonders why their iPhone 15 Pro starts heating up after five minutes of app usage. Animation is always a deal with the devil (hardware). The question is just how favorable an exchange rate you can negotiate.&lt;/p&gt;
&lt;h4 id=&quot;the-price-of-every-frame-cpu-gpu-and-battery&quot;&gt;The Price of Every Frame: CPU, GPU, and Battery&lt;/h4&gt;
&lt;p&gt;When we launch an animation, we force the system to work at its limit for a short period of time. In an ideal world (60 or 120 FPS), we have about 8–16 milliseconds to prepare a frame.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CPU (Layout &amp;amp; Preparation):&lt;/strong&gt; This is where the most &quot;expensive&quot; stuff happens. Recalculating constraints, creating view hierarchies, calculating paths - all of this falls on the central processor's shoulders. If you animate &lt;code&gt;frame&lt;/code&gt; or &lt;code&gt;bounds&lt;/code&gt; on a view with tons of child elements, expect trouble: the system will call &lt;code&gt;layoutSubviews()&lt;/code&gt; at every step.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GPU (Rendering):&lt;/strong&gt; The graphics chip takes care of rasterization and layer compositing. The main enemies here are transparency (Alpha Blend), blur (Visual Effects), and shadows without a specified &lt;code&gt;shadowPath&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Battery:&lt;/strong&gt; This is the &quot;invisible&quot; metric. High CPU/GPU Usage directly converts into percentage points of charge. Users might not notice a drop to 55 FPS, but they'll definitely notice that your messenger &quot;ate&quot; 20% of their battery in half an hour.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;implicit-animations-the-magic-of-withtransaction&quot;&gt;Implicit Animations: The Magic of &lt;code&gt;withTransaction&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;In SwiftUI, we're used to &lt;code&gt;.animation(.easeInOut, value: target)&lt;/code&gt;. But sometimes we need finer control over the process, especially when we want to synchronize changes or suppress animation for specific elements.&lt;/p&gt;
&lt;p&gt;I prefer using &lt;code&gt;withTransaction&lt;/code&gt; for complex transitions. This allows you not just to &quot;turn on&quot; animation, but to pass context: for example, disable animation for a specific change inside the block while preserving it for everything else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;var transaction = Transaction(animation: .spring())
transaction.disablesAnimations = false // Flexible control over the logic

withTransaction(transaction) {
    self.showDetails.toggle()
    // This change will pick up the transaction settings
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is much cleaner than trying to wrap every view with its own &lt;code&gt;.animation()&lt;/code&gt;. Plus, it helps avoid the &quot;jello effect,&quot; when elements fly off in different directions due to conflicting instructions.&lt;/p&gt;
&lt;h4 id=&quot;uiview-animate-vs-swiftui-animation&quot;&gt;UIView.animate vs SwiftUI Animation&lt;/h4&gt;
&lt;p&gt;If you're still maintaining UIKit projects (or writing wrappers for SwiftUI), the question arises: what to use?&lt;/p&gt;
&lt;p&gt;&lt;code&gt;UIView.animate&lt;/code&gt; is good old-fashioned classic, working on top of Core Animation. It's predictable. But in SwiftUI, animations are built on a different principle: they depend on state changes (State-driven).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;My rule is simple:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;If you need to animate &lt;code&gt;CALayer&lt;/code&gt; properties (for example, &lt;code&gt;borderWidth&lt;/code&gt; or specific masks) - go with UIKit/Core Animation.&lt;/li&gt;
&lt;li&gt;If you're working with navigation and content updates in SwiftUI - use native tools.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The main mistake in SwiftUI is animating everything with &lt;code&gt;.animation(.default)&lt;/code&gt;. Always specify the concrete value (&lt;code&gt;value&lt;/code&gt;) that the animation should react to. Otherwise, you risk getting parasitic movements when other screen properties update.&lt;/p&gt;
&lt;h4 id=&quot;the-cardinal-sin-layout-on-main-thread&quot;&gt;The Cardinal Sin: Layout on Main Thread&lt;/h4&gt;
&lt;p&gt;The fastest way to kill performance is to force the Main Thread to calculate geometry during animation. When you change constraints inside an animation block, you trigger a heavy recalculation process for the entire tree.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How to do it right:&lt;/strong&gt; Instead of changing &lt;code&gt;height&lt;/code&gt; or &lt;code&gt;width&lt;/code&gt;, use &lt;code&gt;CGAffineTransform&lt;/code&gt; (in UIKit) or &lt;code&gt;.scaleEffect()&lt;/code&gt; / &lt;code&gt;.offset()&lt;/code&gt; (in SwiftUI). These properties are handled by the GPU. The system simply takes an already prepared &quot;snapshot&quot; of the view and transforms it without recalculating the positions of neighboring elements.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// Bad: triggers Layout recalculation
view.frame.size.height = 200 

// Good: just a visual transformation
view.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;xcode-instruments-the-developer-s-eyes&quot;&gt;Xcode Instruments: The Developer's Eyes&lt;/h4&gt;
&lt;p&gt;If animation &quot;lags&quot; to the naked eye - you've already lost. But how do you understand why it lags? I run &lt;strong&gt;Animation Hitches&lt;/strong&gt; in Xcode Instruments at least once a week.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Core Animation Instrument:&lt;/strong&gt; Shows frame rate and, more importantly, &quot;Color Offscreen-Rendered Yellow&quot;. If your screen is flooded with yellow - you're forcing the GPU to do double work (for example, rendering masks or shadows without optimization).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hitch Rate:&lt;/strong&gt; Apple introduced this term to measure interface &quot;stuttering&quot;. A good metric is less than 5 ms/s.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;accessibility-respect-your-user&quot;&gt;Accessibility: Respect Your User&lt;/h4&gt;
&lt;p&gt;Remember people who get motion sick from abrupt movements. iOS has a &lt;strong&gt;Reduce Motion&lt;/strong&gt; setting.&lt;/p&gt;
&lt;p&gt;Ignoring it is a sign of poor taste and unprofessionalism. If a user asked the system not to move, your full-screen zoom effect should turn into a simple and elegant crossfade.&lt;/p&gt;
&lt;p&gt;In SwiftUI, this is done elementarily:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;@Environment(\.accessibilityReduceMotion) var reduceMotion

var body: some View {
    Circle()
        .offset(x: moveIt ? 100 : 0)
        .animation(reduceMotion ? nil : .easeInOut, value: moveIt)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I always check this flag. Trust me, users with vestibular disorders will thank you (even though you'll never know about it).&lt;/p&gt;
&lt;h4 id=&quot;the-sufficiency-guideline&quot;&gt;The &quot;Sufficiency&quot; Guideline&lt;/h4&gt;
&lt;p&gt;Polish is cool, but it's easy to turn an app into a Christmas tree. Here are my internal criteria for &quot;healthy&quot; animation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Duration:&lt;/strong&gt; Most UI animations should fit within the &lt;strong&gt;0.2 – 0.3 second&lt;/strong&gt; range. Anything longer starts to irritate with frequent use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Functionality:&lt;/strong&gt; Animation should explain where an element came from and where it went. If it's just &quot;for beauty&quot; and doesn't carry meaning - delete it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Responsiveness:&lt;/strong&gt; If a user tapped a button, the reaction should be instant. Don't make them wait for an animation to finish to perform the next action (Interruptible animations are everything).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Animation is a balance between art and mathematics. Try right now to open your most complex screen in Xcode Instruments and look at the &lt;strong&gt;Hitch Rate&lt;/strong&gt;. If you see red zones - start by replacing constraint animations with &lt;code&gt;CGAffineTransform&lt;/code&gt;.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/11225/magic-beyond-hogwarts-how-to-build-expensive-ui-without-overloading-the-gpu</guid>
<pubDate>Sat, 07 Feb 2026 17:06:01 +0000</pubDate>
</item>
<item>
<title>How much does it take to “finish” an app</title>
<link>https://coderlegion.com/11134/how-much-does-it-take-to-finish-an-app</link>
<description>&lt;p&gt;Quite strange equation, don’t you think? But in a developer’s world, math works in unexpected ways.&lt;/p&gt;
&lt;p&gt;Six months ago, I decided to build the best EPUB reader for Apple devices. I started immediately and, in four months, I had a working prototype with about 90% of the functionality. Everything worked as expected. Without fear, I launched my first TestFlight.&lt;/p&gt;
&lt;p&gt;I expected anything and everything, but not this: I slid back to 10% done.&lt;/p&gt;
&lt;p&gt;Somewhere in 2025, I watched Sean Allen’s video on the “90/90 rule.” Now I truly know, what that second 90% means.&lt;/p&gt;
&lt;p&gt;TestFlight revealed two truths to me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Developers are always wrong&lt;/li&gt;
&lt;li&gt;When you think you’re almost done, you’ve barely started&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These two things forced me to rethink the app’s internals, design, and UI.&lt;/p&gt;
&lt;p&gt;I spent the last two to three weeks working harder than in the previous four months. I spent nights fixing bugs, unifying the interface, working on promised features, and making sure, that basics are there (like a consistent close button: top-right X, always).&lt;/p&gt;
&lt;p&gt;And even now, I’m not “finished.”&lt;/p&gt;
&lt;p&gt;Since this series is about &lt;a rel=&quot;nofollow&quot; href=&quot;https://justread.app&quot;&gt;justRead.app&lt;/a&gt; development, here’s the latest state with tech details.&lt;/p&gt;
&lt;h2 id=&quot;what-was-fixed&quot;&gt;What was fixed&lt;/h2&gt;
&lt;p&gt;The killer problem was simple: the app was freezing and crashing. Something I didn’t experience in development, something TestFlight users experienced a lot.&lt;/p&gt;
&lt;p&gt;justRead.app loads epub files from the cloud, often buried deep in subfolders (not yet downloaded). When folder with books is selected, it ran three heavy processes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Analyze all the (sub)directories for epub files (no files downloaded)&lt;/li&gt;
&lt;li&gt;Extract thumbnails and some accessible data from those files (still no files downloaded)&lt;/li&gt;
&lt;li&gt;Parse metadata (downloading of files starts now)&lt;br&gt;
Users saw a “frozen” app, they tried everything, then app then crashed. What was worse: on relaunch with a selected library, it repeated the exact same cycle with the exact same result for user.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;the-fix-&quot;&gt;The fix:&lt;/h2&gt;
&lt;p&gt;The app is running the same processes, but now in the background with clear user progress indicators. Users can read downloaded books while those three processes are running. Simple and completely avoided with better testing.&lt;/p&gt;
&lt;p&gt;Smaller bugs got fixed too: streak calculations, read-time tracking, bookmark saves.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8jp6w9wh15usgd1tu21m.webp&quot; alt=&quot;Loading of books&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;what-was-changed&quot;&gt;What was changed&lt;/h2&gt;
&lt;p&gt;Non-crashing users gave me around 20 ideas like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tap anywhere on a book row to open it (not just the cover)&lt;/li&gt;
&lt;li&gt;Hide settings on book start&lt;/li&gt;
&lt;li&gt;Load font manually from menu, not automatically from folder&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I experimented with all the ideas, iterated on users feedback and implemented what was the best fit.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/rntt9c8io21hlgk6n9ju.webp&quot; alt=&quot;Settings and external font loading.&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;what-was-added&quot;&gt;What was added&lt;/h2&gt;
&lt;p&gt;No breakage risk here. Those were added:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Statistics dashboard&lt;/li&gt;
&lt;li&gt;Roadmap with user voting&lt;/li&gt;
&lt;li&gt;Alphabet jump bar for libraries&lt;/li&gt;
&lt;li&gt;Extra settings tweaks.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Plus about 50 other small things, like highlighting your current TOC chapter.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ytcmd04p3gq4ydn69nfw.webp&quot; alt=&quot;Statistics (still in beta) with reading streak.&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;testflight-2&quot;&gt;TestFlight #2&lt;/h2&gt;
&lt;p&gt;It’s live today.&lt;br&gt;
This time, I’m expecting crashes, freezes, and issues.&lt;/p&gt;
&lt;h2 id=&quot;as-a-user-why-is-this-all-important-&quot;&gt;As a user, why is this all important?&lt;/h2&gt;
&lt;p&gt;You want polished apps, no crashes, intuitive user interface. You are expecting a developer who cares about the app, because he uses it.&lt;/p&gt;
&lt;p&gt;That’s why TestFlight matters. Apple enables it, testers refine the app and the users get app as polished as possible.&lt;/p&gt;
&lt;p&gt;The voting roadmap builds on this: you shape justRead.app’s future, prioritizing what you want.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oibl15mutoddofbv0ndp.webp&quot; alt=&quot;justRead.app roadmap with voting and commenting.&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;as-a-developer-why-is-this-all-important-&quot;&gt;As a developer, why is this all important?&lt;/h2&gt;
&lt;p&gt;We build apps and systems because we love to.&lt;/p&gt;
&lt;p&gt;Sean’s rule reminds us: the final 90% polish is the app.&lt;/p&gt;
&lt;p&gt;Rush development and release, and you ship a prototype.&lt;/p&gt;
&lt;p&gt;Deliver the app polished, and you create something users love.&lt;/p&gt;
&lt;p&gt;That’s why 90% + 90 % makes 100%.&lt;/p&gt;
&lt;p&gt;And to answer the question in the beginning: it does take a lot more than what you have planned. Even if you have years of experience in development.&lt;/p&gt;
&lt;p&gt;_&lt;a rel=&quot;nofollow&quot; href=&quot;https://justread.app&quot;&gt;justRead.app&lt;/a&gt; is an epub reader for iPhone, currently in TestFlight.__&lt;br&gt;
Peter&lt;br&gt;
Reader, Developer, justRead.app Creator&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/11134/how-much-does-it-take-to-finish-an-app</guid>
<pubDate>Fri, 06 Feb 2026 01:40:00 +0000</pubDate>
</item>
<item>
<title>When Your App Freezes in Front of Your Testers: What I Learned About Ego &amp; iOS Development</title>
<link>https://coderlegion.com/10436/when-your-app-freezes-in-front-of-your-testers-what-i-learned-about-ego-ios-development</link>
<description>&lt;blockquote&gt;&lt;p&gt;Development of justRead app in six parts; part five.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I’ve been building software for almost 15 years. Manufacturing systems, backend services, databases that handle millions of transactions. Things that actually matter, production lines depend on them. I thought I knew what I was doing.&lt;/p&gt;
&lt;p&gt;Then I shipped my first TestFlight, and my app crashed for 50% of my testers.&lt;/p&gt;
&lt;h2 id=&quot;the-confidence-trap&quot;&gt;The confidence trap&lt;/h2&gt;
&lt;p&gt;When you’ve spent a decade and a half solving hard problems, you develop a certain… confidence. Not arrogance, exactly. Just the quiet assumption that you understand your domain. You’ve debugged memory leaks in enterprise systems. You’ve optimized database queries for millions of records. You’ve shipped products to paying customers who depended on your code.&lt;/p&gt;
&lt;p&gt;Building a native iOS app in SwiftUI felt manageable by comparison. Smaller scope. Single platform. Modern tooling. Surely the hard parts were the product design and the business strategy, not the code.&lt;/p&gt;
&lt;p&gt;So when I invited 157 testers to try justRead, my e-book reader app, I was genuinely unprepared for what came next.&lt;/p&gt;
&lt;h2 id=&quot;the-day-everything-broke&quot;&gt;The day everything broke&lt;/h2&gt;
&lt;p&gt;The numbers seemed reasonable at first:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;157 people applied for TestFlight&lt;/li&gt;
&lt;li&gt;88 actually run the TestFlight&lt;/li&gt;
&lt;li&gt;4 unsubscribed immediately (missing features they wanted)&lt;/li&gt;
&lt;li&gt;84 stayed and used TestFlight&lt;/li&gt;
&lt;li&gt;10–15 engaged deeply with feedback&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What I wasn’t ready for: approximately 50% of those users experienced crashes and freezes. Not occasionally. Repeatedly.&lt;/p&gt;
&lt;p&gt;The feedback came in waves on the first day. “App froze.” “Crashed when I opened a book.” “Keeps crashing during search.” “Freezes and won’t respond.”&lt;/p&gt;
&lt;p&gt;I felt it immediately: shame &amp;amp; embarrassment. That specific kind of ego hit you get when you assumed you were competent and the evidence suggests otherwise.&lt;/p&gt;
&lt;p&gt;My first instinct was to fix it immediately. I pushed four quick builds in rapid succession, each targeting what I thought were the culprits. Each time I was convinced I’d found it. Each time, I learned I hadn’t.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7kp3biy5vqu1hd75uga9.webp&quot; alt=&quot;progress in informing user&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-problem-i-didn-t-expect&quot;&gt;The problem I didn’t expect&lt;/h2&gt;
&lt;p&gt;I totally not anticipated: the app was trying to download data from cloud storage to local iPhone storage in the background while users were trying to interact with it. The process wasn’t properly managed. It wasn’t being cancelled. It wasn’t communicating with the UI that it was happening.&lt;/p&gt;
&lt;p&gt;The result? The app would freeze while syncing. Then crash when users tried to do anything else.&lt;/p&gt;
&lt;p&gt;This is a foundational problem. Not a bug. A design oversight. And it required rethinking how the entire cloud-sync system works.&lt;/p&gt;
&lt;p&gt;That’s not fixed in a quick build. That’s fixed in the next major revision: the one I’m working on now, scheduled for next week.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4ng0tppvtzfztqq57ji7.webp&quot; alt=&quot;splash screen&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;why-this-mattered-more-than-i-expected&quot;&gt;Why this mattered more than I expected&lt;/h2&gt;
&lt;p&gt;I wrote the post-TestFlight email to my testers laying out exactly what we found, exactly what we’re fixing. It’s detailed. 27 separate items across stability fixes, functional improvements, experience enhancements, and new features.&lt;/p&gt;
&lt;p&gt;When I look at that list, I feel two things simultaneously:&lt;br&gt;
(1) genuine gratitude that TestFlight caught these problems before actual users did, and …&lt;br&gt;
(2) the uncomfortable knowledge that I shipped something to real people that wasn’t ready.&lt;/p&gt;
&lt;p&gt;But this thing hit harder than the crashes themselves: I wasn’t expecting problems in the first place.&lt;/p&gt;
&lt;p&gt;Not intellectually. Obviously, you expect problems when you’re building in public and testing. That’s the point of TestFlight.&lt;/p&gt;
&lt;p&gt;But emotionally? I thought I knew what I was doing. My experience did translate — to the architectural thinking, the debugging methodology, the understanding that stability matters. But it didn’t translate to the specifics of how iOS manages background tasks, memory pressure, and network state. That requires learning deeply iOS, not just applying general software engineering. I thought the hard parts would be the things I expected them to be, not the foundational infrastructure that I’d glossed over.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hta1dp7mu1hve8z600w2.webp&quot; alt=&quot;settings&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-humility-reboot&quot;&gt;The humility reboot&lt;/h2&gt;
&lt;p&gt;Every developer who’s shipped something meaningful knows this feeling: the moment your assumptions collide with reality. It’s not special. It’s not even novel. Thousands of indie developers ship broken first versions to TestFlight every week.&lt;br&gt;
What is interesting is that I didn’t see it coming, even though I should have.&lt;/p&gt;
&lt;p&gt;I think it’s because there’s a category error in how we talk about indie app development. The content I read before shipping was almost entirely about growth metrics, monetization strategy, App Store Optimization, and how to build profitable businesses. Very little about the actual problem-solving journey. Very little about the moment when your code meets real devices, real networks, real edge cases, and fails spectacularly.&lt;/p&gt;
&lt;p&gt;Everyone talks about MRR and unit economics and customer acquisition cost. Almost nobody talks about the humbling moment when you realize your search implementation is leaking memory, or your cloud-sync strategy is fundamentally broken, or your assumptions about how iOS handles background tasks were incomplete.&lt;/p&gt;
&lt;p&gt;So I wasn’t prepared for the specific ways iOS exposes stability problems: background task lifecycle, memory pressure, network interruption, concurrent file access. I knew stability mattered. I didn’t anticipate how differently it manifests.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8156prz47c7qz64vja24.webp&quot; alt=&quot;what is coming&quot;&gt;&lt;/p&gt;
&lt;h2 id=&quot;what-i-know-now-that-i-didn-t-know-last-week-&quot;&gt;What I know now (that I didn’t know last week)&lt;/h2&gt;
&lt;h3 id=&quot;confidence-and-competence-are-not-the-same-thing-&quot;&gt;Confidence and competence are not the same thing.&lt;/h3&gt;
&lt;p&gt;I’ve built complex systems. I understand databases, performance optimization, distributed systems. But iOS development operates by different rules. Memory management is different. Concurrency is different. The operating system’s lifecycle management is different. And my 15 years of experience didn’t automagically transfer to these domains.&lt;br&gt;
The apps that freeze on 50% of devices aren’t built by incompetent people. They’re built by people who didn’t anticipate the specific, non-obvious ways that iOS handles background tasks, network requests, and memory pressure.&lt;/p&gt;
&lt;h3 id=&quot;testing-on-the-simulator-is-not-the-same-as-testing-on-real-devices-&quot;&gt;Testing on the simulator is not the same as testing on real devices.&lt;/h3&gt;
&lt;p&gt;I tested extensively. On the simulator. On my own devices. But real users with different network speeds, device generations, cloud storage providers, and actual reading patterns found problems in 48 hours that I didn’t find in months.&lt;br&gt;
The testers weren’t doing anything wrong. They were using the app the way it’s supposed to be used. And that’s exactly when the problems appeared.&lt;/p&gt;
&lt;h3 id=&quot;the-gap-between-it-works-for-me-and-it-works-is-massive-&quot;&gt;The gap between “it works for me” and “it works” is massive.&lt;/h3&gt;
&lt;p&gt;This is obvious when you say it out loud. But it was a genuine surprise living through it. My devices are development machines. They have good connectivity, fresh iOS versions, ample memory. Real users have older iPhones running older iOS versions on spotty WiFi networks while the app is trying to download a 500MB library from cloud storage.&lt;br&gt;
The problem wasn’t the code. The problem was that the code had never met these conditions before.&lt;/p&gt;
&lt;h3 id=&quot;freezes-and-crashes-are-not-small-bugs-they-re-design-problems-&quot;&gt;Freezes and crashes are not small bugs. They’re design problems.&lt;/h3&gt;
&lt;p&gt;I initially approached the crashes as debugging exercises. Find the null pointer. Find the memory leak. Find the race condition. But the core issue was architectural: my app’s approach to cloud sync didn’t account for the reality that syncing might take a long time, might fail, might be interrupted, and the UI should probably tell the user what’s happening instead of just freezing.&lt;/p&gt;
&lt;p&gt;That’s not a bug fix. That’s a redesign.&lt;/p&gt;
&lt;h2 id=&quot;the-week-ahead&quot;&gt;The week ahead&lt;/h2&gt;
&lt;p&gt;I’m rebuilding the cloud-sync system from the ground. I’m adding proper error handling, user notifications, timeout mechanisms, and background task management. I’m making sure the app doesn’t try to open files that haven’t finished downloading. I’m creating feedback loops so users know what’s happening instead of wondering why their app is frozen.&lt;/p&gt;
&lt;p&gt;The new build goes to TestFlight next week.&lt;/p&gt;
&lt;p&gt;And I have to admit that I’m nervous about it. Not because I think there will still be crashes (I’m 100% confident there won’t be, and yes, I know what that overconfidence sounds like now). But because I’ve learned that my confidence is not a reliable predictor of reality.&lt;/p&gt;
&lt;p&gt;The 84 people who stuck with the app deserve a product that works. The 10–15 who gave detailed feedback deserve to know that their investment of time was the thing that made the difference between shipping something broken and shipping something real.&lt;/p&gt;
&lt;h2 id=&quot;the-thing-nobody-writes-about&quot;&gt;The thing nobody writes about&lt;/h2&gt;
&lt;p&gt;This is why I’m writing this. Not to tell you that my app is great (it’s not, yet). Not to tell you about my growth metrics or my monetization strategy (I have neither, we’re literally in TestFlight week one). And not to tell you that building an indie app is easy if you follow these ten steps.&lt;/p&gt;
&lt;p&gt;I’m writing this because the indie dev content I read before shipping was almost entirely about the business side, the metrics, the strategy and the market opportunity. Almost none of it was about the moment when you realize that iOS stability requires entirely different thinking than backend systems. That managing cloud sync while the OS kills background tasks while the user is on spotty WiFi requires architectural decisions, not just debugging. Stability as a principle? Everyone knows that. Stability as iOS execution? That’s what nobody talks about.&lt;/p&gt;
&lt;p&gt;Almost none of it was about the feeling of shipping something to real people and having it fail for half of them, even though you thought you knew what you were doing.&lt;/p&gt;
&lt;p&gt;Almost none of it was about the humility that comes from learning, in real time, why your confidence was misplaced.&lt;/p&gt;
&lt;p&gt;And maybe that’s because those stories are harder to write. They require admitting that despite 15 years of experience, despite months of development, despite testing that felt comprehensive, you still got something fundamentally wrong.&lt;/p&gt;
&lt;p&gt;But they’re also the stories that matter most, because they’re the ones that prepare you for what actually happens when you ship.&lt;/p&gt;
&lt;h2 id=&quot;what-comes-next&quot;&gt;What comes next&lt;/h2&gt;
&lt;p&gt;For justRead: a rebuilt cloud-sync system, more thorough testing with real devices, and a deep respect for what I still have to learn about iOS development.&lt;/p&gt;
&lt;p&gt;For this journey: continued transparency about what works and what doesn’t. Continued gratitude for the 84 testers who are willing to help us figure this out. And continued proof that the hard part of building an app isn’t the idea or the design, it’s the relentless, humbling work of making it actually work.&lt;/p&gt;
&lt;p&gt;The next TestFlight build will be better. And I’ll be more honest about what “better” actually means: not that we fixed everything, but that we learned something.&lt;/p&gt;
&lt;p&gt;That’s progress, even if it doesn’t feel like it when your app is freezing in front of real people.&lt;br&gt;
justRead is an EPUB reader for iPhone, currently in TestFlight. If you’re interested in early access or want to share your own story of shipped-too-early products, I’d love to hear from you. The next build ships next week.&lt;/p&gt;
&lt;p&gt;Peter&lt;br&gt;
Reader, Developer, &lt;a rel=&quot;nofollow&quot; href=&quot;https://justread.app&quot;&gt;justRead&lt;/a&gt; Creator&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/10436/when-your-app-freezes-in-front-of-your-testers-what-i-learned-about-ego-ios-development</guid>
<pubDate>Mon, 26 Jan 2026 00:14:00 +0000</pubDate>
</item>
<item>
<title>Preserving Scroll Position and Cursor State During Navigation Transitions</title>
<link>https://coderlegion.com/10439/preserving-scroll-position-and-cursor-state-during-navigation-transitions</link>
<description>&lt;p&gt;Have you ever noticed the difference between a simply good app and one that feels &quot;native,&quot; polished, and premium? Often it's not about design or animations, but about small details we take for granted until they break. One such critical detail is an app's ability to remember where you left off. You're reading a long article, minimize the app to respond to a message, come back a minute later, and boom - you're thrown back to the very beginning of the text. Annoying, right? Or you're writing a note, switch to the browser, come back, and the keyboard is hidden with the cursor lost. In this article, we'll break down how to technically implement scroll position and cursor state preservation in iOS apps properly. We'll go beyond banal advice and examine real scenarios with navigation, tabs, and asynchronous data loading, so your users never feel lost.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;why-losing-context-hurts&quot;&gt;Why Losing Context Hurts&lt;/h4&gt;
&lt;p&gt;Let's be honest: users don't know terms like &quot;controller lifecycle&quot; or &quot;memory unloading.&quot; But they can absolutely feel when an app behaves stupidly. Losing scroll position or cursor during navigation is exactly that kind of stupid behavior. It destroys the interaction flow.&lt;/p&gt;
&lt;p&gt;Imagine you're working in a code editor on an iPad. You scroll a file to line 500, place the cursor in the middle of a complex function, then open the side menu to check the project structure. You close the menu - and bam! - you're back at line one. At this moment, the app transforms from a professional tool into a toy.&lt;/p&gt;
&lt;p&gt;Another classic example is news feeds or product catalogs. The user has scrolled down through a hundred items, drilled into a product detail screen, tapped Back, and their list reloaded, throwing them back to the top. This isn't just inconvenient, it's wasting the user's time. In a world with cutthroat competition for attention, such UX mistakes are unforgivable. An app that doesn't remember my state feels like a cheap web wrapper, even if it's written in pure Swift.&lt;/p&gt;
&lt;h4 id=&quot;the-foundation-state-restoration-in-ios&quot;&gt;The Foundation: State Restoration in iOS&lt;/h4&gt;
&lt;p&gt;Apple has long provided us with a powerful mechanism to solve these problems - UI State Restoration. But in my observations, many developers ignore it, preferring to reinvent the wheel by saving data in &lt;code&gt;UserDefaults&lt;/code&gt; or singletons. I was guilty of this myself for a long time, until I understood how the system works under the hood.&lt;/p&gt;
&lt;p&gt;The built-in mechanism is good because it's systemic. It triggers not only when you push a controller onto the navigation stack, but also when the system kills your app in the background due to memory pressure, and then the user relaunches it.&lt;/p&gt;
&lt;p&gt;The cornerstone of the entire mechanism is the &lt;code&gt;restorationIdentifier&lt;/code&gt; property.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// In viewDidLoad
self.restorationIdentifier = &quot;MyDetailViewController&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If a controller (or any &lt;code&gt;UIView&lt;/code&gt;) doesn't have this identifier set, the system won't even attempt to save its state. This is your entry ticket. Once the identifier is set, two methods from the &lt;code&gt;UIStateRestoring&lt;/code&gt; protocol come into play, which every &lt;code&gt;UIViewController&lt;/code&gt; already has:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;override func encodeRestorableState(with coder: NSCoder) {
    super.encodeRestorableState(with: coder)
    // Here we encode what we want to save
    coder.encode(someImportantData, forKey: &quot;dataKey&quot;)
}

override func decodeRestorableState(with coder: NSCoder) {
    super.decodeRestorableState(with: coder)
    // And here we retrieve it back
    if let data = coder.decodeObject(forKey: &quot;dataKey&quot;) as? DataToRestore {
        self.someImportantData = data
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sounds simple, but the devil is in the implementation details. State Restoration doesn't save your model &lt;em&gt;data&lt;/em&gt; (that's what Core Data or files are for). It saves &lt;em&gt;interface state&lt;/em&gt;, which helps display that data exactly as it was before the user left.&lt;/p&gt;
&lt;h4 id=&quot;saving-scroll-position-scrollview-offset-&quot;&gt;Saving Scroll Position (ScrollView Offset)&lt;/h4&gt;
&lt;p&gt;The most common case is &lt;code&gt;UITableView&lt;/code&gt; or &lt;code&gt;UICollectionView&lt;/code&gt;. When the user leaves the screen (for example, pushes the next controller), we want to remember the &lt;code&gt;contentOffset&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Many try to save the offset in &lt;code&gt;viewWillDisappear&lt;/code&gt;. This is a working strategy for simple transitions within the navigation stack, when the controller stays in memory. But if we're talking about full-fledged State Restoration (with app termination), it's better to use specialized methods.&lt;/p&gt;
&lt;p&gt;Let's say we have a &lt;code&gt;UITableViewController&lt;/code&gt;. Fortunately, &lt;code&gt;UITableView&lt;/code&gt; and &lt;code&gt;UICollectionView&lt;/code&gt; already know how to save a lot themselves, if they (and their cells!) have &lt;code&gt;restorationIdentifier&lt;/code&gt; set. But sometimes we need more control, especially if the scroll view is complex.&lt;/p&gt;
&lt;p&gt;I prefer an explicit approach. Let's create a structure to store our screen's state:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct ListViewState: Codable {
    let contentOffsetY: CGFloat
    // Can add selected cell ID, etc.
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now in the controller:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class MyListViewController: UIViewController, UITableViewDelegate {
    
    @IBOutlet weak var tableView: UITableView!
    private let stateKey = &quot;ListViewControllerState&quot;

    override func viewDidLoad() {
        super.viewDidLoad()
        // Critical for the mechanism to work
        self.restorationIdentifier = &quot;MyListVC&quot;
    }

    // MARK: - State Restoration

    override func encodeRestorableState(with coder: NSCoder) {
        super.encodeRestorableState(with: coder)
        
        // Save current offset
        let state = ListViewState(contentOffsetY: tableView.contentOffset.y)
        do {
            let data = try JSONEncoder().encode(state)
            coder.encode(data, forKey: stateKey)
        } catch {
            print(&quot;Failed to encode state: \(error)&quot;)
        }
    }

    override func decodeRestorableState(with coder: NSCoder) {
        super.decodeRestorableState(with: coder)
        
        guard let data = coder.decodeObject(forKey: stateKey) as? Data,
              let state = try? JSONDecoder().decode(ListViewState.self, from: data) else {
            return
        }
        
        // HERE'S THE MAIN GOTCHA
        // You can't just set contentOffset right now.
        // The table data might not be loaded yet.
        self.restoreScrollPosition(to: state.contentOffsetY)
    }
    
    private var pendingScrollOffset: CGFloat?

    private func restoreScrollPosition(to offset: CGFloat) {
        // Save the offset &quot;for later&quot;
        self.pendingScrollOffset = offset
    }
    
    // Call this method when data is definitely loaded and table has redrawn
    func dataDidFinishLoading() {
        tableView.reloadData()
        
        if let offset = pendingScrollOffset {
            // Sometimes need to give the Layout engine time to work
            DispatchQueue.main.async {
                self.tableView.setContentOffset(CGPoint(x: 0, y: offset), animated: false)
                self.pendingScrollOffset = nil
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the &lt;code&gt;pendingScrollOffset&lt;/code&gt;. This is a key moment when working with async data. If you try to restore &lt;code&gt;contentOffset&lt;/code&gt; in &lt;code&gt;decodeRestorableState&lt;/code&gt; when your table has 0 rows, the scroll will simply reset to 0. You must apply the saved offset &lt;em&gt;only after&lt;/em&gt; the data is received and &lt;code&gt;reloadData()&lt;/code&gt; has executed.&lt;/p&gt;
&lt;h4 id=&quot;hunting-for-the-cursor-in-uitextview&quot;&gt;Hunting for the Cursor in UITextView&lt;/h4&gt;
&lt;p&gt;With text editors, things get even more interesting. Here we need to save not only scroll, but also cursor position (or text selection). This is handled by the &lt;code&gt;selectedRange&lt;/code&gt; property of &lt;code&gt;UITextView&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The problem is that cursor position is inextricably linked to keyboard focus. If during state restoration we simply set &lt;code&gt;selectedRange&lt;/code&gt; but don't make the text field active (&lt;code&gt;becomeFirstResponder&lt;/code&gt;), the user will see the text, possibly even scrolled to the right place, but without the cursor and keyboard. They'll have to tap again. This isn't the &quot;seamless&quot; experience we're aiming for.&lt;/p&gt;
&lt;p&gt;Here's how I typically approach solving this in an editor controller:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class EditorViewController: UIViewController {
    
    @IBOutlet weak var textView: UITextView!
    private let selectionKey = &quot;EditorSelectionRange&quot;
    private let textKey = &quot;EditorTextContent&quot; // If text isn't saved in model

    override func viewDidLoad() {
        super.viewDidLoad()
        self.restorationIdentifier = &quot;EditorVC&quot;
        // Important: the textView itself also needs an ID if we want
        // the system to help us with scroll
        self.textView.restorationIdentifier = &quot;EditorTextView&quot;
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // If we restored state and should show keyboard
        if shouldRestoreFocus {
            textView.becomeFirstResponder()
            shouldRestoreFocus = false
        }
    }

    // MARK: - State Restoration

    override func encodeRestorableState(with coder: NSCoder) {
        super.encodeRestorableState(with: coder)
        
        // Save range. NSRange encodes perfectly.
        let range = textView.selectedRange
        if range.location != NSNotFound {
             coder.encode(NSValue(range: range), forKey: selectionKey)
        }
        
        // Save whether this field had focus
        coder.encode(textView.isFirstResponder, forKey: &quot;wasFirstResponder&quot;)
    }

    private var shouldRestoreFocus = false

    override func decodeRestorableState(with coder: NSCoder) {
        super.decodeRestorableState(with: coder)
        
        // Restore text (if needed)
        // textView.text = ...

        // Restore range
        if let value = coder.decodeObject(forKey: selectionKey) as? NSValue {
            let range = value.rangeValue
            // Critical moment: need to ensure range is valid for current text
            if range.upperBound &amp;lt;= textView.text.count {
                textView.selectedRange = range
                
                // Often need to force scroll to cursor,
                // as automatic scroll might not work during restoration
                textView.scrollRangeToVisible(range)
            }
        }
        
        // Remember if we need to restore focus
        shouldRestoreFocus = coder.decodeBool(forKey: &quot;wasFirstResponder&quot;)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I deliberately moved &lt;code&gt;becomeFirstResponder()&lt;/code&gt; to &lt;code&gt;viewDidAppear&lt;/code&gt;. Calling it in &lt;code&gt;decodeRestorableState&lt;/code&gt; or &lt;code&gt;viewDidLoad&lt;/code&gt; is often premature, since the view hierarchy isn't fully ready yet, and the system might ignore the keyboard request. Plus, keyboard appearance changes the &lt;code&gt;UITextView&lt;/code&gt; insets, which can affect scroll. By doing this in &lt;code&gt;viewDidAppear&lt;/code&gt;, we let the interface &quot;settle.&quot;&lt;/p&gt;
&lt;h4 id=&quot;tabs-modals-and-app-backgrounding-a-unified-strategy&quot;&gt;Tabs, Modals, and App Backgrounding: A Unified Strategy&lt;/h4&gt;
&lt;p&gt;Developers often implement different mechanisms for different navigation types. For &lt;code&gt;UINavigationController&lt;/code&gt; they use &lt;code&gt;viewWillDisappear&lt;/code&gt; to save offset. For tab bar - &lt;code&gt;UITabBarControllerDelegate&lt;/code&gt; methods. For app backgrounding - they subscribe to &lt;code&gt;UIApplication.didEnterBackgroundNotification&lt;/code&gt; notifications.&lt;/p&gt;
&lt;p&gt;This is a path to code duplication and bugs. I'm a proponent of a unified strategy.&lt;/p&gt;
&lt;p&gt;The State Restoration mechanism described above (&lt;code&gt;encodeRestorableState&lt;/code&gt;/&lt;code&gt;decodeRestorableState&lt;/code&gt;) works perfectly for the &quot;kill and resurrect&quot; app scenario.&lt;/p&gt;
&lt;p&gt;But what about simple navigation within a live app? Switching tabs or push/pop controllers. Here State Restoration in its pure form doesn't quite fit, since it's about &quot;cold restart.&quot;&lt;/p&gt;
&lt;p&gt;For &quot;hot&quot; navigation I prefer using lifecycle methods &lt;code&gt;viewWillDisappear&lt;/code&gt; and &lt;code&gt;viewWillAppear&lt;/code&gt; as the most reliable triggers.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Leaving the screen (&lt;code&gt;viewWillDisappear&lt;/code&gt;):&lt;/strong&gt; This is the perfect place to save current &quot;ephemeral&quot; state (scroll, focus) to a local controller variable or associated ViewModel. Doesn't matter why the screen is disappearing - new one being pushed, tab switching, or modal presenting. This method will be called.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Returning to screen (&lt;code&gt;viewWillAppear&lt;/code&gt; / &lt;code&gt;viewDidAppear&lt;/code&gt;):&lt;/strong&gt; Here we check if there's saved state and apply it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example for &quot;hot&quot; saving in a list:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class HotListViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!
    
    // Store offset right in controller for quick transitions
    private var lastScrollOffset: CGPoint?

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // Leaving? Remember where we were.
        lastScrollOffset = tableView.contentOffset
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        // Returned? If there's something to restore, and data hasn't changed critically.
        if let offset = lastScrollOffset {
            // Important: check table isn't empty, otherwise crash or glitch
            if tableView.contentSize.height &amp;gt; offset.y {
                 tableView.setContentOffset(offset, animated: false)
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This approach works great when switching tabs in &lt;code&gt;UITabBarController&lt;/code&gt;. Controllers in tabs usually aren't unloaded from memory, so the local &lt;code&gt;lastScrollOffset&lt;/code&gt; variable will hold the value indefinitely, as long as the app session is alive.&lt;/p&gt;
&lt;p&gt;If you're using custom navigation where controllers are recreated, you'll need to save this &quot;hot&quot; state somewhere external - for example, in a navigation service or global state storage tied to screen ID.&lt;/p&gt;
&lt;h4 id=&quot;testing-breaking-what-we-built&quot;&gt;Testing: Breaking What We Built&lt;/h4&gt;
&lt;p&gt;The biggest mistake when implementing these features is improper testing. Simply minimizing the app with the Home button and reopening it is not a State Restoration test. In this case, the app simply &quot;sleeps&quot; in RAM, and when awakened, all views and controllers remain in place untouched. No &lt;code&gt;decodeRestorableState&lt;/code&gt; is called.&lt;/p&gt;
&lt;p&gt;To test for real, you need to simulate system actions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Simulating memory pressure:&lt;/strong&gt; In iOS Simulator, select &lt;code&gt;Debug -&amp;gt; Simulate Memory Warning&lt;/code&gt; from the menu. If your controller is deep in the navigation stack or in an inactive tab, and it's &quot;heavy,&quot; the system might unload it (call &lt;code&gt;didReceiveMemoryWarning&lt;/code&gt;, and then possibly free its view). When returning to it, the restoration mechanism should trigger.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;App termination in background:&lt;/strong&gt; This is the main test.&lt;ul&gt;
&lt;li&gt;Launch the app from Xcode.&lt;/li&gt;
&lt;li&gt;Minimize it (go to Home Screen).&lt;/li&gt;
&lt;li&gt;In Xcode, press the &quot;Stop&quot; button (square). This kills the process, but as if the system did it in the background (saving state), not like a crash.&lt;/li&gt;
&lt;li&gt;Launch the app again &lt;em&gt;from the icon on simulator/device&lt;/em&gt; (not the Run button in Xcode, otherwise the saved state will be wiped).&lt;/li&gt;
&lt;li&gt;If you did everything right, the app should launch not from scratch, but restore the controller hierarchy and their state, including scroll and cursors.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;common-pitfalls-asynchronicity-is-my-enemy&quot;&gt;Common Pitfalls: Asynchronicity Is My Enemy&lt;/h4&gt;
&lt;p&gt;I've touched on this before, but I'll repeat it, as this is the most frequent cause of bugs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Problem:&lt;/strong&gt; You saved &lt;code&gt;contentOffset.y = 2000&lt;/code&gt;. When the app launches, &lt;code&gt;decodeRestorableState&lt;/code&gt; fires, and you dutifully try to do &lt;code&gt;tableView.setContentOffset(CGPoint(0, 2000))&lt;/code&gt;. But at this moment, your &lt;code&gt;ViewModel&lt;/code&gt; has just started an asynchronous network request for data. The table is empty. Its &lt;code&gt;contentSize&lt;/code&gt; is zero. Setting the offset is either ignored or gets reset to zero at the first data reload.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Solution:&lt;/strong&gt; Never restore scroll position (or text cursor) until the content is ready.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;In &lt;code&gt;decodeRestorableState&lt;/code&gt;, only decode the parameters and save them to temporary properties of the controller.&lt;/li&gt;
&lt;li&gt;Wait for the data loading to complete.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;reloadData()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Only after this (sometimes it's useful to wrap in &lt;code&gt;DispatchQueue.main.async&lt;/code&gt; to let the layout cycle pass) apply the saved parameters.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you're using reactive frameworks like RxSwift or Combine, this is convenient to do in a subscription to data updates, right after new data has been passed to the diffable data source or table reload has been called.&lt;/p&gt;
&lt;p&gt;Careful attention to state preservation is what distinguishes a mature product from an MVP. It's care for the user that they may not consciously notice, but will definitely feel its absence if you decide to cut corners on this.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/10439/preserving-scroll-position-and-cursor-state-during-navigation-transitions</guid>
<pubDate>Sun, 25 Jan 2026 16:57:20 +0000</pubDate>
</item>
<item>
<title>SwiftUI vs UIKit in 2026: When to Use What</title>
<link>https://coderlegion.com/10405/swiftui-vs-uikit-in-2026-when-to-use-what</link>
<description>&lt;h3 id=&quot;the-myth-that-swiftui-will-replace-uikit&quot;&gt;The Myth That SwiftUI Will Replace UIKit&lt;/h3&gt;
&lt;p&gt;Let's get this straight right away: if someone tells you SwiftUI is the future and UIKit is legacy, don't believe them. Apple itself continues writing critical parts of its apps in UIKit. Open Settings on your iPhone - that's UIKit. Music? UIKit with sprinkles of SwiftUI. Photos? Same story.&lt;/p&gt;
&lt;p&gt;Why is this happening? Because SwiftUI, despite all its advantages, can't handle certain tasks as efficiently as UIKit. I've seen projects where teams tried to write everything in SwiftUI &quot;because it's trendy,&quot; then spent three months fighting navigation bugs and ended up rewriting in UIKit.&lt;/p&gt;
&lt;p&gt;Over the past few years, SwiftUI has grown significantly. The Navigation API got better with &lt;code&gt;NavigationStack&lt;/code&gt; and &lt;code&gt;NavigationSplitView&lt;/code&gt;. We got proper scroll position tracking support. But the fundamental limitations haven't gone anywhere. When you need pixel-perfect control over layout or complex animation with precise synchronization between elements, SwiftUI starts struggling.&lt;/p&gt;
&lt;p&gt;The truth is that in 2026, both frameworks have a right to exist. The question isn't what to choose, but when to use what. And most importantly - do you even need to pick just one?&lt;/p&gt;
&lt;h3 id=&quot;where-swiftui-really-wins&quot;&gt;Where SwiftUI Really Wins&lt;/h3&gt;
&lt;p&gt;SwiftUI shines when you need to quickly assemble UI from standard components. Declarative syntax isn't just pretty marketing words from Apple, it actually works.&lt;/p&gt;
&lt;p&gt;Compare:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// UIKit
let label = UILabel()
label.text = &quot;Hello&quot;
label.textColor = .systemBlue
label.font = .systemFont(ofSize: 16, weight: .bold)
view.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
    label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
// SwiftUI
Text(&quot;Hello&quot;)
    .font(.system(size: 16, weight: .bold))
    .foregroundColor(.blue)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The difference is obvious. But it's not just about lines of code.&lt;/p&gt;
&lt;p&gt;Live previews in SwiftUI - that's what made me actually fall in love with this framework. When you're writing a registration form and see changes in real-time without rebuilding the project, that saves hours. In UIKit you either launch the simulator every time or struggle with Interface Builder, which periodically breaks storyboards.&lt;/p&gt;
&lt;p&gt;Yes, previews crash sometimes. Yes, after an Xcode update they might stop working for a couple days. But when everything works - it's incredibly convenient. You can set up multiple preview configurations with different data, screen sizes, dark/light mode simultaneously. Try doing the same in UIKit - you'll have to either write a playground or create separate view controllers for each case.&lt;/p&gt;
&lt;p&gt;Cross-platform development - another SwiftUI ace. The same code works on iOS, macOS, watchOS, and - importantly in 2026 - visionOS. Apple is clearly betting on Vision Pro, and if you want to port your app there without a complete rewrite, SwiftUI gives you a head start.&lt;/p&gt;
&lt;p&gt;Real-world example: I made a habit tracking app. The main screen was a list with checkboxes and statistics. Wrote the entire UI in SwiftUI in one day. Then the client asked for a macOS version. Added a couple &lt;code&gt;#if os(macOS)&lt;/code&gt; checks to adapt the menu, and done. If this were UIKit, I'd have to write the entire macOS interface from scratch using AppKit.&lt;/p&gt;
&lt;p&gt;Here's another point many people miss: state management in SwiftUI is built into the framework. &lt;code&gt;@State&lt;/code&gt;, &lt;code&gt;@Binding&lt;/code&gt;, &lt;code&gt;@ObservableObject&lt;/code&gt; - these aren't just property wrappers, they're a whole philosophy of data management. In UIKit you have to build architecture yourself: MVVM, VIPER, Coordinator pattern. In SwiftUI basic cases work out of the box.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text(&quot;Count: \(count)&quot;)
            Button(&quot;Increment&quot;) {
                count += 1
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This code just works. No delegates, target-action, KVO, or notification center. Pressed button - state updated - UI redrawn. In UIKit the same task requires more code and explicit update management.&lt;/p&gt;
&lt;p&gt;Personally, I use SwiftUI for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;App settings (forms with toggles, sliders)&lt;/li&gt;
&lt;li&gt;Onboarding (five screens with animations between them)&lt;/li&gt;
&lt;li&gt;Simple forms (registration, login)&lt;/li&gt;
&lt;li&gt;Prototypes that need to be shown to clients in a couple days&lt;/li&gt;
&lt;li&gt;Dashboards with charts and statistics&lt;/li&gt;
&lt;li&gt;Any new features in existing apps where there are no strict performance requirements&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-uikit-is-still-irreplaceable&quot;&gt;When UIKit Is Still Irreplaceable&lt;/h3&gt;
&lt;p&gt;UIKit is older, which means more stable. There are no unexpected surprises that SwiftUI loves to deliver in every Xcode release.&lt;/p&gt;
&lt;p&gt;Take gestures. SwiftUI has &lt;code&gt;.gesture()&lt;/code&gt;, but try doing something more complex than a simple tap - for example, simultaneous pan and pinch recognition with different priorities. In UIKit it's &lt;code&gt;UIPanGestureRecognizer&lt;/code&gt; + &lt;code&gt;UIPinchGestureRecognizer&lt;/code&gt; with delegates, everything's clear and works predictably. In SwiftUI you'll have to build workarounds through &lt;code&gt;UIViewRepresentable&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I had a project with a custom image editor. Users could zoom photos, rotate them, apply filters in real-time. On SwiftUI this turned into a nightmare. Gesture modifiers conflicted with each other, priorities didn't always trigger correctly. Rewrote it in UIKit in two days, and everything worked like clockwork.&lt;/p&gt;
&lt;p&gt;Libraries. Almost the entire powerful iOS ecosystem is written for UIKit. Want to integrate a cool charting library like Charts (not Apple Charts, but third-party)? Most likely it's UIKit. Yes, you can wrap it through representable, but that's an additional layer of abstraction and potential lifecycle problems.&lt;/p&gt;
&lt;p&gt;Performance control. In UIKit you know exactly when layout happens, when a view renders, you can optimize &lt;code&gt;layoutSubviews&lt;/code&gt;, use &lt;code&gt;drawRect&lt;/code&gt; for custom drawing. In SwiftUI the framework decides when to redraw something, and sometimes does it too often.&lt;/p&gt;
&lt;p&gt;I encountered a situation where a complex SwiftUI view was redrawing 60 times per second for no visible reason. Profiling showed the problem was implicit state changes - somewhere deep in the hierarchy a &lt;code&gt;@State&lt;/code&gt; was updating, and the entire subtree was redrawing. Debugging that was quite the quest. In UIKit this doesn't happen: if a view updates, someone explicitly called &lt;code&gt;setNeedsDisplay&lt;/code&gt; or &lt;code&gt;setNeedsLayout&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Another point: memory management. In SwiftUI it's very easy to catch a retain cycle through capturing self in closures. All those &lt;code&gt;@StateObject&lt;/code&gt; and &lt;code&gt;@ObservedObject&lt;/code&gt; add magic that sometimes doesn't work as you expect. In UIKit everything's explicit: there's &lt;code&gt;weak&lt;/code&gt;, &lt;code&gt;unowned&lt;/code&gt;, and you know exactly who's holding whom.&lt;/p&gt;
&lt;p&gt;Collection views with custom layout. &lt;code&gt;UICollectionView&lt;/code&gt; with custom &lt;code&gt;UICollectionViewLayout&lt;/code&gt; is an incredibly powerful tool. You can do Pinterest-style masonry grids, circular layouts, complex transition animations between cells. SwiftUI has &lt;code&gt;LazyVGrid&lt;/code&gt; and &lt;code&gt;LazyHGrid&lt;/code&gt;, but they only cover basic cases. As soon as you need something non-standard - back to UIKit.&lt;/p&gt;
&lt;p&gt;Accessibility. Strangely enough, in UIKit the accessibility API is more mature and predictable. In SwiftUI modifiers like &lt;code&gt;.accessibilityLabel()&lt;/code&gt; work, but when you start doing something more complex - custom rotor actions, adjustable traits, grouped elements - UIKit gives more control.&lt;/p&gt;
&lt;p&gt;I choose UIKit for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Custom controls with non-standard behavior (slider with non-uniform scale, circular progress with animations)&lt;/li&gt;
&lt;li&gt;Apps where every millisecond matters (video editors, games)&lt;/li&gt;
&lt;li&gt;Projects with minimum iOS 13-14 (SwiftUI there is too raw)&lt;/li&gt;
&lt;li&gt;Complex animations with &lt;code&gt;CALayer&lt;/code&gt; and Core Animation&lt;/li&gt;
&lt;li&gt;Working with large tables (thousands of rows with good performance)&lt;/li&gt;
&lt;li&gt;Integration with complex third-party SDKs&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;hybrid-approach-best-of-both-worlds&quot;&gt;Hybrid Approach: Best of Both Worlds&lt;/h3&gt;
&lt;p&gt;The most reasonable solution - combine them. Apple itself does this, why shouldn't we? A hybrid approach lets you use SwiftUI where it's convenient and UIKit where it's necessary.&lt;/p&gt;
&lt;h4 id=&quot;uikit-in-swiftui-via-uiviewrepresentable&quot;&gt;UIKit in SwiftUI via UIViewRepresentable&lt;/h4&gt;
&lt;p&gt;Say you need a rich text editor in a SwiftUI app. The standard &lt;code&gt;TextEditor&lt;/code&gt; is too simple - no formatting, can't change fonts or colors. Ready-made SwiftUI solutions either don't exist or cost money. Take the proven &lt;code&gt;UITextView&lt;/code&gt; and wrap it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct RichTextEditor: UIViewRepresentable {
    @Binding var text: NSAttributedString
    var placeholder: String
    
    func makeUIView(context: Context) -&amp;gt; UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator
        textView.font = .systemFont(ofSize: 16)
        textView.backgroundColor = .clear
        textView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
        return textView
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        // Important: only update if text actually changed
        if uiView.attributedText != text {
            let selectedRange = uiView.selectedRange
            uiView.attributedText = text
            // Preserve cursor position
            uiView.selectedRange = selectedRange
        }
    }
    
    func makeCoordinator() -&amp;gt; Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        var parent: RichTextEditor
        
        init(_ parent: RichTextEditor) {
            self.parent = parent
        }
        
        func textViewDidChange(_ textView: UITextView) {
            // Update binding only when text changes
            parent.text = textView.attributedText
        }
        
        func textViewDidBeginEditing(_ textView: UITextView) {
            if textView.text == parent.placeholder {
                textView.text = &quot;&quot;
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It works. But there are nuances. The most important - state synchronization between SwiftUI and UIKit can be painful. Make sure not to create unnecessary updates in &lt;code&gt;updateUIView&lt;/code&gt;, otherwise you'll get an infinite redraw loop or lose focus in text fields.&lt;/p&gt;
&lt;p&gt;Another example - custom map view with annotations. MKMapView from MapKit works great, but natively it's a UIKit component. Wrap it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct MapView: UIViewRepresentable {
    @Binding var region: MKCoordinateRegion
    var annotations: [MKPointAnnotation]
    
    func makeUIView(context: Context) -&amp;gt; MKMapView {
        let mapView = MKMapView()
        mapView.delegate = context.coordinator
        return mapView
    }
    
    func updateUIView(_ mapView: MKMapView, context: Context) {
        mapView.setRegion(region, animated: true)
        
        // Update annotations only if changed
        if mapView.annotations.count != annotations.count {
            mapView.removeAnnotations(mapView.annotations)
            mapView.addAnnotations(annotations)
        }
    }
    
    func makeCoordinator() -&amp;gt; Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, MKMapViewDelegate {
        var parent: MapView
        
        init(_ parent: MapView) {
            self.parent = parent
        }
        
        func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
            parent.region = mapView.region
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;swiftui-in-uikit-via-uihostingcontroller&quot;&gt;SwiftUI in UIKit via UIHostingController&lt;/h4&gt;
&lt;p&gt;Reverse task: you have a large UIKit app, but a new settings screen is easier to write in SwiftUI. Or you need to add a beautiful dashboard with charts, and doing it in UIKit is pure hell.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class MainViewController: UIViewController {
    
    func showSettings() {
        let settingsView = SettingsView(onDismiss: { [weak self] in
            self?.dismiss(animated: true)
        })
        
        let hostingController = UIHostingController(rootView: settingsView)
        hostingController.modalPresentationStyle = .formSheet
        
        present(hostingController, animated: true)
    }
}

struct SettingsView: View {
    @AppStorage(&quot;notifications&quot;) private var notifications = true
    @AppStorage(&quot;darkMode&quot;) private var darkMode = false
    var onDismiss: () -&amp;gt; Void
    
    var body: some View {
        NavigationView {
            Form {
                Section(&quot;Preferences&quot;) {
                    Toggle(&quot;Enable Notifications&quot;, isOn: $notifications)
                    Toggle(&quot;Dark Mode&quot;, isOn: $darkMode)
                }
            }
            .navigationTitle(&quot;Settings&quot;)
            .toolbar {
                Button(&quot;Done&quot;, action: onDismiss)
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is simpler than &lt;code&gt;UIViewRepresentable&lt;/code&gt;. Main thing - properly handle lifecycle to avoid memory leaks. SwiftUI view inside hosting controller holds strong references to all its &lt;code&gt;@ObservedObject&lt;/code&gt; and &lt;code&gt;@StateObject&lt;/code&gt;, so be careful with closures.&lt;/p&gt;
&lt;p&gt;Another important point: sizing. UIHostingController doesn't always correctly determine its size. If adding a SwiftUI view as a child view controller, explicitly set frame or constraints:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;let hostingController = UIHostingController(rootView: mySwiftUIView)
addChild(hostingController)
view.addSubview(hostingController.view)

hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
    hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
    hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
    hostingController.view.heightAnchor.constraint(equalToConstant: 300)
])

hostingController.didMove(toParent: self)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I use a hybrid approach in most projects. The main architecture can be UIKit, but individual screens - SwiftUI. Or vice versa: SwiftUI base, but custom controls through UIViewRepresentable. Main thing - don't obsess over one technology and choose the right tool for the job.&lt;/p&gt;
&lt;h3 id=&quot;performance-numbers-not-myths&quot;&gt;Performance: Numbers, Not Myths&lt;/h3&gt;
&lt;p&gt;I ran tests on iPhone 15 Pro, iPhone 12 mini, and iPhone SE 2020. Here's what I got.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Startup time:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SwiftUI app (clean project): ~180ms&lt;/li&gt;
&lt;li&gt;UIKit app (clean project): ~140ms&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SwiftUI is slightly slower on startup due to additional runtime and framework initialization. On new devices the difference is imperceptible, on old ones - you feel it. If your app needs to launch instantly (camera, calculator), every millisecond matters.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Memory footprint:&lt;/strong&gt; When displaying a list of 1000 elements with images:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SwiftUI &lt;code&gt;List&lt;/code&gt;: ~45MB&lt;/li&gt;
&lt;li&gt;UIKit &lt;code&gt;UITableView&lt;/code&gt;: ~38MB&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Again SwiftUI loses, but not critically. The 7MB difference for modern devices with 6-8GB RAM is insignificant. But if you're making an app for emerging markets where users are on old iPhones, those megabytes can become a problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scroll performance:&lt;/strong&gt; Here it gets more interesting. Complex list with images, text, and buttons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SwiftUI on iPhone 15 Pro: stable 60 FPS (120 on ProMotion)&lt;/li&gt;
&lt;li&gt;SwiftUI on iPhone 12 mini: drops to 45-50 FPS during fast scrolling&lt;/li&gt;
&lt;li&gt;SwiftUI on iPhone SE 2020: drops to 40 FPS&lt;/li&gt;
&lt;li&gt;UIKit on all devices: stable 60 FPS&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SwiftUI works better on new hardware, but if your audience is on old iPhones, UIKit gives more predictable results. The difference is especially noticeable when cells have asynchronous image loading or complex calculations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Navigation stacks:&lt;/strong&gt; Deep navigation (7-8 screens) in SwiftUI sometimes glitches - screens don't dismiss correctly, especially with custom transitions or when combining sheets, fullScreenCover, and NavigationStack. In UIKit navigation controller works like a Swiss watch for 15 years already.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Animations:&lt;/strong&gt; Simple animations (fade, scale, rotation) in SwiftUI are written in seconds and work smoothly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;@State private var isAnimating = false

Circle()
    .scaleEffect(isAnimating ? 1.5 : 1.0)
    .animation(.spring(response: 0.5), value: isAnimating)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Beautiful, simple, works. But try making a complex sequence of animations with dependencies - for example, element A moves, then element B fades in, then both rotate. In SwiftUI you'll have to use &lt;code&gt;withAnimation&lt;/code&gt;, &lt;code&gt;DispatchQueue.asyncAfter&lt;/code&gt; and pray the timings align.&lt;/p&gt;
&lt;p&gt;In UIKit through &lt;code&gt;UIView.animate&lt;/code&gt; or &lt;code&gt;CAAnimation&lt;/code&gt; you have full control. You can set exact curves, completion handlers, group animations. For simple cases SwiftUI is more convenient, for complex ones - UIKit is more reliable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Concrete case: Instagram-like feed&lt;/strong&gt; Made a test: feed with posts (image, author name, likes, comments). 500 posts, scroll through the entire list.&lt;/p&gt;
&lt;p&gt;SwiftUI version:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Memory: 65MB&lt;/li&gt;
&lt;li&gt;Frame drops: 15-20% on iPhone 12&lt;/li&gt;
&lt;li&gt;Code: ~150 lines&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;UIKit version:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Memory: 52MB&lt;/li&gt;
&lt;li&gt;Frame drops: 2-3% on iPhone 12&lt;/li&gt;
&lt;li&gt;Code: ~300 lines&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;UIKit wins on performance but requires twice as much code. What to choose? Depends on priorities. If development speed matters more than the last few percent of performance - SwiftUI. If the app needs to fly even on old hardware - UIKit.&lt;/p&gt;
&lt;h3 id=&quot;decision-making-checklist&quot;&gt;Decision-Making Checklist&lt;/h3&gt;
&lt;p&gt;Here's how I make decisions for each new feature or project:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use SwiftUI if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Minimum iOS version of the project is 15+ (better 16+)&lt;/li&gt;
&lt;li&gt;Need a quick prototype or MVP&lt;/li&gt;
&lt;li&gt;UI consists of standard components (buttons, text, lists, forms)&lt;/li&gt;
&lt;li&gt;Planning to support visionOS, macOS, or watchOS&lt;/li&gt;
&lt;li&gt;Team is ready to deal with bugs and unstable API&lt;/li&gt;
&lt;li&gt;Performance on old devices isn't critical&lt;/li&gt;
&lt;li&gt;No dependency on complex UIKit libraries&lt;/li&gt;
&lt;li&gt;Project is new or you can isolate the feature into a separate module&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Use UIKit if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Need to support iOS 13-14 (or even older)&lt;/li&gt;
&lt;li&gt;Performance on old devices is critical&lt;/li&gt;
&lt;li&gt;Complex custom gestures or animations required&lt;/li&gt;
&lt;li&gt;Strong dependency on third-party UIKit libraries&lt;/li&gt;
&lt;li&gt;No time for experiments with unstable API&lt;/li&gt;
&lt;li&gt;Need pixel-perfect control over layout&lt;/li&gt;
&lt;li&gt;Working with large data volumes (thousands of elements)&lt;/li&gt;
&lt;li&gt;App needs to be maximally stable (fintech, medicine)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Use hybrid approach if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Migrating from UIKit to SwiftUI gradually&lt;/li&gt;
&lt;li&gt;Have legacy code that works and you're scared to touch it&lt;/li&gt;
&lt;li&gt;Different screens have different requirements&lt;/li&gt;
&lt;li&gt;Want new features in SwiftUI but base in UIKit&lt;/li&gt;
&lt;li&gt;Need custom UIKit components in SwiftUI app&lt;/li&gt;
&lt;li&gt;Team knows both frameworks&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Red flags for SwiftUI:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Client requires iOS 13 support&lt;/li&gt;
&lt;li&gt;App lags even on new devices&lt;/li&gt;
&lt;li&gt;Xcode previews haven't worked for a week&lt;/li&gt;
&lt;li&gt;Spending more time fighting the framework than on features&lt;/li&gt;
&lt;li&gt;Need to integrate 10+ UIKit libraries&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Red flags for UIKit:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Writing 500 lines of code for a simple form&lt;/li&gt;
&lt;li&gt;Spending an hour on layout constraints for one screen&lt;/li&gt;
&lt;li&gt;Junior developer can't figure out storyboards&lt;/li&gt;
&lt;li&gt;Every UI change requires full rebuild&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;conclusion-it-s-about-trade-offs-not-trends&quot;&gt;Conclusion: It's About Trade-offs, Not Trends&lt;/h3&gt;
&lt;p&gt;I manage several projects simultaneously. In one - pure SwiftUI, because it's an internal team tool with minimal performance requirements. In another - UIKit with small SwiftUI inclusions for simple screens like settings or onboarding. In the third - pure UIKit, because the client requires iOS 14 support and performance on old devices is critical.&lt;/p&gt;
&lt;p&gt;Choosing a framework is about context, not hype. SwiftUI is a great tool for certain tasks. But it's not a silver bullet. UIKit isn't going anywhere for a very long time, and that's okay.&lt;/p&gt;
&lt;p&gt;If SwiftUI solves your problem faster and more reliably - use it. If not - don't hesitate to write in UIKit. If you need both - combine them. Your job is to deliver a product that works, not follow trends from conferences.&lt;/p&gt;
&lt;p&gt;Final advice: learn both frameworks. Even if you're a SwiftUI fanatic, knowing UIKit will make you a better developer. And vice versa. Understanding how UIKit works under the hood helps write more efficient SwiftUI code. And knowing SwiftUI patterns can improve your UIKit architecture.&lt;/p&gt;
&lt;p&gt;In 2026, the best iOS developer isn't the one who only knows SwiftUI or only UIKit. It's the one who knows both and can choose the right tool for the specific task.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/10405/swiftui-vs-uikit-in-2026-when-to-use-what</guid>
<pubDate>Sat, 24 Jan 2026 16:38:16 +0000</pubDate>
</item>
</channel>
</rss>