<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged swift</title>
<link>https://coderlegion.com/tag/swift</link>
<description>Powered by Question2Answer</description>
<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>Fine in Chrome, broken in Preview: two PDFKit gotchas for anyone who writes PDFs</title>
<link>https://coderlegion.com/32435/fine-in-chrome-broken-in-preview-two-pdfkit-gotchas-for-anyone-who-writes-pdfs</link>
<description>&lt;p&gt;&lt;em&gt;This article was originally published on Hashnode: &lt;a rel=&quot;nofollow&quot; href=&quot;https://wangyan.hashnode.dev/fine-in-chrome-broken-in-preview-two-pdfkit-gotchas-for-anyone-who-writes-pdfs&quot;&gt;Fine in Chrome, broken in Preview&lt;/a&gt;. It is republished here with light edits.&lt;/em&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; we make &lt;a rel=&quot;nofollow&quot; href=&quot;https://lensup.ai/pdf-editor/&quot;&gt;LensUp's PDF editor&lt;/a&gt;, the browser tool where we ran into both bugs, so we have a direct connection to the product mentioned here. This write-up was drafted and edited with AI assistance; the bugs, test results and reproduction scripts come from our own testing, and the scripts are public so you can check every claim yourself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;We build a PDF editor that runs in the browser. It opens a PDF, lets you add text, a signature or a highlight, fill in form fields, and writes the result back as an incremental update. We checked every save with three renderers: pdf.js (Firefox and many web viewers), pdfium (Chrome) and poppler (most Linux tools). All three were happy.&lt;/p&gt;
&lt;p&gt;Then we rendered the same files with Apple's PDFKit, the engine behind Preview on the Mac, the Files app on iOS and PDFs in Safari. It showed two problems none of the others did: a translucent highlighter that turned into an opaque bar or a sentence that vanished, and form answers printed twice on top of each other.&lt;/p&gt;
&lt;p&gt;Neither problem shows up if you only test on Linux or in Chrome. Here are both, with small scripts that reproduce them on any Mac in a few seconds: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ppop123/pdfkit-pitfalls&quot;&gt;github.com/ppop123/pdfkit-pitfalls&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;gotcha-1-an-incremental-update-must-use-the-same-cross-reference-form-as-the-file-it-updates&quot;&gt;Gotcha 1: an incremental update must use the same cross-reference form as the file it updates&lt;/h3&gt;
&lt;h4 id=&quot;some-background&quot;&gt;Some background&lt;/h4&gt;
&lt;p&gt;An incremental update appends to a PDF instead of rewriting it. The new and changed objects go at the end, followed by a new cross-reference section that points back to the previous one. That's how signed PDFs stay signed, and it's why saving an edit doesn't mean rewriting the whole file: only the edit is written.&lt;/p&gt;
&lt;p&gt;The cross-reference section comes in two forms. The classic one is an &lt;code&gt;xref&lt;/code&gt; table followed by a &lt;code&gt;trailer&lt;/code&gt;. PDF 1.5 added cross-reference streams (&lt;code&gt;/Type /XRef&lt;/code&gt;), which usually come with object streams (&lt;code&gt;/Type /ObjStm&lt;/code&gt;): many small non-stream objects compressed together into one stream.&lt;/p&gt;
&lt;p&gt;We opened files for incremental update with pdf-lib (the @cantoo/pdf-lib fork, which supports incremental updates) and saved with the defaults, which turn object streams on. So whatever the original file used, our update ended in an XRef stream, with our new dictionaries packed into an object stream.&lt;/p&gt;
&lt;h4 id=&quot;what-pdfkit-does-with-that&quot;&gt;What PDFKit does with that&lt;/h4&gt;
&lt;p&gt;Take a one-page PDF that ends in a classic &lt;code&gt;xref&lt;/code&gt; table and append a highlighter stroke: a yellow rectangle drawn through an ExtGState with 60% opacity and the Multiply blend mode. Written as a table-based update, it renders the same everywhere. Written as an XRef stream with the ExtGState inside an object stream, poppler still draws it correctly, but PDFKit shows an opaque yellow bar where the sentence was.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ppop123/pdfkit-pitfalls/main/xref-mismatch.png&quot; alt=&quot;The same highlight, appended four ways, rendered by Apple PDFKit and by poppler&quot;&gt;&lt;/p&gt;
&lt;p&gt;Render it with &lt;code&gt;CG_PDF_VERBOSE=1&lt;/code&gt; set, and CoreGraphics says why:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;encountered unexpected object type: 7.
missing or invalid object number.
missing or invalid cross-reference stream.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It gives up on the update's XRef stream. Objects the update wrote out on their own are still found: the rectangle itself is drawn. Objects inside the update's object stream are not, and the ExtGState is one of them. Without it the rectangle is painted fully opaque, on top of the text.&lt;/p&gt;
&lt;p&gt;The reverse breaks too. Over an original that ends in an XRef stream, an update written with a classic table gets:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;failed to find start of cross-reference section.
font `Helvetica-7098480789' not found in document.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This time it's the original's object stream that gets lost, and the font was in it. The highlight is there; the sentence is gone.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Original's last section &lt;/th&gt;&lt;th&gt;Update written as &lt;/th&gt;&lt;th&gt;PDFKit&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;fine&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;XRef stream with object streams &lt;/td&gt;&lt;td&gt;loses the objects in the update's object stream&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;XRef stream &lt;/td&gt;&lt;td&gt;&lt;code&gt;xref&lt;/code&gt; table &lt;/td&gt;&lt;td&gt;loses the objects in the original's object streams&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;XRef stream &lt;/td&gt;&lt;td&gt;XRef stream &lt;/td&gt;&lt;td&gt;fine&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;In our editor's checks, pdf.js, pdfium and poppler accepted all four combinations. That's exactly why a Linux-only test suite never caught it.&lt;/p&gt;
&lt;h4 id=&quot;the-fix&quot;&gt;The fix&lt;/h4&gt;
&lt;p&gt;Look at where the original's last &lt;code&gt;startxref&lt;/code&gt; points. If it lands on the keyword &lt;code&gt;xref&lt;/code&gt;, the last section is a table; otherwise it's a stream. Then write the update in the same form:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// The form of the original's last cross-reference section: 'table' or 'stream'.
function xrefForm(bytes) {
  const tail = new TextDecoder('latin1').decode(bytes.subarray(Math.max(0, bytes.length - 2048)));
  const found = [...tail.matchAll(/startxref\s+(\d+)/g)];
  const offset = found.length ? Number(found[found.length - 1][1]) : NaN;
  if (!(offset &amp;gt;= 0 &amp;amp;&amp;amp; offset &amp;lt; bytes.length)) return 'table';
  let i = offset;
  while (i &amp;lt; bytes.length &amp;amp;&amp;amp; /\s/.test(String.fromCharCode(bytes[i]))) i += 1;
  return new TextDecoder('latin1').decode(bytes.subarray(i, i + 4)) === 'xref' ? 'table' : 'stream';
}

const doc = await PDFDocument.load(bytes, { forIncrementalUpdate: true });
// … edit …
const saved = await doc.save({ useObjectStreams: xrefForm(bytes) === 'stream' });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Hybrid-reference files, whose trailer is a table that also points to an XRef stream, end in a table, so they count as &lt;code&gt;'table'&lt;/code&gt; here.&lt;/p&gt;
&lt;h3 id=&quot;gotcha-2-pdfkit-draws-filled-text-fields-itself&quot;&gt;Gotcha 2: PDFKit draws filled text fields itself&lt;/h3&gt;
&lt;h4 id=&quot;what-pdfkit-does&quot;&gt;What PDFKit does&lt;/h4&gt;
&lt;p&gt;A form field's look is supposed to come from its appearance stream (&lt;code&gt;/AP&lt;/code&gt;): a small content stream in which the writer draws the value. For a text field that has a value, PDFKit does something else. It draws the value (&lt;code&gt;/V&lt;/code&gt;) itself with CoreText, in the font named by the field's default appearance string (&lt;code&gt;/DA&lt;/code&gt;), and it drops the text-showing operators (&lt;code&gt;BT … Tj … ET&lt;/code&gt;) from the appearance stream. Everything else in the appearance stream it still paints, such as paths and Form XObjects.&lt;/p&gt;
&lt;p&gt;You can see the first half even with pdf-lib's own appearance. In the first row below, poppler draws pdf-lib's auto-sized Helvetica, while PDFKit ignores it and draws its own rendering of the value at a different size.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ppop123/pdfkit-pitfalls/main/field-double-draw.png&quot; alt=&quot;One filled text field with three kinds of appearance, rendered by Apple PDFKit and by poppler&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;why-that-bit-us&quot;&gt;Why that bit us&lt;/h4&gt;
&lt;p&gt;Our text engine shapes text itself (joining Persian and Arabic, ordering mixed-direction text, falling back between fonts) and draws the result as glyph outlines, so every reader shows exactly the same glyphs. In a field's appearance, those outlines are paths. PDFKit painted the paths and then drew &lt;code&gt;/V&lt;/code&gt; again with CoreText on top: two copies of every answer, as in the second row. On a real IRS W-9 the two copies didn't even line up, because PDFKit left-aligned its copy while our appearance was right-aligned.&lt;/p&gt;
&lt;p&gt;None of these stopped the second drawing: making the field read-only, setting &lt;code&gt;/NeedAppearances&lt;/code&gt; to false, removing the &lt;code&gt;/Tx BMC … EMC&lt;/code&gt; marked content, or changing the font or color in &lt;code&gt;/DA&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;the-fix-1&quot;&gt;The fix&lt;/h4&gt;
&lt;p&gt;Write the value as text after all, just not in a real font: use a Type 3 font whose glyph procedures are the same outlines. PDFKit drops text-showing operators, Type 3 included, and draws the value once in its own rendering. pdf.js, pdfium and poppler draw the Type 3 glyphs, which are our outlines. That's the third row.&lt;/p&gt;
&lt;p&gt;The trade-off is that in Preview you get PDFKit's rendering of the value (its font from &lt;code&gt;/DA&lt;/code&gt;, its alignment), not yours. For us that was acceptable, since CoreText shapes Persian correctly.&lt;/p&gt;
&lt;p&gt;Two related notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dropdowns and list boxes are different.&lt;/strong&gt; PDFKit doesn't redraw them, so for choice fields we kept the outlines.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Acrobat had its own problem with outlines.&lt;/strong&gt; Acrobat Reader drew our outline appearances in some forms but left the box blank in others (fields created by pdf-lib, with a &lt;code&gt;/MK&lt;/code&gt; background color). It drew the Type 3 version in both.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;bonus-gotcha-from-acrobat-reader-extensions-ur3-&quot;&gt;Bonus gotcha from Acrobat: Reader extensions (&lt;code&gt;/UR3&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;Many official forms, the W-9 among them, carry usage rights (&lt;code&gt;/Perms /UR3&lt;/code&gt;, &quot;Reader extensions&quot;): a signature over the file as issued. Change the file and Acrobat Reader opens it with:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;This document enabled extended features in Adobe Acrobat Reader. The document has been changed since it was created and use of extended features is no longer available.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Every save changes the file, so on save we now remove &lt;code&gt;/UR3&lt;/code&gt; and the older &lt;code&gt;/UR&lt;/code&gt; from &lt;code&gt;/Perms&lt;/code&gt; (and &lt;code&gt;/Perms&lt;/code&gt; itself if nothing else is left). A certification signature (&lt;code&gt;/DocMDP&lt;/code&gt;) stays. iText does the same thing with &lt;code&gt;removeUsageRights&lt;/code&gt;. To be clear about what we've checked: we saw the warning in Acrobat Reader, and we haven't re-tested the fixed files there yet.&lt;/p&gt;
&lt;h3 id=&quot;how-to-test-with-pdfkit-without-clicking-through-preview&quot;&gt;How to test with PDFKit without clicking through Preview&lt;/h3&gt;
&lt;p&gt;PDFKit is scriptable from Swift in about twenty lines: open the file with &lt;code&gt;PDFDocument&lt;/code&gt;, draw page 1 into a bitmap with &lt;code&gt;PDFPage.draw(with:to:)&lt;/code&gt; (form widgets included), and write a PNG. Run it with &lt;code&gt;CG_PDF_VERBOSE=1&lt;/code&gt; and CoreGraphics prints what it didn't like. The repo's &lt;code&gt;render-pdfkit.swift&lt;/code&gt; does exactly that. &lt;code&gt;qlmanage -t&lt;/code&gt;, the Quick Look thumbnailer, goes through PDFKit too.&lt;/p&gt;
&lt;p&gt;If you compare against pdfium from Python: pypdfium2 doesn't draw form fields unless you call &lt;code&gt;pdf.init_forms()&lt;/code&gt; first. Without it, every field looks empty, which is easy to mistake for a bug in your writer.&lt;/p&gt;
&lt;h3 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Three renderers agreeing on a file doesn't mean the viewer on every Mac and iPhone will. If your PDFs reach Apple devices, render them with PDFKit, in CI on a macOS runner or at least by hand.&lt;/li&gt;
&lt;li&gt;In an incremental update, match the cross-reference form of the file you're updating.&lt;/li&gt;
&lt;li&gt;For a filled text field, PDFKit draws the value itself. Avoid drawing the value into the appearance as paths; text in a Type 3 font works everywhere we tested.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The reproductions, in Node and Swift, are MIT-licensed: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ppop123/pdfkit-pitfalls&quot;&gt;github.com/ppop123/pdfkit-pitfalls&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;About the author: the team behind this post builds &lt;a rel=&quot;nofollow&quot; href=&quot;https://lensup.ai/&quot;&gt;LensUp Scanner&lt;/a&gt;, a free set of browser tools for scanning documents and working with PDFs. If you have seen other PDFKit quirks, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ppop123/pdfkit-pitfalls&quot;&gt;pdfkit-pitfalls repo&lt;/a&gt; is open to issues and pull requests.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32435/fine-in-chrome-broken-in-preview-two-pdfkit-gotchas-for-anyone-who-writes-pdfs</guid>
<pubDate>Mon, 26 Oct 2026 00:16:01 +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>Eunify: Bridging the Continuity Gap Between Mac and Android</title>
<link>https://coderlegion.com/18702/eunify-bridging-the-continuity-gap-between-mac-and-android</link>
<description>&lt;p&gt;If you’ve ever lived deep within the Apple ecosystem, you know the magic of Continuity. The ability to copy a link on your iPhone and instantly paste it on your Mac, or use your phone’s camera as a wireless webcam, feels like living in the future. Apple built these features seamlessly into their OS, giving their users a cohesive multi-device experience.&lt;/p&gt;
&lt;p&gt;But what if you love your Mac, but prefer an Android phone? Out of the box, you get nothing. The wall around Apple's garden is tall, and cross-platform continuity has historically been clunky, relying on a patchwork of third-party apps that drain battery and feel decidedly un-magical.&lt;/p&gt;
&lt;p&gt;Eunify (&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/FredrickOdondi/eunify&quot;&gt;https://github.com/FredrickOdondi/eunify&lt;/a&gt;) changes that.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What is Eunify?&lt;/strong&gt;&lt;br&gt;
Eunify is an open-source &quot;Continuity Bridge&quot; designed specifically for users who split their digital lives between macOS and Android. It consists of a native macOS host app (built with Swift and SwiftUI) and an Android client (built with Flutter), tied together by a clever cloud relay system.&lt;/p&gt;
&lt;p&gt;The goal? To give Android users the exact same seamless, real-time continuity features that were previously exclusive to the Apple ecosystem.&lt;/p&gt;
&lt;p&gt;Bringing Apple Magic to Android&lt;br&gt;
Eunify doesn't just mimic one or two features; it aims to replicate the entire Continuity suite:&lt;/p&gt;
&lt;p&gt;Universal Clipboard: Copy text or a block of code on your Mac, and it instantly lands in your Android clipboard.&lt;br&gt;
Tab &amp;amp; URL Push: Drag a Chrome or Safari tab onto the Eunify Mac app, and it opens instantly on your phone. You can also send links in reverse, from your Android back to your Mac browser.&lt;br&gt;
Notification Mirroring: See your Android push notifications appear on your Mac in real-time, complete with actionable quick replies.&lt;br&gt;
File Transfer: Drag any file onto the Mac app to send it directly to your Android device via high-speed P2P WebRTC.&lt;br&gt;
Continuity Camera: Stream your Android phone's camera feed directly to your Mac to use as a wireless webcam.&lt;br&gt;
Biometric Mac Unlock: Tap your fingerprint on your Android phone to unlock your Mac remotely.&lt;br&gt;
Media Sync: See what's playing on your Android and control it right from your Mac desktop.&lt;br&gt;
How It Works Under the Hood&lt;br&gt;
Achieving this level of synergy across fundamentally different operating systems requires a robust technical foundation.&lt;/p&gt;
&lt;p&gt;The Handshake: It starts with a simple QR code. The macOS app generates a QR code encoding a unique room ID. Scanning this with the Android app connects both devices to the same Supabase Realtime (WebSocket) channel.&lt;br&gt;
Signaling: Standard events—like URL pushes, clipboard syncs, and notification mirroring—are securely transmitted as structured JSON payloads over the Supabase cloud relay.&lt;br&gt;
Peer-to-Peer Streaming: For heavy lifting (binary data like files and live camera frames), the WebSocket channel is used to negotiate a direct WebRTC DataChannel. This ensures fast, secure, peer-to-peer data streaming without routing massive payloads through a cloud server.&lt;br&gt;
Conquering Android Background Restrictions&lt;br&gt;
One of the biggest hurdles for an app like this is keeping the connection alive while the mobile app is in the background. Modern Android is notoriously aggressive about killing background processes to save battery.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Eunify solves this with a two-layer approach:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A Foreground Service keeps the Supabase WebSocket connection alive with a WakeLock.&lt;br&gt;
A separate Background Isolate written in Dart re-initializes the connection independently, handling message delivery even if the main UI is completely killed by the OS.&lt;br&gt;
(It even employs clever workarounds, like using FlutterForegroundTask to briefly trampoline the app to the foreground on Android 10+ just to bypass background clipboard-writing restrictions.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Get Started&lt;/strong&gt;&lt;br&gt;
If you're tired of emailing yourself links and want to bring your Android device into your Mac's ecosystem, Eunify is open source and ready to try.&lt;/p&gt;
&lt;p&gt;Check out the code, read the full protocol design, and download the latest releases directly from the GitHub repository:&lt;/p&gt;
&lt;p&gt;Explore Eunify on GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/FredrickOdondi/eunify&quot;&gt;https://github.com/FredrickOdondi/eunify&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apkpure.com/eunify/com.eunify.client&quot;&gt;https://apkpure.com/eunify/com.eunify.client&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Built with Swift, SwiftUI, Flutter, Dart, and Supabase.&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/18702/eunify-bridging-the-continuity-gap-between-mac-and-android</guid>
<pubDate>Fri, 22 May 2026 17:24:01 +0000</pubDate>
</item>
<item>
<title>Eunify: Cross-Platform Continuity System for Android and MacOS</title>
<link>https://coderlegion.com/18178/eunify-cross-platform-continuity-system-for-android-and-macos</link>
<description>&lt;blockquote&gt;&lt;p&gt;I Was Just Solving a Problem that I Face as an Android Phone User&lt;br&gt;
Working With MacOS. I Am Sure There's Someone Else Out There With&lt;br&gt;
Similar Challenges&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I developed Eunify, a continuity platform engineered to create a seamless, native-feeling ecosystem between macOS and Android. By bridging these two distinct operating systems, Eunify eliminates workflow friction and ensures uninterrupted cross-device synchronization for productivity and communication.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Core Features&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Universal Clipboard: Instantly synchronize your clipboard across operating systems. Text or media copied on your macOS machine is immediately available to paste on your Android device.&lt;/p&gt;
&lt;p&gt;Fluid Browser Handoff: Maintain uninterrupted browsing sessions across devices. You can drag and drop an active tab into Eunify on macOS to instantly launch it on your Android device, or push a tab from Android to pop up seamlessly on your Mac.&lt;/p&gt;
&lt;p&gt;Bidirectional File Transfer: Securely and rapidly send files directly back and forth between your macOS and Android devices without relying on external cloud services.&lt;/p&gt;
&lt;p&gt;Biometric Authentication Integration: Enhance your security workflow by leveraging your Android device's native biometric sensors to unlock your macOS laptop.&lt;/p&gt;
&lt;p&gt;Video Call Continuity: Effortlessly hand off active video calls from your macOS machine to your Android device, allowing you to transition from your desk to mobile without dropping the connection.&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=6697081181013665602&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7464064703061912304&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5071500663947463985&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7189078171734645109&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=7666383216313576231&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11077430319582350407&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=12703508908033091367&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=783774772349424263&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=5679720936373908046&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/18178/eunify-cross-platform-continuity-system-for-android-and-macos</guid>
<pubDate>Sun, 17 May 2026 22:12:01 +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>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>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>Modern Networking in iOS with URLSession and async/await – Part 2</title>
<link>https://coderlegion.com/8902/modern-networking-in-ios-with-urlsession-and-async-await-part-2</link>
<description>&lt;p&gt;In &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/8489/modern-networking-in-ios-with-urlsession-and-async-await-a-practical-guide&quot;&gt;Part 1&lt;/a&gt; we built a clean networking layer using Swift’s modern concurrency and &lt;code&gt;URLSession&lt;/code&gt;. The example endpoints were public and didn’t require authentication. Real-world apps, however, usually require you to authenticate users and attach short-lived access tokens to every request. These tokens eventually expire, and we need a way to obtain a new access token without forcing the user to log in again. In this part we’ll walk through how to implement a secure and robust token handling mechanism on top of the networking client from &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/8489/modern-networking-in-ios-with-urlsession-and-async-await-a-practical-guide&quot;&gt;Part 1&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;understanding-access-and-refresh-tokens&quot;&gt;Understanding access and refresh tokens&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Access tokens&lt;/strong&gt; grant access to protected APIs. They are typically short‑lived (minutes to hours) so that an attacker only has a limited window if the token is compromised.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Refresh tokens&lt;/strong&gt; are credentials that allow the client to request a new access token. Because they can be used to mint new access tokens, they need to be protected as if they were user passwords.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;token-model&quot;&gt;Token model&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;import Foundation

/// A container for access/refresh tokens and their expiration date.
/// Conforms to Codable so it can be encoded to and decoded from JSON.

struct TokenBundle: Codable {
   let accessToken: String
   let refreshToken: String
   let expiresAt: Date

   /// Returns true if the access token is expired.
   var isExpired: Bool {
      return expiresAt &amp;lt;= Date()
}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This struct mirrors the JSON payload returned by your authentication server (e.g. &lt;code&gt;{ &quot;access_token&quot;:…, &quot;refresh_token&quot;:…, &quot;expires_at&quot;:… }&lt;/code&gt;). The &lt;code&gt;isExpired&lt;/code&gt; is a computed property that helps us decide when to refresh.&lt;/p&gt;
&lt;h3 id=&quot;secure-persistence---keychain&quot;&gt;Secure persistence - Keychain&lt;/h3&gt;
&lt;p&gt;Let's create a helper for storing and retrieving tokens:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import Foundation
import Security

/// A simple helper for storing and retrieving data from the Keychain.
/// This stores and retrieves a single `TokenBundle` under a fixed key.
/// You can generalize it if you need to store more items.
enum KeychainService {

    /// Errors thrown by keychain operations.
    enum KeychainError: Error {
        case unhandled(status: OSStatus)
    }

    /// Change this to your app’s bundle identifier to avoid key collisions.
    private static let service = &quot;pro.mobile.dev.ModernNetworking&quot;
    private static let account = &quot;authTokens&quot;

    /// Saves the given token bundle to the keychain, overwriting any existing value.
    static func save(_ tokens: TokenBundle) throws {
        let data = try JSONEncoder().encode(tokens)
        // Remove any existing entry.
        try? delete()
        // Add the new entry.
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecValueData as String: data
        ]
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.unhandled(status: status)
        }
    }

    /// Loads the token bundle from the keychain, or returns nil if no entry exists.
    static func load() throws -&amp;gt; TokenBundle? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        var item: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &amp;amp;item)
        if status == errSecItemNotFound {
            return nil
        }
        guard status == errSecSuccess, let data = item as? Data else {
            throw KeychainError.unhandled(status: status)
        }
        return try JSONDecoder().decode(TokenBundle.self, from: data)
    }

    /// Removes the token bundle from the keychain.
    static func delete() throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account
        ]
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.unhandled(status: status)
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This helper encodes a &lt;code&gt;TokenBundle&lt;/code&gt; to &lt;code&gt;Data&lt;/code&gt;, stores it under a single key in the Keychain, and decodes it back to a TokenB&lt;code&gt;enter code here&lt;/code&gt;undle when needed. It also includes a &lt;code&gt;delete()&lt;/code&gt; method to clear the stored tokens when the user logs out.&lt;/p&gt;
&lt;h3 id=&quot;concurrency-safe-token-management&quot;&gt;Concurrency‑safe token management&lt;/h3&gt;
&lt;p&gt;When multiple network requests need a valid token simultaneously, we must avoid refreshing the token more than once at the same time.&lt;br&gt;
Using a Swift &lt;code&gt;actor&lt;/code&gt; ensures that only one refresh call happens concurrently.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import Foundation

/// Manages access and refresh tokens.
/// Uses an actor to serialize token access and refresh operations safely.
actor AuthManager {
    /// The currently running refresh task, if any.
    private var refreshTask: Task&amp;lt;TokenBundle, Error&amp;gt;?
    /// Cached token bundle loaded from the keychain.
    private var currentTokens: TokenBundle?

    init() {
        // Load any persisted tokens at initialization.
        currentTokens = try? KeychainService.load()
    }

    /// Returns a valid token bundle, refreshing if necessary.
    /// Throws if no tokens are available or if refresh fails.
    func validTokenBundle() async throws -&amp;gt; TokenBundle {
        // If a refresh is already in progress, await its result.
        if let task = refreshTask {
            return try await task.value
        }
        // No stored tokens means the user must log in.
        guard let tokens = currentTokens else {
            throw AuthError.noCredentials
        }
        // If not expired, return immediately.
        if !tokens.isExpired {
            return tokens
        }
        // Otherwise refresh.
        return try await refreshTokens()
    }

    /// Forces a refresh of the tokens regardless of expiration status.
    func refreshTokens() async throws -&amp;gt; TokenBundle {
        // If a refresh is already happening, await it.
        if let task = refreshTask {
            return try await task.value
        }
        // Ensure we have a refresh token.
        guard let tokens = currentTokens else {
            throw AuthError.noCredentials
        }
        // Create a new task to perform the refresh.
        let task = Task { () throws -&amp;gt; TokenBundle in
            defer { refreshTask = nil }
            // Build a request to your auth server’s token endpoint.
            // Replace api.example.com and path with your actual auth server and endpoint.
            var components = URLComponents()
            components.scheme = &quot;https&quot;
            components.host = &quot;api.example.com&quot; // change to your auth server
            components.path = &quot;/oauth/token&quot;
            var request = URLRequest(url: components.url!)
            request.httpMethod = &quot;POST&quot;
            request.setValue(&quot;application/json&quot;, forHTTPHeaderField: &quot;Content-Type&quot;)
            let body: [String: String] = [&quot;refresh_token&quot;: tokens.refreshToken]
            request.httpBody = try JSONEncoder().encode(body)
            // Perform the network call.
            let (data, response) = try await URLSession.shared.data(for: request)
            guard let httpResponse = response as? HTTPURLResponse,
                  (200..&amp;lt;300).contains(httpResponse.statusCode) else {
                throw AuthError.invalidCredentials
            }
            // Decode the new tokens.
            let newTokens = try JSONDecoder().decode(TokenBundle.self, from: data)
            // Persist and cache the tokens.
            try KeychainService.save(newTokens)
            currentTokens = newTokens
            return newTokens
        }
        // Store the in‑flight refresh task so concurrent callers reuse it.
        refreshTask = task
        return try await task.value
    }

    /// Clears stored tokens from memory and the keychain.
    func clearTokens() async throws {
        currentTokens = nil
        try KeychainService.delete()
    }
}

/// Errors thrown by `AuthManager`.
enum AuthError: Error {
    /// No tokens exist; the user must log in.
    case noCredentials
    /// Refresh failed or credentials are invalid.
    case invalidCredentials
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This &lt;code&gt;actor&lt;/code&gt; lazily loads tokens from the keychain at initialization.&lt;br&gt;
When a valid token is requested, it either returns the cached token (if it hasn’t expired) or refreshes it by calling server’s refresh token endpoint.&lt;br&gt;
The refresh process is protected by a single Task: if multiple calls to &lt;code&gt;validTokenBundle()&lt;/code&gt; happen concurrently, they will all await the same refresh task.&lt;br&gt;
If no tokens are stored or the refresh fails, &lt;code&gt;AuthManager&lt;/code&gt; throws an &lt;code&gt;AuthError&lt;/code&gt; we can react to and logout the user.&lt;/p&gt;
&lt;h3 id=&quot;adding-auth-token-to-endpoints-that-require-it&quot;&gt;Adding auth token to endpoints that require it&lt;/h3&gt;
&lt;p&gt;We will update our &lt;code&gt;Endpoint&lt;/code&gt; enum to have a variable indicating whether this endpoint requires authentication or not.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var requiresAuthentication: Bool {
    switch self {
    case .secureEndpoint: return true
    default : return false
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We will add the auth token when needed inside the &lt;code&gt;NetworkClient func send&amp;lt;T: APIRequest&amp;gt;&lt;/code&gt; :&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if request.endpoint.requiresAuthentication {
    let tokens = try await authManager.validTokenBundle()
    urlRequest.setValue(&quot;Bearer \(tokens.accessToken)&quot;, forHTTPHeaderField: &quot;Authorization&quot;)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We will need an AuthManager object in our client:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;final class NetworkClient {
    private let authManager: AuthManager

    init(authManager: AuthManager = AuthManager()) {
        self.authManager = authManager
    }

    func send&amp;lt;T: APIRequest&amp;gt;(_ request: T, allowRetry: Bool = true) async throws -&amp;gt; T.Response {
        ...
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Our &lt;code&gt;send&lt;/code&gt; function requires a variable to know if a retry is allowed:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; func send&amp;lt;T: APIRequest&amp;gt;(_ request: T, allowRetry: Bool = true) async throws -&amp;gt; T.Response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Lastly let's check for auth errors and react accordingly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if httpResponse.statusCode == 401 {
    guard allowRetry else {
        throw NetworkError.unauthorized // A new error type 
    }

    do {
        _ = try await authManager.refreshTokens()
        return try await send(request, allowRetry: false)
    } catch {
        // refresh failed -&amp;gt; force re-auth path
        // optionally: try? await authManager.clearTokens()
        throw error
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We are done!&lt;br&gt;
You can check out the full project on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Pro-Mobile-Dev/ios-modern-network-layer&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Networking</category>
<guid isPermaLink="true">https://coderlegion.com/8902/modern-networking-in-ios-with-urlsession-and-async-await-part-2</guid>
<pubDate>Thu, 25 Dec 2025 14:35:26 +0000</pubDate>
</item>
<item>
<title>Modern Networking in iOS with URLSession and async/await: A Practical Guide</title>
<link>https://coderlegion.com/8489/modern-networking-in-ios-with-urlsession-and-async-await-a-practical-guide</link>
<description>&lt;p&gt;Networking is at the core of most iOS apps. Almost every modern application needs to talk to remote services, fetch or send data, and present fetched information in real time. This makes it essential to have a reliable, safe, and maintainable approach to performing HTTP requests.&lt;/p&gt;
&lt;p&gt;Apple provides &lt;code&gt;URLSession&lt;/code&gt; as the standard way to handle network communication in iOS. It's robust, battle-tested, and highly configurable. Whether you need to fetch JSON, download files, or upload data, &lt;code&gt;URLSession&lt;/code&gt; is the foundation you'll build on. In the past, developers relied on completion handlers or Combine to manage these asynchronous calls. While functional, those methods introduced complexity and verbosity.&lt;/p&gt;
&lt;p&gt;Enter &lt;code&gt;async/await&lt;/code&gt;, introduced in Swift 5.5. This modern concurrency model transforms asynchronous code into something that reads and behaves like synchronous code. With &lt;code&gt;async/await&lt;/code&gt;, networking becomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Easier to read and maintain&lt;/li&gt;
&lt;li&gt;Safer and less error-prone&lt;/li&gt;
&lt;li&gt;More Swift-native and familiar&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This tutorial will walk you through building a modern, fully async network layer using &lt;code&gt;URLSession&lt;/code&gt; and &lt;code&gt;async/await&lt;/code&gt; and display the fetched data in a SwiftUI list.&lt;/p&gt;
&lt;h3 id=&quot;the-apis-we-ll-use&quot;&gt;The APIs We’ll Use&lt;/h3&gt;
&lt;p&gt;The following public testing APIs will be used&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET https://jsonplaceholder.typicode.com/users
GET https://jsonplaceholder.typicode.com/posts
GET https://jsonplaceholder.typicode.com/comments
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;They return dummy data, which we will use to display lists of users, posts and comments&lt;/p&gt;
&lt;h3 id=&quot;step-1-create-the-models&quot;&gt;Step 1: Create the Models&lt;/h3&gt;
&lt;p&gt;Before we can decode any JSON, we need to define some models that mirror the structure of the API responses. In Swift, we use the Codable protocol to enable easy JSON parsing and Identifiable to make integration with SwiftUI lists seamless.&lt;/p&gt;
&lt;p&gt; Codable allows us to convert between JSON data and Swift structs automatically.&lt;br&gt;
 Identifiable is required for SwiftUI's List view to uniquely identify each element in a collection.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let username: String
    let email: String
}

struct Post: Codable, Identifiable {
    let id: Int
    let userId: Int
    let title: String
    let body: String
}

struct Comment: Codable, Identifiable {
    let id: Int
    let postId: Int
    let name: String
    let email: String
    let body: String
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-2-create-the-networking-layer&quot;&gt;Step 2: Create the Networking Layer&lt;/h3&gt;
&lt;p&gt;We use two powerful Swift features: enums and generics to create a scalable and maintainable networking layer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;enum Endpoint&lt;/strong&gt; &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The Endpoint enum encapsulates all supported API paths in a single type-safe structure.&lt;/li&gt;
&lt;li&gt;Each case in the enum maps to a specific endpoint (e.g. .users, .comments(postId:)), avoiding stringly-typed URLs scattered throughout your code.&lt;/li&gt;
&lt;li&gt;It helps centralize logic like HTTP method selection and URL path construction.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;enum Endpoint {
    case users
    case posts
    case comments(postId: Int)

    var path: String {
        switch self {
        case .users: return &quot;/users&quot;
        case .posts: return &quot;/posts&quot;
        case .comments(let postId): return &quot;/posts/\(postId)/comments&quot;
        }
    }

    var method: HTTPMethod {
        switch self {
        case .users, .posts, .comments: return .GET
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;enum NetworkError&lt;/strong&gt; &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Structured error handling: Clearly categorizes different failure scenarios such as URL construction issues, server errors, and decoding problems.&lt;/li&gt;
&lt;li&gt;Better debugging: By distinguishing error types, you can display user-friendly messages or handle specific issues differently (e.g., retry on .serverError).&lt;/li&gt;
&lt;li&gt;Swift-native: Conforms to Swift’s Error protocol and integrates seamlessly with try/catch.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;enum NetworkError: Error {
    case invalidURL
    case decodingError
    case serverError(statusCode: Int)
    case unknown(Error)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;enum HTTPMethod&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;enum HTTPMethod: String {
    case GET, POST, PUT, DELETE
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Clarity: This enum defines common HTTP methods as strongly-typed cases instead of using raw strings like &quot;GET&quot; or &quot;POST&quot; throughout your code.&lt;/li&gt;
&lt;li&gt;Safety: Prevents typos and allows the compiler to catch invalid method usage.&lt;/li&gt;
&lt;li&gt;Scalability: Easy to extend by adding methods like .PATCH or .HEAD later.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;protocol APIRequest&lt;/strong&gt; &lt;/p&gt;
&lt;pre&gt;&lt;code&gt;protocol APIRequest {
    associatedtype Response: Decodable
    var endpoint: Endpoint { get }
}

extension APIRequest {
    var url: URL? {
        var components = URLComponents()
        components.scheme = &quot;https&quot;
        components.host = &quot;jsonplaceholder.typicode.com&quot;
        components.path = endpoint.path
        return components.url
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;struct GetUserRequest: APIRequest {
    typealias Response = [User]
    let endpoint: Endpoint = .users
}

struct GetPostsRequest: APIRequest {
    typealias Response = [Post]
    let endpoint: Endpoint = .posts
}

struct GetCommentsRequest: APIRequest {
    typealias Response = [Comment]
    let endpoint: Endpoint = .users
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Generic Request Definition: This protocol defines the structure for any type of API request.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;associatedtype Response&lt;/code&gt;: This allows each request to specify the exact data type it expects to receive. For example, one request might return [User], another might return [Post].&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Decodable&lt;/code&gt; constraint: Ensures the response can be parsed from JSON using Swift’s Decodable system.&lt;/li&gt;
&lt;li&gt;Extensibility: By conforming to this protocol, you can add any number of request types (GetPostsRequest, GetCommentsRequest, etc.) without modifying the networking logic.&lt;/li&gt;
&lt;li&gt;Extension with default URL construction: Instead of requiring every request to build its own URL, we use a protocol extension to provide a shared implementation.&lt;/li&gt;
&lt;li&gt;Encapsulation of base URL logic: Centralizes the host and scheme configuration (e.g. &quot;&lt;a rel=&quot;nofollow&quot; href=&quot;https://jsonplaceholder.typicode.com&quot;&gt;https://jsonplaceholder.typicode.com&lt;/a&gt;&quot;), keeping the logic DRY (Don't Repeat Yourself).&lt;/li&gt;
&lt;li&gt;Reusability: Any request that conforms to APIRequest gets this default URL computation for free.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;func send&lt;/strong&gt; &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;This is a generic function that works with any type conforming to APIRequest, as long as it defines what type of response it expects.&lt;/li&gt;
&lt;li&gt;Using generics allows the networking client to remain decoupled from specific model types like User, Post, or Comment.&lt;/li&gt;
&lt;li&gt;This makes the network layer reusable and extensible: just create new request types for different endpoints and the same send function will work with them.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;async&lt;/code&gt;: Indicates this function performs asynchronous work.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;throws&lt;/code&gt;: Indicates this function can fail and will throw errors you need to handle.&lt;/li&gt;
&lt;li&gt;Returns: The decoded result of the API call, e.g., [User] or [Post].&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;final class NetworkClient {
    func send&amp;lt;T: APIRequest&amp;gt;(_ request: T) async throws -&amp;gt; T.Response {
        guard let url = request.url else {
            throw NetworkError.invalidURL
        }
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = request.endpoint.method.rawValue

        let (data, response): (Data, URLResponse)
        do {
            (data, response) = try await URLSession.shared.data(for: urlRequest)
        } catch {
            throw NetworkError.unknown(error)
        }

        guard let httpResponse = response as? HTTPURLResponse else {
            throw NetworkError.unknown(NSError(domain: &quot;Invalid response&quot;, code: 0))
        }

        guard (200..&amp;lt;300).contains(httpResponse.statusCode) else {
            throw NetworkError.serverError(statusCode: httpResponse.statusCode)
        }

        do {
            return try JSONDecoder().decode(T.Response.self, from: data)
        } catch {
            throw NetworkError.decodingError
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the above snippet, note the:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(data, response) = try await URLSession.shared.data(for: urlRequest)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;The call is made asynchronously (as promised by async) thanks to await.&lt;/li&gt;
&lt;li&gt;It suspends execution until the response is received, meaning the next line in the code won't run until await is done.&lt;/li&gt;
&lt;li&gt;If any network error occurs (e.g. no connection, timeout), it throws — and we catch it below. Wrapping the error into our custom .unknown error case, preserving the original error for context.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; } catch {
     throw NetworkError.unknown(error)
 }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, we validate the response:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;guard let httpResponse = response as? HTTPURLResponse else {
    throw NetworkError.unknown(NSError(domain: &quot;Invalid response&quot;, code: 0))
}

guard (200..&amp;lt;300).contains(httpResponse.statusCode) else {
    throw NetworkError.serverError(statusCode: httpResponse.statusCode)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;First checks if the response is an HTTP response.&lt;/li&gt;
&lt;li&gt;Then ensures the HTTP status code is in the success range (200–299).&lt;/li&gt;
&lt;li&gt;If not, throws a .serverError(statusCode:), making it easier to handle in UI or logs later.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;After validation, we are trying to decode the response:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;return try JSONDecoder().decode(T.Response.self, from: data)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Tries to decode the received Data into the expected response type (T.Response) using Swift’s &lt;code&gt;Decodable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If decoding fails, it’s caught and rethrown as our custom &lt;code&gt;.decodingError&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;code:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;} catch {
    throw NetworkError.decodingError
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-3-integrating-with-swiftui&quot;&gt;Step 3: Integrating with SwiftUI&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;ViewModel&lt;/strong&gt; &lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import SwiftUI

@MainActor
class UsersViewModel: ObservableObject {
    @Published var users: [User] = []
    @Published var isLoading = false
    @Published var errorMessage: String?

    private let client = NetworkClient()

    func loadUsers() async {
        isLoading = true
        errorMessage = nil

        do {
            let users = try await client.send(GetUsersRequest())
            self.users = users
        } catch {
            errorMessage = &quot;Failed to load users: \(error.localizedDescription)&quot;
        }

        isLoading = false
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;View&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import SwiftUI

struct UsersView: View {
    @StateObject private var viewModel = UsersViewModel()

    var body: some View {
        NavigationView {
            Group {
                if viewModel.isLoading {
                    ProgressView(&quot;Loading...&quot;)
                } else if let error = viewModel.errorMessage {
                    Text(error).foregroundColor(.red)
                } else {
                    List(viewModel.users) { user in
                        VStack(alignment: .leading) {
                            Text(user.name).bold()
                            Text(user.email).font(.subheadline).foregroundColor(.gray)
                        }
                    }
                }
            }
            .navigationTitle(&quot;Users&quot;)
            .task {
                await viewModel.loadUsers()
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And we are done!&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=6410350353929772202&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;You can checkout the full project on &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Pro-Mobile-Dev/ios-modern-network-layer&quot;&gt;GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can read part II of this subject - dealing with auth tokens here:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/8902/modern-networking-in-ios-with-urlsession-and-async-await-part-2&quot;&gt;https://coderlegion.com/8902/modern-networking-in-ios-with-urlsession-and-async-await-part-2&lt;/a&gt;&lt;/p&gt;
</description>
<category>Networking</category>
<guid isPermaLink="true">https://coderlegion.com/8489/modern-networking-in-ios-with-urlsession-and-async-await-a-practical-guide</guid>
<pubDate>Mon, 15 Dec 2025 19:28:11 +0000</pubDate>
</item>
<item>
<title>Learn how to integrate Firebase AI Logic into your iOS app.</title>
<link>https://coderlegion.com/3286/learn-how-to-integrate-firebase-ai-logic-into-your-ios-app</link>
<description>&lt;h2 id=&quot;implementing-firebase-ai-logic-in-your-ios-app-with-swift&quot;&gt;Implementing Firebase AI Logic in Your iOS App with Swift&lt;/h2&gt;
&lt;p&gt;Google Firebase has introduced &lt;strong&gt;AI Logic&lt;/strong&gt;, a new way to build intelligent, dynamic behavior into your mobile apps using natural language prompts and structured logic—without needing to build or fine-tune your own models.&lt;/p&gt;
&lt;p&gt;In this guide, we’ll walk through how to integrate Firebase AI Logic into your &lt;strong&gt;iOS app using Swift&lt;/strong&gt;, from setup to implementation.&lt;/p&gt;
&lt;h3 id=&quot;what-is-firebase-ai-logic-&quot;&gt;What Is Firebase AI Logic?&lt;/h3&gt;
&lt;p&gt;Firebase AI Logic is a managed service that allows you to define, test, and run AI-powered logic blocks using a mix of prompts and structured configuration. Think of it as serverless AI functions that take inputs, apply logic, and return structured outputs.&lt;/p&gt;
&lt;p&gt;With AI Logic, you can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Create logic using natural language prompts.&lt;/li&gt;
&lt;li&gt;Call this logic from your iOS app using the &lt;strong&gt;Firebase AI SDK&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Use AI-generated decisions to personalize your UI, make recommendations, and more.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;requirements&quot;&gt;Requirements&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Xcode 16.2+&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;iOS 15+&lt;/strong&gt; or &lt;strong&gt;macOS 12+&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Firebase SDK v11.13.0+&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;A Firebase-connected app&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;firebase-console-setup&quot;&gt;Firebase Console Setup&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Go to your project in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://console.firebase.google.com/&quot;&gt;Firebase Console&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;AI&lt;/strong&gt;, select &lt;strong&gt;AI Logic&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Tap &lt;strong&gt;Start&lt;/strong&gt;, then under &lt;strong&gt;Gemini Developer API&lt;/strong&gt;, tap &lt;strong&gt;Start using this API&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In the dialog that appears, tap &lt;strong&gt;Enable&lt;/strong&gt;. Wait until the API is enabled.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Continue&lt;/strong&gt; to complete activation.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;enable-app-check-recommended-&quot;&gt;Enable App Check (Recommended)&lt;/h4&gt;
&lt;p&gt;To restrict API calls to authorized devices:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;In the AI Logic panel, click &lt;strong&gt;Register on App Check&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In the App Check dashboard, click &lt;strong&gt;Start&lt;/strong&gt;, then &lt;strong&gt;Register&lt;/strong&gt; for the app you want to secure.&lt;/li&gt;
&lt;li&gt;If your app supports only iOS 14+, you can register &lt;strong&gt;App Attest&lt;/strong&gt; only. Otherwise, also register &lt;strong&gt;Device Check&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Complete all required fields and finish registration.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Next:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;In the AI Logic dashboard, go to the &lt;strong&gt;APIs&lt;/strong&gt; section.&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Firebase AI Logic APIs&lt;/strong&gt;, then click &lt;strong&gt;Apply&lt;/strong&gt; to enforce App Check.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Enabling enforcement can take up to &lt;strong&gt;30 minutes&lt;/strong&gt; to propagate.&lt;/p&gt;
&lt;h3 id=&quot;enable-app-transport-security-exception&quot;&gt;Enable App Transport Security Exception&lt;/h3&gt;
&lt;p&gt;In my case, I also needed to enable &lt;strong&gt;App Transport Security Exception&lt;/strong&gt;.&lt;br&gt;
To do this, go to &lt;strong&gt;Project → Signing &amp;amp; Capabilities → Add Capability → App Transport Security Exception&lt;/strong&gt;.&lt;br&gt;
Inside the capability, tap the &lt;strong&gt;+&lt;/strong&gt; button and add &lt;strong&gt;googleapis.com&lt;/strong&gt;.&lt;/p&gt;
&lt;h3 id=&quot;code-implementation&quot;&gt;Code Implementation&lt;/h3&gt;
&lt;h4 id=&quot;app-check&quot;&gt;App Check&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Add the &lt;strong&gt;Firebase App Check&lt;/strong&gt; library to your project.&lt;/li&gt;
&lt;li&gt;Create a file named &lt;code&gt;AppCheckManager.swift&lt;/code&gt;:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Firebase
import FirebaseAppCheck
 
final class AppCheckManager: NSObject, AppCheckProviderFactory {
 
public static let shared = AppCheckManager()
 
func createProvider(with app: FirebaseApp) -&amp;gt; AppCheckProvider? {
return AppAttestProvider(app: app)
}
 
public func setProviderFactory() {
#if DEBUG
 AppCheck.setAppCheckProviderFactory(AppCheckDebugProviderFactory())
#else
AppCheck.setAppCheckProviderFactory(self)
#endif
}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In your &lt;code&gt;AppDelegate&lt;/code&gt; or main app file, before &lt;code&gt;FirebaseApp.configure()&lt;/code&gt;, add:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;AppCheckManager.shared.setProviderFactory()
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;In your &lt;strong&gt;Signing &amp;amp; Capabilities&lt;/strong&gt;, add the &lt;strong&gt;App Attest&lt;/strong&gt; entitlement.&lt;/li&gt;
&lt;li&gt;In the generated &lt;code&gt;.entitlements&lt;/code&gt; file, change the App Attest mode from &lt;code&gt;development&lt;/code&gt; to &lt;code&gt;production&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;To enable Firebase debug logging:&lt;ul&gt;
&lt;li&gt;Go to &lt;strong&gt;Product &amp;gt; Scheme &amp;gt; Edit Scheme…&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Run &amp;gt; Arguments&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Add a new argument: &lt;code&gt;-FIRDebugEnabled&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Run the app. You should see a log like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;&amp;lt;Warning&amp;gt; [AppCheckCore][I-GAC004001] App Check debug token: '1E04B27F-15CD-46FA-862B-ED8E67841C9D'.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Copy this token, go to &lt;strong&gt;App Check &amp;gt; Apps&lt;/strong&gt; in the Firebase Console, click the three dots next to your iOS app, choose &lt;strong&gt;Manage debug tokens&lt;/strong&gt;, then add and save the token.&lt;/p&gt;
&lt;p&gt;You're done setting up App Check.&lt;/p&gt;
&lt;h3 id=&quot;using-firebase-ai-logic&quot;&gt;Using Firebase AI Logic&lt;/h3&gt;
&lt;p&gt;Add the &lt;code&gt;FirebaseAI&lt;/code&gt; package:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Go to &lt;strong&gt;General &amp;gt; Frameworks, Libraries, and Embedded Content&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Tap the &lt;strong&gt;+&lt;/strong&gt; button and add &lt;code&gt;FirebaseAI&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;simple-response&quot;&gt;Simple Response&lt;/h4&gt;
&lt;p&gt;To generate a text response from a prompt:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import FirebaseAI
 
func callAI() async {
do {
let prompt = &quot;Your prompt here...&quot;
let ai = FirebaseAI.firebaseAI(backend: .googleAI())
let model = ai.generativeModel(modelName: &quot;gemini-2.0-flash&quot;)
let response = try await model.generateContent(prompt)
 
if let answer = response.text {
print(&quot;FirebaseAI response:&quot;, answer)
}
} catch {
print(&quot;FirebaseAI error:&quot;, error)
}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;streamed-response&quot;&gt;Streamed Response&lt;/h4&gt;
&lt;p&gt;For a more responsive UI, stream partial results as they’re generated:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;func aiStrem() async {
do {
let prompt = &quot;Your prompt here...&quot;
let ai = FirebaseAI.firebaseAI(backend: .googleAI())
let model = ai.generativeModel(modelName: &quot;gemini-2.0-flash&quot;)
let response = try model.generateContentStream(prompt)
 
for try await chunk in response {
if let text = chunk.text {
print(&quot;FirebaseAI text:&quot;, text)
}
}
} catch {
print(&quot;FirebaseAI error:&quot;, error)
}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;troubleshooting&quot;&gt;Troubleshooting&lt;/h3&gt;
&lt;h4 id=&quot;http-403-errors&quot;&gt;HTTP 403 Errors&lt;/h4&gt;
&lt;p&gt;If you see a 403 error:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to the &lt;a rel=&quot;nofollow&quot; href=&quot;https://console.cloud.google.com/&quot;&gt;Google Cloud Console&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Select your Firebase project&lt;/li&gt;
&lt;li&gt;Navigate to &lt;strong&gt;APIs &amp;amp; Services &amp;gt; Credentials&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Find your iOS key, click it&lt;/li&gt;
&lt;li&gt;Ensure &lt;strong&gt;Firebase AI Logic API&lt;/strong&gt; is enabled under &lt;em&gt;API Restrictions&lt;/em&gt;. If not:&lt;ul&gt;
&lt;li&gt;Select it&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Wait up to 30 minutes before retrying&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If the error persists:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Temporarily switch to &lt;strong&gt;“Don’t restrict key”&lt;/strong&gt; mode to test.&lt;/li&gt;
&lt;li&gt;You may also try &lt;strong&gt;disabling and re-enabling App Check&lt;/strong&gt; for Firebase AI Logic.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Firebase AI Logic allows iOS developers to integrate intelligent logic blocks into apps without the need to manage ML models or infrastructure. With App Check, you can keep API access secure, and with just a few lines of Swift, you can call or stream AI responses to build truly dynamic user experiences.&lt;/p&gt;
&lt;p&gt;Thanks for reading! If you have questions or want a complete sample project, feel free to reach out or leave a comment.&lt;/p&gt;
</description>
<category>Getting Started</category>
<guid isPermaLink="true">https://coderlegion.com/3286/learn-how-to-integrate-firebase-ai-logic-into-your-ios-app</guid>
<pubDate>Sun, 08 Jun 2025 20:36:07 +0000</pubDate>
</item>
</channel>
</rss>