130 Widgets

Building tools. Learning to build tools. Learning to build learning tools.

4. Data Model and Persistent Settings iOS

Before the app can stream anything, it needs to know where the printer is. PrinterSettings.swift is the data model: 25 lines of Swift that store the printer’s IP address and access code, persist them across app launches, and derive the RTSPS URL from them. Small file, but it demonstrates three important iOS patterns in one place.

PrinterSettings.swift

import Foundation
import SwiftUI

class PrinterSettings: ObservableObject {
    @Published var printerIP: String {
        didSet { UserDefaults.standard.set(printerIP, forKey: "printerIP") }
    }
    @Published var accessCode: String {
        didSet { UserDefaults.standard.set(accessCode, forKey: "accessCode") }
    }

    var isConfigured: Bool {
        !printerIP.isEmpty && !accessCode.isEmpty
    }

    var streamURL: URL? {
        guard isConfigured else { return nil }
        return URL(string: "rtsps://bblp:\(accessCode)@\(printerIP):322/streaming/live/1")
    }

    init() {
        self.printerIP = UserDefaults.standard.string(forKey: "printerIP") ?? ""
        self.accessCode = UserDefaults.standard.string(forKey: "accessCode") ?? ""
    }
}

ObservableObject and @Published

ObservableObject is a protocol that enables a class to participate in SwiftUI’s reactive update system. When a property marked @Published changes, SwiftUI automatically re-renders any views that are observing this object.

If you’ve written C# with WPF, this maps closely to:

Swift / SwiftUIC# / WPF
class PrinterSettings: ObservableObject class PrinterSettings : INotifyPropertyChanged
@Published var printerIP: String [ObservableProperty] private string _printerIP; (CommunityToolkit)
SwiftUI auto-subscribes when a view reads the property Binding in XAML subscribes to PropertyChanged

The @Published property wrapper synthesizes a Combine Publisher behind the scenes. When the value changes, it sends a notification to SwiftUI’s observation system, which schedules a re-render of any view that read the property.

iOS Concept

ObservableObject requires a class (reference type), not a struct. This is because multiple views need to share a single instance and observe changes to it. If PrinterSettings were a struct, each copy in each view would be independent, and changes in one view wouldn’t propagate to others. The reference semantics of a class guarantee that @StateObject in ContentView and @ObservedObject in SettingsView point to the same object.

UserDefaults Persistence with didSet

iOS apps are regularly terminated and relaunched. The printer IP and access code need to survive across sessions. UserDefaults is the right tool for small, user-level preferences — it’s essentially a persistent dictionary backed by a property list file in the app’s container.

The persistence is wired through Swift’s didSet property observer:

@Published var printerIP: String {
    didSet { UserDefaults.standard.set(printerIP, forKey: "printerIP") }
}

didSet is a block that runs immediately after the property’s value changes. Every time printerIP is assigned a new value, the new value is written to UserDefaults. The init() reads it back on startup:

init() {
    self.printerIP = UserDefaults.standard.string(forKey: "printerIP") ?? ""
    self.accessCode = UserDefaults.standard.string(forKey: "accessCode") ?? ""
}

The ?? "" is the nil-coalescing operator — if no value has ever been saved for that key, string(forKey:) returns nil, and we substitute an empty string. This is equivalent to C#’s ?? operator: Properties.Settings.Default.PrinterIP ?? string.Empty.

Tip

For a production app, storing a security credential like an access code in UserDefaults is not best practice — the Keychain is the right place for secrets. UserDefaults is unencrypted and accessible to anyone with a full device backup. For a personal-use app on your own phone, the tradeoff is acceptable. A future improvement would be to store accessCode using SecItemAdd/SecItemCopyMatching in the Keychain, or use the KeychainAccess library as a friendlier wrapper.

Computed Properties: isConfigured and streamURL

Rather than storing a Bool for “is the user configured?” or recomputing the stream URL in multiple places, these are computed properties — properties with no storage that compute their value on demand:

var isConfigured: Bool {
    !printerIP.isEmpty && !accessCode.isEmpty
}

var streamURL: URL? {
    guard isConfigured else { return nil }
    return URL(string: "rtsps://bblp:\(accessCode)@\(printerIP):322/streaming/live/1")
}

streamURL returns an Optional<URL> (written as URL?). It returns nil when not configured, and a URL when it is. Swift’s string interpolation (\(accessCode)) is equivalent to C#’s interpolated strings ($"{accessCode}").

Notice that streamURL is not @Published. It doesn’t need to be — because it’s derived from printerIP and accessCode, SwiftUI knows to recompute it whenever either of those @Published properties changes. A computed property based on observed state is automatically reactive.

@StateObject vs @ObservedObject

PrinterSettings is created once and shared across two views:

// ContentView.swift
@StateObject private var settings = PrinterSettings()

// SettingsView.swift — receives the same instance as a parameter
@ObservedObject var settings: PrinterSettings

The distinction matters:

A common mistake is to use @ObservedObject in the root view that creates the object. If you write @ObservedObject private var settings = PrinterSettings() in ContentView, SwiftUI may create a new PrinterSettings instance every time the view rebuilds, losing the reference and the data. Use @StateObject for the view that owns the lifetime of the object.

Summary

PrinterSettings is an ObservableObject with two @Published properties that auto-save to UserDefaults via didSet. The RTSPS URL is a computed property derived from those two values, so it’s always consistent. ContentView owns the instance with @StateObject; SettingsView observes it with @ObservedObject.