<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged swiftui</title>
<link>https://coderlegion.com/tag/swiftui</link>
<description>Powered by Question2Answer</description>
<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&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>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-1&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-2&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>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-3&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>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-4&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>Keeping a Web-Backed SSH Terminal Alive Across SwiftUI Navigation</title>
<link>https://coderlegion.com/24308/keeping-a-web-backed-ssh-terminal-alive-across-swiftui-navigation</link>
<description>&lt;p&gt;A desktop SSH client can keep the remote connection alive and still give the user the impression that the terminal was reset. The failure often sits one layer above SSH: the UI destroys and rebuilds the terminal surface when the user switches to another section.&lt;/p&gt;
&lt;p&gt;That distinction matters when a macOS app embeds xterm.js in &lt;code&gt;WKWebView&lt;/code&gt;. The SSH process, terminal model, WebKit process, DOM, scrollback, focus chain, and visible geometry all have different lifetimes. Treating them as one SwiftUI view makes normal navigation surprisingly destructive.&lt;/p&gt;
&lt;p&gt;This article describes the design rules we ended up using in a native macOS SSH client.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://nexusshell.app/assets/img/screens/terminal.png&quot; alt=&quot;A native macOS SSH terminal with multiple sessions and a visible server workspace&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;why-rebuilding-is-not-a-harmless-refresh&quot;&gt;Why rebuilding is not a harmless refresh&lt;/h3&gt;
&lt;p&gt;A straightforward SwiftUI implementation conditionally creates the selected section:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;if selectedSection == .terminal {
    TerminalSurface()
} else {
    FilesView()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the condition changes, SwiftUI is free to dismantle the terminal subtree. If that subtree owns a &lt;code&gt;WKWebView&lt;/code&gt;, the app can lose much more than a view object:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the WebContent process and xterm.js DOM may be destroyed;&lt;/li&gt;
&lt;li&gt;scrollback stored only in the browser process disappears;&lt;/li&gt;
&lt;li&gt;alternate-screen applications such as &lt;code&gt;vim&lt;/code&gt;, &lt;code&gt;nano&lt;/code&gt;, or &lt;code&gt;top&lt;/code&gt; cannot be reconstructed from plain text;&lt;/li&gt;
&lt;li&gt;restoring a bounded output snapshot may omit older history;&lt;/li&gt;
&lt;li&gt;WebKit initialization and snapshot replay add visible latency;&lt;/li&gt;
&lt;li&gt;focus and resize events can arrive in a different order after reconstruction.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keeping the SSH handle alive does not solve any of these UI-state losses. The remote PTY may still be healthy while the local terminal surface has forgotten how to represent it.&lt;/p&gt;
&lt;h3 id=&quot;separate-four-lifetimes&quot;&gt;Separate four lifetimes&lt;/h3&gt;
&lt;p&gt;The useful mental model is to separate at least four things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Logical session&lt;/strong&gt;: the tab, server identity, name, working directory, and user-facing state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transport session&lt;/strong&gt;: the SSH handle and remote PTY that carry input and output.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Terminal renderer&lt;/strong&gt;: the &lt;code&gt;WKWebView&lt;/code&gt;, xterm.js DOM, parser state, and scrollback.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Visible attachment&lt;/strong&gt;: whether that renderer currently participates in the AppKit view hierarchy and responder chain.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Navigation should normally change only the fourth lifetime. Closing a tab can end all four. A network failure may replace the transport while retaining the logical tab and renderer. Conflating those transitions is where most of the surprising behavior begins.&lt;/p&gt;
&lt;h3 id=&quot;mount-once-then-deactivate&quot;&gt;Mount once, then deactivate&lt;/h3&gt;
&lt;p&gt;After the terminal section is opened for the first time, keep its SwiftUI subtree mounted. Other sections can still be created normally, while the terminal surface changes visibility and interaction state:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;if terminalHasLoaded || selectedSection == .terminal {
    TerminalSurface(isActive: selectedSection == .terminal)
        .opacity(selectedSection == .terminal ? 1 : 0)
        .allowsHitTesting(selectedSection == .terminal)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This preserves the representable and its coordinator instead of asking SwiftUI to rebuild them on every sidebar click. The &lt;code&gt;isActive&lt;/code&gt; value also gives terminal-specific shortcuts, toolbars, and event monitors an explicit lifecycle. Hidden terminal controls should not continue handling keyboard shortcuts while the user is working in Files or Settings.&lt;/p&gt;
&lt;p&gt;Opacity alone, however, is not sufficient for a WebKit-backed terminal.&lt;/p&gt;
&lt;h3 id=&quot;preserve-the-wkwebview-object-not-necessarily-its-attachment&quot;&gt;Preserve the WKWebView object, not necessarily its attachment&lt;/h3&gt;
&lt;p&gt;On macOS, an embedded &lt;code&gt;WKWebView&lt;/code&gt; participates in AppKit focus, drag registration, and window behavior. Keeping it attached but transparent can leave side effects in sections that should have nothing to do with the terminal.&lt;/p&gt;
&lt;p&gt;A more reliable arrangement is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;let &lt;code&gt;NSViewRepresentable&lt;/code&gt; return a lightweight container;&lt;/li&gt;
&lt;li&gt;let its coordinator hold a strong reference to the &lt;code&gt;WKWebView&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;attach the web view to the container only while the terminal section is active;&lt;/li&gt;
&lt;li&gt;remove it from the hierarchy while inactive, without releasing the object;&lt;/li&gt;
&lt;li&gt;attach the same object again when the user returns.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The WebKit object, WebContent process, xterm.js DOM, parser state, and scrollback stay alive even while the view is temporarily detached. The rest of the app no longer has an invisible WebKit view interfering with hit testing or drag sessions.&lt;/p&gt;
&lt;p&gt;This is different from serializing terminal text and creating a new renderer. It preserves the renderer itself.&lt;/p&gt;
&lt;h3 id=&quot;hand-focus-back-before-detaching&quot;&gt;Hand focus back before detaching&lt;/h3&gt;
&lt;p&gt;Focus is a correctness issue for terminals, not just polish. If the window's first responder remains inside the web view during a section switch, later keystrokes can be delivered somewhere the user cannot see. In the worst case, a stray character is interpreted by the remote shell.&lt;/p&gt;
&lt;p&gt;Before detaching an active terminal web view:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check whether the window's first responder is the web view or one of its descendants.&lt;/li&gt;
&lt;li&gt;Explicitly clear or move the first responder.&lt;/li&gt;
&lt;li&gt;Remove the web view from its container.&lt;/li&gt;
&lt;li&gt;Disable terminal-only keyboard monitors and shortcut controls.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;When reattaching, restore focus deliberately after the container is visible. Avoid assuming that SwiftUI's appearance callback and AppKit's responder-chain update happen in the same frame.&lt;/p&gt;
&lt;h3 id=&quot;freeze-hidden-geometry&quot;&gt;Freeze hidden geometry&lt;/h3&gt;
&lt;p&gt;A hidden but mounted view can still receive geometry changes. If its width follows every animation in the visible section, xterm.js may repeatedly run &lt;code&gt;fit()&lt;/code&gt;, and each result may resize the remote PTY.&lt;/p&gt;
&lt;p&gt;That creates a subtle class of bugs: opening a file-transfer panel or animating another page changes the dimensions of a terminal the user is not looking at. Full-screen terminal programs then redraw against &quot;ghost&quot; sizes.&lt;/p&gt;
&lt;p&gt;Record the terminal's last active width and freeze the inactive container at that width. On reattachment:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;align the web view to the visible container once;&lt;/li&gt;
&lt;li&gt;let the renderer's resize observer compute the new rows and columns;&lt;/li&gt;
&lt;li&gt;send one meaningful PTY resize instead of a frame-by-frame storm.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The same rule applies to side panels: reserve their width only in the section that owns them, not at a shared ancestor that also sizes the hidden terminal.&lt;/p&gt;
&lt;h3 id=&quot;keep-output-flowing-without-flooding-the-ui&quot;&gt;Keep output flowing without flooding the UI&lt;/h3&gt;
&lt;p&gt;Detaching the renderer should not pause the SSH transport. Output can continue updating the logical session and renderer while the user visits another section.&lt;/p&gt;
&lt;p&gt;High-volume PTY output needs its own boundary. Read callbacks can arrive hundreds of times per second, while sending every chunk through the main thread and WebKit IPC creates unnecessary contention. A practical policy is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;deliver sparse output immediately so typing echo feels instant;&lt;/li&gt;
&lt;li&gt;merge continuous bursts into roughly one flush per display frame;&lt;/li&gt;
&lt;li&gt;update the bounded recovery buffer before notifying the renderer;&lt;/li&gt;
&lt;li&gt;keep session logs on a separate path from visual replay.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The recovery buffer remains useful if WebKit really crashes, but it is no longer the normal navigation mechanism.&lt;/p&gt;
&lt;h3 id=&quot;test-transitions-not-just-steady-states&quot;&gt;Test transitions, not just steady states&lt;/h3&gt;
&lt;p&gt;The most valuable tests are sequences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;start &lt;code&gt;top&lt;/code&gt;, switch to Files, resize the window, and return;&lt;/li&gt;
&lt;li&gt;scroll far back, visit Settings, and confirm the same scrollback remains;&lt;/li&gt;
&lt;li&gt;focus the terminal, switch sections, type immediately, and verify nothing reaches SSH;&lt;/li&gt;
&lt;li&gt;open and close a side panel while the terminal is hidden;&lt;/li&gt;
&lt;li&gt;disconnect while hidden, then return and exercise the reconnect path;&lt;/li&gt;
&lt;li&gt;close a terminal tab while its web view is detached and confirm observers and script handlers are removed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These tests cross SwiftUI, AppKit, WebKit, and SSH boundaries. A unit test around one layer will not reveal the ordering failures between them.&lt;/p&gt;
&lt;h3 id=&quot;the-broader-rule&quot;&gt;The broader rule&lt;/h3&gt;
&lt;p&gt;View visibility and resource lifetime are not the same thing. A terminal is a long-lived interactive resource with a UI surface, not a disposable page. Once the logical session, transport, renderer, and visible attachment have separate ownership, navigation becomes predictable: switching sections changes what the user sees without quietly rebuilding the tool they were using.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Developer disclosure:&lt;/strong&gt; I work on &lt;a rel=&quot;nofollow&quot; href=&quot;https://nexusshell.app/?utm_source=coderlegion&amp;amp;utm_medium=technical_article&amp;amp;utm_campaign=terminal_view_lifetime&quot;&gt;Nexus Shell&lt;/a&gt;, a native macOS SSH workspace. This article is based on implementation and debugging lessons from that work, not a benchmark against other clients.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Editorial note:&lt;/strong&gt; This draft was prepared with AI assistance and reviewed against the current SwiftUI, AppKit, WebKit, and SSH implementation before publication.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24308/keeping-a-web-backed-ssh-terminal-alive-across-swiftui-navigation</guid>
<pubDate>Sun, 09 Aug 2026 04:53:28 +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>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>Your App Goes Offline. Does It Panic, Lie, or Handle It Gracefully?</title>
<link>https://coderlegion.com/12022/your-app-goes-offline-does-it-panic-lie-or-handle-it-gracefully</link>
<description>&lt;p&gt;Most developers think about offline mode last - when the app is already done, designs are signed off, and the PM is breathing down your neck about the deadline. The result? Users see a white screen, a frozen spinner, or worse - silently lost data. This article is about building honest UX for no-network states: from the psychology of anxiety to exponential backoff code, from visual error language to conflict resolution strategies. All of it applies to any mobile or web app that operates under unstable connectivity - which is most of them.&lt;/p&gt;
&lt;h3 id=&quot;the-psychology-of-offline-states&quot;&gt;The Psychology of Offline States&lt;/h3&gt;
&lt;p&gt;There's a pattern I've seen in usability tests over and over: a user taps &quot;Send,&quot; nothing happens, they tap again - and again. Ten seconds later they either close the app or start getting angry. That's not a network problem. That's an uncertainty problem.&lt;/p&gt;
&lt;p&gt;Uncertainty breeds anxiety. Anxiety breeds avoidance. The user doesn't know whether their message went through, whether the document saved, whether the transaction completed. The brain interprets the system's silence as a threat: &quot;Something went wrong, and I have no idea what.&quot; That's exactly why well-designed offline UX isn't a technical problem - it's an anxiety management problem.&lt;/p&gt;
&lt;p&gt;There are three states worth distinguishing: the system is working normally, the system is slow or unstable, the system is fully offline. Each one needs its own visual response. Conflate them and you confuse the user even further. If you show the same indicator for a slow connection as for no connection at all, the user has no idea whether to keep waiting or give up.&lt;/p&gt;
&lt;p&gt;One more thing that often gets overlooked: people tolerate waiting much better when they understand why and have a rough sense of how long. This is well-established in queue research and loading UX. Offline is no different. &quot;No internet&quot; is already better than a blank screen. &quot;No internet - your data is saved locally and will sync when the connection is restored&quot; - that's trust.&lt;/p&gt;
&lt;h3 id=&quot;visual-language-for-offline-states&quot;&gt;Visual Language for Offline States&lt;/h3&gt;
&lt;p&gt;Rule one: don't hide things. I've seen interfaces that simply disabled buttons when there was no network and offered zero explanation. The user stares at a grayed-out button and thinks: is this a feature? A bug? Why now?&lt;/p&gt;
&lt;p&gt;The offline state needs to be explicit. A banner at the top of the screen in a neutral color - not red; red signals an error, not an environmental condition - a status bar indicator, a subtle UI change - all of these work. The core rule: UI elements that are unavailable because of missing connectivity should be visible but clearly disabled, with an explanation. Not hidden - disabled, with a tooltip or inline message.&lt;/p&gt;
&lt;p&gt;A solid practice for mobile apps is showing connection state in the toolbar or navigation bar. On iOS you can observe &lt;code&gt;NWPathMonitor&lt;/code&gt; and update the UI reactively:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;import Network

final class NetworkMonitor: ObservableObject {
    private let monitor = NWPathMonitor()
    private let queue = DispatchQueue(label: &quot;NetworkMonitor&quot;)

    @Published var isConnected: Bool = true

    init() {
        monitor.pathUpdateHandler = { [weak self] path in
            DispatchQueue.main.async {
                self?.isConnected = path.status == .satisfied
            }
        }
        monitor.start(queue: queue)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In SwiftUI this plugs in trivially via &lt;code&gt;@EnvironmentObject&lt;/code&gt;, letting any screen react to connectivity changes without tight coupling.&lt;/p&gt;
&lt;p&gt;On the iconography front: I prefer the SF Symbol &lt;code&gt;wifi.slash&lt;/code&gt; on iOS - users read it instantly. On the web, the same idea applies with SVG icons, but don't overdo animation. A pulsing offline icon gets annoying within thirty seconds. A static one just informs.&lt;/p&gt;
&lt;h3 id=&quot;queueing-actions-for-later-sync&quot;&gt;Queueing Actions for Later Sync&lt;/h3&gt;
&lt;p&gt;This is where the real engineering begins. When a user does something offline - likes a post, creates a task, edits a document - that action needs to be stored somewhere and executed later. A simple in-memory array won't survive an app restart. You need a persistent store: Core Data, SQLite, or at minimum &lt;code&gt;UserDefaults&lt;/code&gt; for simple cases.&lt;/p&gt;
&lt;p&gt;But persistence is only half the problem. The other half is idempotency.&lt;/p&gt;
&lt;p&gt;Idempotency means that executing the same operation multiple times produces the same result. This is critical for a sync queue, because we never actually know whether the request reached the server before the connection dropped. If an operation isn't idempotent, retrying it might create duplicates or corrupt data.&lt;/p&gt;
&lt;p&gt;In practice, this is solved two ways. First - assign a client-generated UUID to each operation and send it in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Idempotency-Key&quot;&gt;&lt;code&gt;Idempotency-Key&lt;/code&gt;&lt;/a&gt; header. The server stores this key and on a duplicate request returns the same response without re-executing the operation. Second - design operations as &lt;code&gt;PATCH&lt;/code&gt; with specific fields rather than &lt;code&gt;POST&lt;/code&gt; with a full object, or use event sourcing on the server side.&lt;/p&gt;
&lt;p&gt;Conflicts are their own story. If a user edited a document offline while another user changed the same document on the server, you have a conflict. The resolution strategies break down like this.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Last Write Wins&lt;/strong&gt; is the simplest approach and often the wrong one. One side's changes get thrown away. Fine for non-critical data like settings, not fine for much else.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Merge&lt;/strong&gt; - for structured data like text or lists, you can apply a three-way merge: base, client version, server version. This is exactly how Git works. CRDT (Conflict-free Replicated Data Types) is the more powerful variant, used by Figma and Linear for collaborative editing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ask the User&lt;/strong&gt; - when automatic merging isn't possible, show both versions and let the user decide. Bad if it happens constantly, but sometimes it's the only honest option.&lt;/p&gt;
&lt;p&gt;I try to design the queue as a state machine: each action has a status of &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;syncing&lt;/code&gt;, &lt;code&gt;synced&lt;/code&gt;, or &lt;code&gt;failed&lt;/code&gt;. This simplifies both the UI state display and the retry logic considerably.&lt;/p&gt;
&lt;h3 id=&quot;showing-pending-vs-failed---different-states-deserve-different-treatment&quot;&gt;Showing &quot;Pending&quot; vs &quot;Failed&quot; - Different States Deserve Different Treatment&lt;/h3&gt;
&lt;p&gt;This distinction gets ignored surprisingly often. &quot;Waiting to send&quot; and &quot;failed to send&quot; are fundamentally different situations from the user's perspective. The first requires no action. The second requires a decision.&lt;/p&gt;
&lt;p&gt;For &lt;code&gt;pending&lt;/code&gt;, a subtle indicator works fine: a gray checkmark, a clock icon, a small badge. The user needs to understand that their action was recorded but hasn't synced yet. No need to shout about it - a quiet signal is enough.&lt;/p&gt;
&lt;p&gt;For &lt;code&gt;failed&lt;/code&gt;, you need explicit feedback. A red icon, an inline error message, and - critically - the ability to cancel. If a user sent a message, it didn't go through, and they see a &lt;code&gt;failed&lt;/code&gt; status, they need a &quot;Cancel&quot; or &quot;Try Again&quot; button right there. Without it they're stuck: the action appears to have happened, but it hasn't.&lt;/p&gt;
&lt;p&gt;Undo for failed states works on the principle of &quot;soft deletion&quot;: the action stays in the queue but gets marked as cancelled. That's simpler than rolling back state. For chats and feeds - show failed messages in gray with an error icon and a retry button next to them. This is a pattern users already know from WhatsApp and Telegram. No need to reinvent it.&lt;/p&gt;
&lt;h3 id=&quot;auto-retry-with-exponential-backoff&quot;&gt;Auto-Retry with Exponential Backoff&lt;/h3&gt;
&lt;p&gt;The naive retry implementation is a loop with a fixed interval. The problem: if a thousand clients lose connectivity simultaneously - say, the office WiFi goes down - they all come back online and hammer the server in sync. That's a thundering herd, and it can take a backend down.&lt;/p&gt;
&lt;p&gt;Exponential backoff with jitter solves this. Each subsequent attempt waits twice as long as the previous one, and random jitter spreads the requests across time. Here's an implementation in Swift using async/await:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;enum RetryError: Error {
    case maxAttemptsReached
    case cancelled
}

func withExponentialBackoff&amp;lt;T&amp;gt;(
    maxAttempts: Int = 5,
    baseDelay: TimeInterval = 1.0,
    operation: @escaping () async throws -&amp;gt; T
) async throws -&amp;gt; T {
    var attempt = 0

    while attempt &amp;lt; maxAttempts {
        do {
            return try await operation()
        } catch {
            attempt += 1
            guard attempt &amp;lt; maxAttempts else {
                throw RetryError.maxAttemptsReached
            }

            let delay = baseDelay * pow(2.0, Double(attempt - 1))
            let jitter = TimeInterval.random(in: 0...delay * 0.3)
            try await Task.sleep(nanoseconds: UInt64((delay + jitter) * 1_000_000_000))
        }
    }

    throw RetryError.maxAttemptsReached
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Usage:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;let result = try await withExponentialBackoff {
    try await apiClient.syncPendingActions()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For the Combine stack this looks different, but the idea is the same - &lt;code&gt;.retry()&lt;/code&gt; in Combine doesn't support delays out of the box, so you need a custom operator or a &lt;code&gt;flatMap&lt;/code&gt; with &lt;code&gt;Deferred + Future&lt;/code&gt; and &lt;code&gt;DispatchQueue.asyncAfter&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;One important detail: retries need to stop when the user explicitly cancels an action, or when the app goes to background. Otherwise you're burning battery and burning data for nothing. I usually tie the retry lifecycle to the task's lifetime in the queue and cancel the &lt;code&gt;Task&lt;/code&gt; on controller or view model deinitialization.&lt;/p&gt;
&lt;p&gt;The maximum number of attempts is a product policy question. For critical transactions - payments, medical data - more attempts and an explicit notification when they're exhausted. For non-critical stuff - analytics, logs - fewer attempts and a silent fail.&lt;/p&gt;
&lt;h3 id=&quot;testing-offline-scenarios&quot;&gt;Testing Offline Scenarios&lt;/h3&gt;
&lt;p&gt;This is where most teams cut corners, and it shows in production. Testing offline isn't &quot;turn off WiFi and see what happens.&quot; It's a systematic check of every state transition.&lt;/p&gt;
&lt;p&gt;On iOS there's Network Link Conditioner - a tool in [Additional Tools for Xcode](&lt;a rel=&quot;nofollow&quot; href=&quot;https://developer.apple.com/download/all/?q=Additional&quot;&gt;https://developer.apple.com/download/all/?q=Additional&lt;/a&gt; Tools). It lets you simulate different network qualities: 100% packet loss, high latency, unstable connectivity. Installs as a system extension on Mac or directly on a device via Settings for real hardware. I use the &quot;100% Loss&quot; profile for full offline testing and &quot;Very Bad Network&quot; for degraded connectivity - these are genuinely different UX situations.&lt;/p&gt;
&lt;p&gt;On Android the equivalent is &lt;code&gt;adb shell&lt;/code&gt; with network state commands:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Disable mobile data
adb shell svc data disable

# Disable WiFi
adb shell svc wifi disable

# Turn them back on
adb shell svc data enable
adb shell svc wifi enable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For web - Chrome DevTools, Network tab, throttle to &quot;Offline&quot; or set up a custom profile.&lt;/p&gt;
&lt;p&gt;But beyond tooling, methodology matters. You need to test specific scenarios: what happens if connectivity drops mid-load? Mid-form submission? What if the user closed the app while a pending action was sitting in the queue, then reopened it? What if the auth token expired during the offline period?&lt;/p&gt;
&lt;p&gt;That last one is particularly nasty. The user comes back online, the queue starts syncing, gets a 401 Unauthorized, and now there's a conflict between &quot;need to retry&quot; and &quot;need to refresh the token first.&quot; This needs to be tested explicitly and handled explicitly in code. I'd genuinely love to hear how others approach this one in the comments.&lt;/p&gt;
&lt;p&gt;Unit tests for queue logic are worth writing against a mock network layer that lets you control success and failure. XCTest makes this straightforward through protocols and dependency injection - no URLSession directly in production code.&lt;/p&gt;
&lt;h3 id=&quot;when-to-warn-users-about-data-loss&quot;&gt;When to Warn Users About Data Loss&lt;/h3&gt;
&lt;p&gt;This is the most painful scenario, and it demands honesty. If data might be lost - warn the user. Before it happens, not after.&lt;/p&gt;
&lt;p&gt;Two typical cases. First: a user tries to exit an unsaved document while offline. The classic &quot;Save / Don't Save / Cancel&quot; dialog isn't enough here - you need to say explicitly: &quot;You have unsaved changes. They can't be synced right now and will only be stored locally.&quot; That's more honest.&lt;/p&gt;
&lt;p&gt;Second: when the sync queue has grown large enough that you can't guarantee all data will survive a cache clear or app update. This calls for a proactive notification - not necessarily a push, but at least an in-app banner on the next launch.&lt;/p&gt;
&lt;p&gt;A separate case is critical data that simply shouldn't be stored locally at all: medical records, financial transactions, legally binding signatures. My view is that for these, the right UX is to block the action entirely when there's no network, and explain clearly why. Yes, it's inconvenient. But a lost transaction or a missynced medical record is incomparably worse.&lt;/p&gt;
&lt;p&gt;The general principle I work by: users should have the ability to make an informed decision. If we can't guarantee data integrity - say so explicitly and give them a choice: wait for connectivity, save a local copy at their own risk, or cancel the action. Silence here isn't neutral. It's a lie.&lt;/p&gt;
&lt;p&gt;A well-implemented offline mode is one of those places where an app either earns long-term user trust or loses it for good. A lost message, a forgotten form, a silent interface on the subway - it all accumulates. The investment in offline UX pays off not in metrics but in reputation.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/12022/your-app-goes-offline-does-it-panic-lie-or-handle-it-gracefully</guid>
<pubDate>Mon, 23 Feb 2026 09:10:00 +0000</pubDate>
</item>
<item>
<title>SwiftUI vs UIKit in 2026: When to Use What</title>
<link>https://coderlegion.com/10405/swiftui-vs-uikit-in-2026-when-to-use-what</link>
<description>&lt;h3 id=&quot;the-myth-that-swiftui-will-replace-uikit&quot;&gt;The Myth That SwiftUI Will Replace UIKit&lt;/h3&gt;
&lt;p&gt;Let's get this straight right away: if someone tells you SwiftUI is the future and UIKit is legacy, don't believe them. Apple itself continues writing critical parts of its apps in UIKit. Open Settings on your iPhone - that's UIKit. Music? UIKit with sprinkles of SwiftUI. Photos? Same story.&lt;/p&gt;
&lt;p&gt;Why is this happening? Because SwiftUI, despite all its advantages, can't handle certain tasks as efficiently as UIKit. I've seen projects where teams tried to write everything in SwiftUI &quot;because it's trendy,&quot; then spent three months fighting navigation bugs and ended up rewriting in UIKit.&lt;/p&gt;
&lt;p&gt;Over the past few years, SwiftUI has grown significantly. The Navigation API got better with &lt;code&gt;NavigationStack&lt;/code&gt; and &lt;code&gt;NavigationSplitView&lt;/code&gt;. We got proper scroll position tracking support. But the fundamental limitations haven't gone anywhere. When you need pixel-perfect control over layout or complex animation with precise synchronization between elements, SwiftUI starts struggling.&lt;/p&gt;
&lt;p&gt;The truth is that in 2026, both frameworks have a right to exist. The question isn't what to choose, but when to use what. And most importantly - do you even need to pick just one?&lt;/p&gt;
&lt;h3 id=&quot;where-swiftui-really-wins&quot;&gt;Where SwiftUI Really Wins&lt;/h3&gt;
&lt;p&gt;SwiftUI shines when you need to quickly assemble UI from standard components. Declarative syntax isn't just pretty marketing words from Apple, it actually works.&lt;/p&gt;
&lt;p&gt;Compare:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;// UIKit
let label = UILabel()
label.text = &quot;Hello&quot;
label.textColor = .systemBlue
label.font = .systemFont(ofSize: 16, weight: .bold)
view.addSubview(label)
label.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
    label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
    label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
// SwiftUI
Text(&quot;Hello&quot;)
    .font(.system(size: 16, weight: .bold))
    .foregroundColor(.blue)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The difference is obvious. But it's not just about lines of code.&lt;/p&gt;
&lt;p&gt;Live previews in SwiftUI - that's what made me actually fall in love with this framework. When you're writing a registration form and see changes in real-time without rebuilding the project, that saves hours. In UIKit you either launch the simulator every time or struggle with Interface Builder, which periodically breaks storyboards.&lt;/p&gt;
&lt;p&gt;Yes, previews crash sometimes. Yes, after an Xcode update they might stop working for a couple days. But when everything works - it's incredibly convenient. You can set up multiple preview configurations with different data, screen sizes, dark/light mode simultaneously. Try doing the same in UIKit - you'll have to either write a playground or create separate view controllers for each case.&lt;/p&gt;
&lt;p&gt;Cross-platform development - another SwiftUI ace. The same code works on iOS, macOS, watchOS, and - importantly in 2026 - visionOS. Apple is clearly betting on Vision Pro, and if you want to port your app there without a complete rewrite, SwiftUI gives you a head start.&lt;/p&gt;
&lt;p&gt;Real-world example: I made a habit tracking app. The main screen was a list with checkboxes and statistics. Wrote the entire UI in SwiftUI in one day. Then the client asked for a macOS version. Added a couple &lt;code&gt;#if os(macOS)&lt;/code&gt; checks to adapt the menu, and done. If this were UIKit, I'd have to write the entire macOS interface from scratch using AppKit.&lt;/p&gt;
&lt;p&gt;Here's another point many people miss: state management in SwiftUI is built into the framework. &lt;code&gt;@State&lt;/code&gt;, &lt;code&gt;@Binding&lt;/code&gt;, &lt;code&gt;@ObservableObject&lt;/code&gt; - these aren't just property wrappers, they're a whole philosophy of data management. In UIKit you have to build architecture yourself: MVVM, VIPER, Coordinator pattern. In SwiftUI basic cases work out of the box.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct CounterView: View {
    @State private var count = 0
    
    var body: some View {
        VStack {
            Text(&quot;Count: \(count)&quot;)
            Button(&quot;Increment&quot;) {
                count += 1
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This code just works. No delegates, target-action, KVO, or notification center. Pressed button - state updated - UI redrawn. In UIKit the same task requires more code and explicit update management.&lt;/p&gt;
&lt;p&gt;Personally, I use SwiftUI for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;App settings (forms with toggles, sliders)&lt;/li&gt;
&lt;li&gt;Onboarding (five screens with animations between them)&lt;/li&gt;
&lt;li&gt;Simple forms (registration, login)&lt;/li&gt;
&lt;li&gt;Prototypes that need to be shown to clients in a couple days&lt;/li&gt;
&lt;li&gt;Dashboards with charts and statistics&lt;/li&gt;
&lt;li&gt;Any new features in existing apps where there are no strict performance requirements&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;when-uikit-is-still-irreplaceable&quot;&gt;When UIKit Is Still Irreplaceable&lt;/h3&gt;
&lt;p&gt;UIKit is older, which means more stable. There are no unexpected surprises that SwiftUI loves to deliver in every Xcode release.&lt;/p&gt;
&lt;p&gt;Take gestures. SwiftUI has &lt;code&gt;.gesture()&lt;/code&gt;, but try doing something more complex than a simple tap - for example, simultaneous pan and pinch recognition with different priorities. In UIKit it's &lt;code&gt;UIPanGestureRecognizer&lt;/code&gt; + &lt;code&gt;UIPinchGestureRecognizer&lt;/code&gt; with delegates, everything's clear and works predictably. In SwiftUI you'll have to build workarounds through &lt;code&gt;UIViewRepresentable&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I had a project with a custom image editor. Users could zoom photos, rotate them, apply filters in real-time. On SwiftUI this turned into a nightmare. Gesture modifiers conflicted with each other, priorities didn't always trigger correctly. Rewrote it in UIKit in two days, and everything worked like clockwork.&lt;/p&gt;
&lt;p&gt;Libraries. Almost the entire powerful iOS ecosystem is written for UIKit. Want to integrate a cool charting library like Charts (not Apple Charts, but third-party)? Most likely it's UIKit. Yes, you can wrap it through representable, but that's an additional layer of abstraction and potential lifecycle problems.&lt;/p&gt;
&lt;p&gt;Performance control. In UIKit you know exactly when layout happens, when a view renders, you can optimize &lt;code&gt;layoutSubviews&lt;/code&gt;, use &lt;code&gt;drawRect&lt;/code&gt; for custom drawing. In SwiftUI the framework decides when to redraw something, and sometimes does it too often.&lt;/p&gt;
&lt;p&gt;I encountered a situation where a complex SwiftUI view was redrawing 60 times per second for no visible reason. Profiling showed the problem was implicit state changes - somewhere deep in the hierarchy a &lt;code&gt;@State&lt;/code&gt; was updating, and the entire subtree was redrawing. Debugging that was quite the quest. In UIKit this doesn't happen: if a view updates, someone explicitly called &lt;code&gt;setNeedsDisplay&lt;/code&gt; or &lt;code&gt;setNeedsLayout&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Another point: memory management. In SwiftUI it's very easy to catch a retain cycle through capturing self in closures. All those &lt;code&gt;@StateObject&lt;/code&gt; and &lt;code&gt;@ObservedObject&lt;/code&gt; add magic that sometimes doesn't work as you expect. In UIKit everything's explicit: there's &lt;code&gt;weak&lt;/code&gt;, &lt;code&gt;unowned&lt;/code&gt;, and you know exactly who's holding whom.&lt;/p&gt;
&lt;p&gt;Collection views with custom layout. &lt;code&gt;UICollectionView&lt;/code&gt; with custom &lt;code&gt;UICollectionViewLayout&lt;/code&gt; is an incredibly powerful tool. You can do Pinterest-style masonry grids, circular layouts, complex transition animations between cells. SwiftUI has &lt;code&gt;LazyVGrid&lt;/code&gt; and &lt;code&gt;LazyHGrid&lt;/code&gt;, but they only cover basic cases. As soon as you need something non-standard - back to UIKit.&lt;/p&gt;
&lt;p&gt;Accessibility. Strangely enough, in UIKit the accessibility API is more mature and predictable. In SwiftUI modifiers like &lt;code&gt;.accessibilityLabel()&lt;/code&gt; work, but when you start doing something more complex - custom rotor actions, adjustable traits, grouped elements - UIKit gives more control.&lt;/p&gt;
&lt;p&gt;I choose UIKit for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Custom controls with non-standard behavior (slider with non-uniform scale, circular progress with animations)&lt;/li&gt;
&lt;li&gt;Apps where every millisecond matters (video editors, games)&lt;/li&gt;
&lt;li&gt;Projects with minimum iOS 13-14 (SwiftUI there is too raw)&lt;/li&gt;
&lt;li&gt;Complex animations with &lt;code&gt;CALayer&lt;/code&gt; and Core Animation&lt;/li&gt;
&lt;li&gt;Working with large tables (thousands of rows with good performance)&lt;/li&gt;
&lt;li&gt;Integration with complex third-party SDKs&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;hybrid-approach-best-of-both-worlds&quot;&gt;Hybrid Approach: Best of Both Worlds&lt;/h3&gt;
&lt;p&gt;The most reasonable solution - combine them. Apple itself does this, why shouldn't we? A hybrid approach lets you use SwiftUI where it's convenient and UIKit where it's necessary.&lt;/p&gt;
&lt;h4 id=&quot;uikit-in-swiftui-via-uiviewrepresentable&quot;&gt;UIKit in SwiftUI via UIViewRepresentable&lt;/h4&gt;
&lt;p&gt;Say you need a rich text editor in a SwiftUI app. The standard &lt;code&gt;TextEditor&lt;/code&gt; is too simple - no formatting, can't change fonts or colors. Ready-made SwiftUI solutions either don't exist or cost money. Take the proven &lt;code&gt;UITextView&lt;/code&gt; and wrap it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct RichTextEditor: UIViewRepresentable {
    @Binding var text: NSAttributedString
    var placeholder: String
    
    func makeUIView(context: Context) -&amp;gt; UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator
        textView.font = .systemFont(ofSize: 16)
        textView.backgroundColor = .clear
        textView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
        return textView
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        // Important: only update if text actually changed
        if uiView.attributedText != text {
            let selectedRange = uiView.selectedRange
            uiView.attributedText = text
            // Preserve cursor position
            uiView.selectedRange = selectedRange
        }
    }
    
    func makeCoordinator() -&amp;gt; Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        var parent: RichTextEditor
        
        init(_ parent: RichTextEditor) {
            self.parent = parent
        }
        
        func textViewDidChange(_ textView: UITextView) {
            // Update binding only when text changes
            parent.text = textView.attributedText
        }
        
        func textViewDidBeginEditing(_ textView: UITextView) {
            if textView.text == parent.placeholder {
                textView.text = &quot;&quot;
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It works. But there are nuances. The most important - state synchronization between SwiftUI and UIKit can be painful. Make sure not to create unnecessary updates in &lt;code&gt;updateUIView&lt;/code&gt;, otherwise you'll get an infinite redraw loop or lose focus in text fields.&lt;/p&gt;
&lt;p&gt;Another example - custom map view with annotations. MKMapView from MapKit works great, but natively it's a UIKit component. Wrap it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;struct MapView: UIViewRepresentable {
    @Binding var region: MKCoordinateRegion
    var annotations: [MKPointAnnotation]
    
    func makeUIView(context: Context) -&amp;gt; MKMapView {
        let mapView = MKMapView()
        mapView.delegate = context.coordinator
        return mapView
    }
    
    func updateUIView(_ mapView: MKMapView, context: Context) {
        mapView.setRegion(region, animated: true)
        
        // Update annotations only if changed
        if mapView.annotations.count != annotations.count {
            mapView.removeAnnotations(mapView.annotations)
            mapView.addAnnotations(annotations)
        }
    }
    
    func makeCoordinator() -&amp;gt; Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, MKMapViewDelegate {
        var parent: MapView
        
        init(_ parent: MapView) {
            self.parent = parent
        }
        
        func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
            parent.region = mapView.region
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;swiftui-in-uikit-via-uihostingcontroller&quot;&gt;SwiftUI in UIKit via UIHostingController&lt;/h4&gt;
&lt;p&gt;Reverse task: you have a large UIKit app, but a new settings screen is easier to write in SwiftUI. Or you need to add a beautiful dashboard with charts, and doing it in UIKit is pure hell.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-swift&quot;&gt;class MainViewController: UIViewController {
    
    func showSettings() {
        let settingsView = SettingsView(onDismiss: { [weak self] in
            self?.dismiss(animated: true)
        })
        
        let hostingController = UIHostingController(rootView: settingsView)
        hostingController.modalPresentationStyle = .formSheet
        
        present(hostingController, animated: true)
    }
}

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

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

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

Circle()
    .scaleEffect(isAnimating ? 1.5 : 1.0)
    .animation(.spring(response: 0.5), value: isAnimating)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Beautiful, simple, works. But try making a complex sequence of animations with dependencies - for example, element A moves, then element B fades in, then both rotate. In SwiftUI you'll have to use &lt;code&gt;withAnimation&lt;/code&gt;, &lt;code&gt;DispatchQueue.asyncAfter&lt;/code&gt; and pray the timings align.&lt;/p&gt;
&lt;p&gt;In UIKit through &lt;code&gt;UIView.animate&lt;/code&gt; or &lt;code&gt;CAAnimation&lt;/code&gt; you have full control. You can set exact curves, completion handlers, group animations. For simple cases SwiftUI is more convenient, for complex ones - UIKit is more reliable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Concrete case: Instagram-like feed&lt;/strong&gt; Made a test: feed with posts (image, author name, likes, comments). 500 posts, scroll through the entire list.&lt;/p&gt;
&lt;p&gt;SwiftUI version:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Memory: 65MB&lt;/li&gt;
&lt;li&gt;Frame drops: 15-20% on iPhone 12&lt;/li&gt;
&lt;li&gt;Code: ~150 lines&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;UIKit version:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Memory: 52MB&lt;/li&gt;
&lt;li&gt;Frame drops: 2-3% on iPhone 12&lt;/li&gt;
&lt;li&gt;Code: ~300 lines&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;UIKit wins on performance but requires twice as much code. What to choose? Depends on priorities. If development speed matters more than the last few percent of performance - SwiftUI. If the app needs to fly even on old hardware - UIKit.&lt;/p&gt;
&lt;h3 id=&quot;decision-making-checklist&quot;&gt;Decision-Making Checklist&lt;/h3&gt;
&lt;p&gt;Here's how I make decisions for each new feature or project:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use SwiftUI if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Minimum iOS version of the project is 15+ (better 16+)&lt;/li&gt;
&lt;li&gt;Need a quick prototype or MVP&lt;/li&gt;
&lt;li&gt;UI consists of standard components (buttons, text, lists, forms)&lt;/li&gt;
&lt;li&gt;Planning to support visionOS, macOS, or watchOS&lt;/li&gt;
&lt;li&gt;Team is ready to deal with bugs and unstable API&lt;/li&gt;
&lt;li&gt;Performance on old devices isn't critical&lt;/li&gt;
&lt;li&gt;No dependency on complex UIKit libraries&lt;/li&gt;
&lt;li&gt;Project is new or you can isolate the feature into a separate module&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Use UIKit if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Need to support iOS 13-14 (or even older)&lt;/li&gt;
&lt;li&gt;Performance on old devices is critical&lt;/li&gt;
&lt;li&gt;Complex custom gestures or animations required&lt;/li&gt;
&lt;li&gt;Strong dependency on third-party UIKit libraries&lt;/li&gt;
&lt;li&gt;No time for experiments with unstable API&lt;/li&gt;
&lt;li&gt;Need pixel-perfect control over layout&lt;/li&gt;
&lt;li&gt;Working with large data volumes (thousands of elements)&lt;/li&gt;
&lt;li&gt;App needs to be maximally stable (fintech, medicine)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Use hybrid approach if:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Migrating from UIKit to SwiftUI gradually&lt;/li&gt;
&lt;li&gt;Have legacy code that works and you're scared to touch it&lt;/li&gt;
&lt;li&gt;Different screens have different requirements&lt;/li&gt;
&lt;li&gt;Want new features in SwiftUI but base in UIKit&lt;/li&gt;
&lt;li&gt;Need custom UIKit components in SwiftUI app&lt;/li&gt;
&lt;li&gt;Team knows both frameworks&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Red flags for SwiftUI:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Client requires iOS 13 support&lt;/li&gt;
&lt;li&gt;App lags even on new devices&lt;/li&gt;
&lt;li&gt;Xcode previews haven't worked for a week&lt;/li&gt;
&lt;li&gt;Spending more time fighting the framework than on features&lt;/li&gt;
&lt;li&gt;Need to integrate 10+ UIKit libraries&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Red flags for UIKit:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Writing 500 lines of code for a simple form&lt;/li&gt;
&lt;li&gt;Spending an hour on layout constraints for one screen&lt;/li&gt;
&lt;li&gt;Junior developer can't figure out storyboards&lt;/li&gt;
&lt;li&gt;Every UI change requires full rebuild&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;conclusion-it-s-about-trade-offs-not-trends&quot;&gt;Conclusion: It's About Trade-offs, Not Trends&lt;/h3&gt;
&lt;p&gt;I manage several projects simultaneously. In one - pure SwiftUI, because it's an internal team tool with minimal performance requirements. In another - UIKit with small SwiftUI inclusions for simple screens like settings or onboarding. In the third - pure UIKit, because the client requires iOS 14 support and performance on old devices is critical.&lt;/p&gt;
&lt;p&gt;Choosing a framework is about context, not hype. SwiftUI is a great tool for certain tasks. But it's not a silver bullet. UIKit isn't going anywhere for a very long time, and that's okay.&lt;/p&gt;
&lt;p&gt;If SwiftUI solves your problem faster and more reliably - use it. If not - don't hesitate to write in UIKit. If you need both - combine them. Your job is to deliver a product that works, not follow trends from conferences.&lt;/p&gt;
&lt;p&gt;Final advice: learn both frameworks. Even if you're a SwiftUI fanatic, knowing UIKit will make you a better developer. And vice versa. Understanding how UIKit works under the hood helps write more efficient SwiftUI code. And knowing SwiftUI patterns can improve your UIKit architecture.&lt;/p&gt;
&lt;p&gt;In 2026, the best iOS developer isn't the one who only knows SwiftUI or only UIKit. It's the one who knows both and can choose the right tool for the specific task.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/10405/swiftui-vs-uikit-in-2026-when-to-use-what</guid>
<pubDate>Sat, 24 Jan 2026 16:38:16 +0000</pubDate>
</item>
<item>
<title>Why most apps feel broken (and why justRead is different)</title>
<link>https://coderlegion.com/10271/why-most-apps-feel-broken-and-why-justread-is-different</link>
<description>&lt;blockquote&gt;&lt;p&gt;Development of justRead app in six parts; part four.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;the-elephant-in-the-room&quot;&gt;The elephant in the room&lt;/h3&gt;
&lt;p&gt;You’ve probably felt it. That moment when you download an app that looks beautiful but feels wrong. &lt;a rel=&quot;nofollow&quot; href=&quot;https://thisisglance.com/blog/why-90-of-users-abandon-apps-during-onboarding-process&quot;&gt;The onboarding is confusing&lt;/a&gt;. Navigation is buried three taps deep. Performance stutters when you expect smoothness. The developer clearly built the app for themselves, not for you.&lt;/p&gt;
&lt;p&gt;According to research, &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.moveoapps.com/blog/the-hidden-cost-of-poor-ux-how-bad-user-experience-kills-retention-and-revenue/&quot;&gt;68% of users will switch to a competitor after just one poor experience&lt;/a&gt;.&lt;br&gt;
Another study found &lt;a rel=&quot;nofollow&quot; href=&quot;https://uxcam.com/blog/ux-statistics/&quot;&gt;88% of users abandon apps due to bad UX alone&lt;/a&gt; — not because they lack features, but because they feel poorly made.&lt;/p&gt;
&lt;p&gt;What’s striking is that this isn’t accidental. It’s a symptom of how most apps are built: from the inside out. Developers ship features without asking if users actually want them. They guess at what “polish” means instead of asking or following guidelines. They update mysteriously, leaving users confused about what changed or why.&lt;/p&gt;
&lt;p&gt;Most apps are made at users, not for them. And users notice.&lt;/p&gt;
&lt;h3 id=&quot;the-15-year-perspective&quot;&gt;The 15-year perspective&lt;/h3&gt;
&lt;p&gt;I’ve spent 15 years building software, and the last decade focusing obsessively on user experience. I’ve watched talented developers ship broken apps. Not technically broken — but broken in the ways that matter: confusing navigation, invisible affordances, missing the small details that make something feel intentional versus accidental — ever experienced misaligned icons?&lt;/p&gt;
&lt;p&gt;And the worst part? Many developers don’t ask what’s broken and what the app needs. They release updates in silence. Users guess why something changed. Features land that nobody wanted. Features people asked for never ship.&lt;/p&gt;
&lt;p&gt;Meanwhile, I’m also a lifetime reader. I’ve used every EPUB reader on iOS. I’ve felt the frustration — smooth scrolling that isn’t, fonts that don’t render properly, library management that feels like an afterthought, features that exist but feel bolted on.&lt;/p&gt;
&lt;p&gt;It became clear: the apps that survive are built by people who deeply understand both craft and empathy.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/45t6oup1m6dvikkc7nul.webp&quot; alt=&quot;xcode&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-problem-apps-without-voices&quot;&gt;The problem: apps without voices&lt;/h3&gt;
&lt;p&gt;Here’s what most apps do wrong: they assume users can read minds.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You download an app. It’s pretty. But then:&lt;/li&gt;
&lt;li&gt;You have an idea for a feature&lt;/li&gt;
&lt;li&gt;You email support (if there even is one)&lt;/li&gt;
&lt;li&gt;You never hear back&lt;/li&gt;
&lt;li&gt;Months later, a mysterious update arrives&lt;/li&gt;
&lt;li&gt;You have no idea if it addressed your feedback or ignored it&lt;/li&gt;
&lt;li&gt;You uninstall&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This happens because there’s no conversation. The developer is building in a vacuum. Users are waiting in the dark.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://360magazine.com/2021/07/27/cisco-appdynamics-app-attention-index/&quot;&gt;72% of users say they expect regular feature updates as extremely important or very important&lt;/a&gt;. But most apps provide zero visibility into what’s coming next. Not a roadmap. Not a hint. Just silence, then surprise.&lt;/p&gt;
&lt;p&gt;This erodes trust. And once trust is gone, so are you.&lt;/p&gt;
&lt;h3 id=&quot;the-better-way-an-in-app-roadmap-where-users-vote&quot;&gt;The better way: an in-app roadmap where users vote&lt;/h3&gt;
&lt;p&gt;This is where justRead is different.&lt;/p&gt;
&lt;p&gt;Right in the app, you’ll find the Feature Roadmap — a living, breathing board where you can:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Vote on features you want to see built next&lt;/li&gt;
&lt;li&gt;See exactly what’s in progress (because transparency matters)&lt;/li&gt;
&lt;li&gt;Track completed features and know your voice shaped them&lt;/li&gt;
&lt;li&gt;Comment and discuss ideas before they’re implemented&lt;/li&gt;
&lt;li&gt;Understand the “why” behind development decisions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This isn’t performative transparency. It’s not a gesture. It’s the actual mechanism by which justRead is built — using VoteFirst, a platform designed specifically for this purpose. Your votes influence what gets built. Your feedback shapes how it gets built. The roadmap updates live — you see progress in real time.&lt;/p&gt;
&lt;p&gt;Why does this matter?&lt;/p&gt;
&lt;p&gt;When users can see and influence the roadmap, something shifts: they go from being customers to being partners. Research shows users who participate in voting are significantly more likely to appreciate updates when they ship — not because the features are better, but because they see themselves in the product.&lt;br&gt;
They feel heard. And when users feel heard, they stay.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0sz7th96yiv8b3bzjihg.webp&quot; alt=&quot;justread I.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;proof-that-polish-matters&quot;&gt;Proof that polish matters&lt;/h3&gt;
&lt;p&gt;I didn’t enter indie app development to build a mediocre reading app. I built justRead because I wanted to create something that feels intentional in every interaction.&lt;/p&gt;
&lt;p&gt;That means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Smooth scrolling that doesn’t stutter&lt;/li&gt;
&lt;li&gt;Thoughtful font rendering that respects typography&lt;/li&gt;
&lt;li&gt;Micro-interactions that feel responsive, not delayed&lt;/li&gt;
&lt;li&gt;Consistent design language throughout (not features that feel bolted on)&lt;/li&gt;
&lt;li&gt;Performance optimization so the app doesn’t drain your battery&lt;/li&gt;
&lt;li&gt;Respecting Apple design guidelines&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is tedious work. No user notices these things consciously. But they feel them. An app that’s been polished feels different — lighter, faster, more trustworthy. An app that hasn’t feels like extra work.&lt;/p&gt;
&lt;p&gt;This isn’t vanity. &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.apmdigest.com/mobile-apps-launch-3-seconds&quot;&gt;Research shows that app performance and stability directly impact how users perceive your entire brand&lt;/a&gt;. One laggy app, and users assume you’re careless everywhere. One smooth app, and they assume you care about details that matter.&lt;/p&gt;
&lt;h3 id=&quot;the-message-to-users&quot;&gt;The message to users&lt;/h3&gt;
&lt;p&gt;If you’re a reader considering justRead, here’s what we’re offering: an app built by someone who reads, who understands what makes apps feel good, and who believes &lt;strong&gt;you should have a voice in what gets built&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;You’re not downloading software. You’re joining a community where your vote matters and your feedback shapes what comes next. Features land because you asked for them, not because a product manager guessed.&lt;/p&gt;
&lt;p&gt;That’s a different kind of relationship. One where the developer is visibly, consistently working for you.&lt;/p&gt;
&lt;p&gt;Join the waitlist at &lt;a rel=&quot;nofollow&quot; href=&quot;https://justread.app&quot;&gt;justread.app&lt;/a&gt; and be part of this process. See the roadmap for yourself. Vote on features. Help us prove that apps can be built for readers, not at them.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5qfyzrfjrurt1gnkax16.webp&quot; alt=&quot;justread II&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-message-to-developers&quot;&gt;The message to developers&lt;/h3&gt;
&lt;p&gt;If you’re a developer reading this, consider what justRead is doing: creating an in-app feature roadmap with user voting.&lt;/p&gt;
&lt;p&gt;Why is this a differentiator? Because most apps don’t do it. Most developers hide their roadmap (if they have one). Most users have no idea what’s being built or if their feedback matters.&lt;/p&gt;
&lt;p&gt;But users crave this transparency. It’s low-cost to build — a GitHub Project, a Notion board, or a dedicated platform like Frill, Changelogfy, or VoteFirst. The hard part isn’t the tool. It’s the commitment: showing your users you’re willing to be transparent about progress, priorities, and constraints.&lt;/p&gt;
&lt;p&gt;When users see your roadmap, they understand:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What you’re working on right now&lt;/li&gt;
&lt;li&gt;What’s coming next (and when)&lt;/li&gt;
&lt;li&gt;Why some features get prioritized over others&lt;/li&gt;
&lt;li&gt;That you’re actively listening&lt;/li&gt;
&lt;li&gt;This builds trust in ways that polished marketing can’t touch.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It’s also a recruiting tool. Developers and designers are attracted to projects that care about users. Transparency says: “We’re not hiding anything. Come build with us.”&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y0p70gnqvc54v5ocdxq9.webp&quot; alt=&quot;votefirst&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-unspoken-competition&quot;&gt;The unspoken competition&lt;/h3&gt;
&lt;p&gt;The competition for user attention isn’t just between EPUB readers. It’s between apps that feel like they want to work for you and apps that feel like they’re tolerating your existence.&lt;/p&gt;
&lt;p&gt;Most apps fall into the latter category. They’re technically functional but emotionally cold. No voice. No relationship. No sense that a human being made thoughtful choices about every detail.&lt;/p&gt;
&lt;p&gt;The apps that win — and I mean truly win, with loyal users who evangelize them — are the ones that feel personal. That feel like someone cared enough to polish every interaction. That feel like the developer is sitting across from you, listening to what you need.&lt;/p&gt;
&lt;p&gt;That’s not a coincidence. That’s design.&lt;/p&gt;
&lt;h3 id=&quot;what-s-next&quot;&gt;What’s next&lt;/h3&gt;
&lt;p&gt;justRead launches with a public, in-app roadmap where you can see every feature in progress and vote on what comes next. This isn’t a “maybe someday” feature. It’s core to how we build.&lt;/p&gt;
&lt;p&gt;If you’re a reader, that means your voice matters from day one.&lt;/p&gt;
&lt;p&gt;If you’re a developer, consider: What if your users didn’t have to wonder what you’re building? What if they could see, vote, and feel ownership over your product’s direction?&lt;/p&gt;
&lt;p&gt;The difference between an app that works and an app that matters is smaller than you think. It starts with listening. It continues with transparency. And it compounds when users feel like partners instead of customers.&lt;/p&gt;
&lt;p&gt;Most apps miss this. They release in silence. They update mysteriously. They hope users stay.&lt;/p&gt;
&lt;p&gt;justRead is built differently.&lt;/p&gt;
&lt;p&gt;Your vote. Your voice. Your app.&lt;/p&gt;
&lt;h3 id=&quot;ready-to-join-&quot;&gt;Ready to join?&lt;/h3&gt;
&lt;p&gt;If you want to be part of shaping justRead’s future — and you’ve been waiting to see what makes this app different — your moment is coming.&lt;br&gt;
→ Join the waitlist at &lt;a rel=&quot;nofollow&quot; href=&quot;https://justread.app&quot;&gt;justread.app&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Watch the roadmap. Vote on features. See your feedback become reality. Help us prove that apps can be built forreaders, not at them.&lt;/p&gt;
&lt;p&gt;P.S. If you’re a developer interested in implementing feature voting in your own app, check out &lt;a rel=&quot;nofollow&quot; href=&quot;https://votefirst.app&quot;&gt;VoteFirst&lt;/a&gt; — that’s what powers justRead’s roadmap. Transparency and user collaboration should be standard practice, not an afterthought.&lt;/p&gt;
&lt;p&gt;Peter&lt;br&gt;
Reader, Developer, justRead Creator&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/10271/why-most-apps-feel-broken-and-why-justread-is-different</guid>
<pubDate>Thu, 22 Jan 2026 12:00:54 +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>UIKit v/s SwiftUI in 2025</title>
<link>https://coderlegion.com/903/uikit-v-s-swiftui-in-2025</link>
<description>&lt;h2 id=&quot;choosing-between-uikit-and-swiftui-in-2025-a-practical-guide&quot;&gt;&lt;strong&gt;Choosing Between UIKit and SwiftUI in 2025: A Practical Guide&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;If you’re an iOS developer, chances are you’ve wondered whether to use UIKit or SwiftUI for your next project. With SwiftUI gaining traction and UIKit remaining a tried-and-true framework, the decision isn’t always straightforward. In 2025, the choice comes down to your app’s needs, your team’s skills, and where you see your project heading. Let us break it down.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;uikit-the-veteran-framework&quot;&gt;&lt;strong&gt;UIKit: The Veteran Framework&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;UIKit has been around since 2008, forming the backbone of iOS app development for years. It is incredibly reliable and offers all the tools you need to build complex, high-performance apps. However, it does come with a steeper learning curve and requires more manual work.&lt;/p&gt;
&lt;h5 id=&quot;when-to-go-with-uikit&quot;&gt;&lt;strong&gt;When to Go with UIKit&lt;/strong&gt;&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Complex UIs&lt;/strong&gt;: If your app needs intricate layouts, custom animations, or highly specific designs, UIKit’s flexibility is hard to beat.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Older iOS Support&lt;/strong&gt;: For apps targeting users on older devices, UIKit is the safer choice.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Working with Legacy Code&lt;/strong&gt;: Updating or extending an existing UIKit-based app? Sticking with UIKit simplifies the process.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Precision Animations&lt;/strong&gt;: UIKit shines when you need precise control over animations or custom graphics.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;pros&quot;&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;Time-tested and stable.&lt;/li&gt;
&lt;li&gt;Extensive documentation and community support.&lt;/li&gt;
&lt;li&gt;Advanced debugging tools.&lt;/li&gt;
&lt;li&gt;Handles complex UI designs effectively.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;cons&quot;&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;More boilerplate code.&lt;/li&gt;
&lt;li&gt;Can feel outdated compared to modern approaches.&lt;/li&gt;
&lt;li&gt;Slower development for simple tasks.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;swiftui-the-future-of-apple-development&quot;&gt;&lt;strong&gt;SwiftUI: The Future of Apple Development&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;SwiftUI, introduced in 2019, takes a fresh, declarative approach to building UIs. It simplifies code, integrates smoothly with Combine for reactive programming, and works across all Apple platforms. By now, it is a mature and capable framework, though some advanced use cases might still require UIKit.&lt;/p&gt;
&lt;h3 id=&quot;when-to-choose-swiftui&quot;&gt;&lt;strong&gt;When to Choose SwiftUI&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt; &lt;strong&gt;Modern App Development&lt;/strong&gt;: Starting fresh? SwiftUI’s faster development cycle and clean syntax are major advantages.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Prototypes and MVPs&lt;/strong&gt;: Its simplicity makes SwiftUI perfect for quick prototypes or Minimum Viable Products.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-Platform Apps&lt;/strong&gt;: If you want to target macOS, watchOS, or tvOS alongside iOS, SwiftUI’s shared APIs are a huge plus.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Future-Proofing&lt;/strong&gt;: SwiftUI is where Apple’s focus lies, making it a solid bet for the long term.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;pros-1&quot;&gt;&lt;strong&gt;Pros&lt;/strong&gt;&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;Cleaner, more readable code.&lt;/li&gt;
&lt;li&gt;Faster development cycles.&lt;/li&gt;
&lt;li&gt;Built-in support for responsive and dynamic UIs.&lt;/li&gt;
&lt;li&gt;Unified development for multiple platforms.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;cons-1&quot;&gt;&lt;strong&gt;Cons&lt;/strong&gt;&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;Doesn’t support iOS versions below 13.&lt;/li&gt;
&lt;li&gt;Some advanced features still need UIKit.&lt;/li&gt;
&lt;li&gt;Smaller knowledge base compared to UIKit.&lt;/li&gt;
&lt;li&gt;API changes as the framework evolves.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;performance-uikit-vs-swiftui&quot;&gt;&lt;strong&gt;Performance: UIKit vs. SwiftUI&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Both frameworks offer excellent performance, but UIKit gives you finer control over details, making it better for demanding tasks like custom graphics. On the other hand, SwiftUI’s declarative nature excels in apps with dynamic data and reactive flows. For mixed projects, combining both frameworks can give you the best of both worlds.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-about-your-team-and-goals-&quot;&gt;&lt;strong&gt;What About Your Team and Goals?&lt;/strong&gt;&lt;/h3&gt;
&lt;h5 id=&quot;team-expertise&quot;&gt;&lt;strong&gt;Team Expertise&lt;/strong&gt;&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;If your team has a deep UIKit background, you might want to stick with it for complex projects.&lt;/li&gt;
&lt;li&gt;Teams already comfortable with Swift and declarative programming will find SwiftUI more intuitive.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;project-goals&quot;&gt;&lt;strong&gt;Project Goals&lt;/strong&gt;&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;If your app needs to support older devices, UIKit is the way to go.&lt;/li&gt;
&lt;li&gt;For quick turnaround and future-proof designs, SwiftUI is a better fit.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id=&quot;a-hybrid-approach&quot;&gt;&lt;strong&gt;A Hybrid Approach&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;In practice, many developers use a mix of UIKit and SwiftUI. SwiftUI views can embed into UIKit projects, and UIKit components can integrate into SwiftUI apps. This flexibility makes it easier to choose the right tool for each part of your app.&lt;/p&gt;
&lt;h5 id=&quot;example&quot;&gt;&lt;strong&gt;Example&lt;/strong&gt;&lt;/h5&gt;
&lt;p&gt;Imagine you’re building a modern app with a highly customized camera feature. You could use UIKit for the camera interface while developing the rest of the app in SwiftUI to save time.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;final-thoughts&quot;&gt;&lt;strong&gt;Final Thoughts&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;In 2025, deciding between UIKit and SwiftUI depends on your app’s needs. UIKit remains a workhorse for complex or legacy projects, while SwiftUI is ideal for modern, future-ready apps. Evaluate your requirements, your team’s expertise, and the project’s timeline to make the best choice.&lt;/p&gt;
&lt;p&gt;For most new apps, SwiftUI is the way forward. But UIKit isn’t going anywhere, and its reliability makes it a solid choice for many situations. Sometimes, the best approach is combining both frameworks to leverage their strengths.&lt;/p&gt;
&lt;p&gt;Whichever you choose, focus on building great apps. The tools are just a means to that end.&lt;/p&gt;
</description>
<category>Python</category>
<guid isPermaLink="true">https://coderlegion.com/903/uikit-v-s-swiftui-in-2025</guid>
<pubDate>Wed, 22 Jan 2025 21:27:46 +0000</pubDate>
</item>
</channel>
</rss>