📱 UIKit iOS Development from First Principles — A SecureRoom Deep Dive

A complete breakdown of every layer of a real-world UIKit app. Based on the open-source SecureRoom project — a passcode-protected secure vault app built with UIKit, Storyboards, Core Data, and the iOS Keychain.

⚠️ This guide deliberately contrasts UIKit's imperative style against SwiftUI's declarative approach wherever relevant, so you understand why things are done the way they are.


What is SecureRoom?

SecureRoom is a passcode-protected vault app. On launch it shows a 4-digit PIN entry screen. The user sets a passcode on first run (saved to the iOS Keychain), and must enter it every time the app is opened or brought back from the background. Passing the PIN reveals a UITabBarController-based home screen with three tabs (Home, Add, Settings).

Tech stack:

  • Language: Swift 5.9
  • UI Framework: UIKit
  • UI Design: Storyboards + Auto Layout
  • Architecture: MVC (Model-View-Controller)
  • Secure Storage: iOS Keychain (Security framework)
  • Local Persistence: Core Data (NSPersistentContainer)
  • Navigation: UIWindow.rootViewController swaps + UITabBarController

UIKit vs SwiftUI — The Core Difference

Before diving into code, understand the fundamental philosophy difference:

UIKit SwiftUI
Paradigm Imperative — you tell the system how to update step by step Declarative — you describe what the UI should look like for a given state
UI Design Storyboards (visual XML) or code with addSubview Pure Swift code in body
Controllers UIViewController classes manage views View structs own their state
State @IBOutlet properties + manual label.text = "..." updates @State, @Published — UI auto-updates
Introduced iOS 2 (2008) iOS 13 (2019)
Current use Vast majority of production apps, all legacy codebases Greenfield apps, Apple's future

In UIKit, when data changes, you write the code that updates the UI. In SwiftUI, you change state and the framework re-renders. This is the fundamental difference.

// UIKit — imperative: you manually update the label
messageLabel.text = "Welcome, create a passcode"

// SwiftUI — declarative: the UI is always derived from state
Text(viewModel.message)   // auto-updates when viewModel.message changes

Project Architecture — MVC

UIKit was built around MVC (Model-View-Controller), Apple's original iOS design pattern.

┌─────────────────────────────────────────────────────────────┐
│                         USER ACTION                          │
│                    (taps Submit button)                      │
└──────────────────────────┬──────────────────────────────────┘
                           │ IBAction
                           ▼
┌─────────────────────────────────────────────────────────────┐
│              CONTROLLER (UIViewController)                   │
│   LockScreenViewController — receives events, reads IBOutlets│
│   → calls KeychainManager (Helper/Service)                  │
│   → decides what to do next (show alert / navigate)         │
│   → directly updates View properties (label.text = ...)     │
└──────────┬──────────────────────────────────────────────────┘
           │                    │
           ▼                    ▼
┌──────────────────┐   ┌───────────────────────────────────┐
│   MODEL          │   │   VIEW (Storyboard + UIKit)        │
│   Secret.swift   │   │   UILabel, UITextField, UIButton   │
│   (data struct)  │   │   Auto Layout constraints          │
└──────────────────┘   └───────────────────────────────────┘

In MVC:

  • Model holds data — plain structs/classes, no UIKit imports
  • View is the visual layer — in UIKit this is managed mostly by Storyboard XML
  • Controller is the bridge — it reads user input from the View, manipulates the Model, and updates the View manually

The main criticism of MVC in iOS is that ViewControllers tend to grow very large ("Massive View Controller") because they own both the business logic and the view manipulation code.


Folder Structure

SecureRoom/
└── SecureRoom/                           ← Xcode project root
    ├── AppDelegate.swift                 ← App lifecycle + Core Data stack
    ├── SceneDelegate.swift               ← Window/scene management + lock-on-background
    ├── ViewController.swift              ← Unused base VC (Xcode default)
    ├── Controllers/
    │   ├── LockScreenViewController.swift4-digit PIN screen (full logic)
    │   └── HomeViewController.swift       ← Home screen (scaffold)
    ├── Helpers/
    │   ├── Constants.swift               ← Global constants (passcodeKey)
    │   └── KeychainManager.swift         ← iOS Keychain save/read/delete
    ├── Models/
    │   └── Secret.swift                  ← SecretModel data struct
    ├── Storyboards/
    │   ├── LockScreen.storyboard         ← Lock screen UI (4 textfields, button, label)
    │   └── HomeScreen.storyboard         ← Tab bar controller with 3 tabs
    └── Assets.xcassets                   ← App icon, Logo image, colors

App Lifecycle — AppDelegate & SceneDelegate

UIKit apps have two lifecycle entry points: AppDelegate (app-wide) and SceneDelegate (per-window). This two-delegate system was introduced in iOS 13 to support multi-window on iPad.


AppDelegate.swift

The AppDelegate is the oldest lifecycle hook in iOS. It's called by the OS for app-wide events that are not tied to any specific window.

import UIKit
import CoreData

@main   // marks this class as the entry point (replaces main.swift)
class AppDelegate: UIResponder, UIApplicationDelegate {

    // Called once when the app process launches — before any UI appears
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        // Ideal place to: configure SDKs, set up analytics, configure appearance
        return true  // must return true unless launch should be aborted
    }

    // Called when a NEW scene (window) is about to be created
    // Returns a configuration object — "Default Configuration" matches Info.plist
    func application(
        _ application: UIApplication,
        configurationForConnecting connectingSceneSession: UISceneSession,
        options: UIScene.ConnectionOptions
    ) -> UISceneConfiguration {
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    // Called when the user closes a scene (e.g. swipes away in the App Switcher)
    func application(
        _ application: UIApplication,
        didDiscardSceneSessions sceneSessions: Set<UISceneSession>
    ) {
        // Release any resources specific to those scenes here
    }

    // MARK: - Core Data stack
    lazy var persistentContainer: NSPersistentContainer = {
        let container = NSPersistentContainer(name: "SecureRoom")
        container.loadPersistentStores { storeDescription, error in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        }
        return container
    }()

    // Called by SceneDelegate.sceneDidEnterBackground to persist Core Data changes
    func saveContext() {
        let context = persistentContainer.viewContext
        if context.hasChanges {
            do {
                try context.save()
            } catch {
                let nserror = error as NSError
                fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
            }
        }
    }
}

Key AppDelegate concepts

Concept What it does
UIResponder Base class for objects that can respond to touch events. AppDelegate and UIViewController both inherit from it.
UIApplicationDelegate Protocol that declares all lifecycle callback methods the OS calls. You only implement the ones you need.
@main Tells Swift this class is the app's entry point. Replaces the old main.swift file. In UIKit apps this is always on AppDelegate.
didFinishLaunchingWithOptions Called once after the app process starts and the main storyboard loads. Ideal for SDK configuration (Firebase, Crashlytics, etc.).
lazy var The property is only computed on first access. persistentContainer can take time to initialise, so lazy defers the work until it's actually needed.
NSPersistentContainer Core Data's all-in-one setup — manages the persistent store, object model, and managed object context.

SceneDelegate.swift

The SceneDelegate manages a single window (scene). Most iPhone apps have exactly one scene. The SceneDelegate handles the lifecycle of that specific window — including when it enters the background (where SecureRoom locks itself).

import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?  // the actual window object that contains all views

    // Called when the scene is about to connect to the app
    // This is where you configure the root view controller
    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        // Cast to UIWindowScene — the concrete type for iOS windows
        guard let windowScene = (scene as? UIWindowScene) else { return }

        // Create the UIWindow manually (overrides storyboard-based window creation)
        let window = UIWindow(windowScene: windowScene)
        self.window = window

        // Load LockScreen storyboard and set it as the root
        let storyboard = UIStoryboard(name: "LockScreen", bundle: nil)
        let lockVc = storyboard.instantiateInitialViewController()

        window.rootViewController = lockVc
        window.makeKeyAndVisible()  // makes this window the key window + shows it
    }

    // Scene lifecycle hooks — called at specific points in the window lifecycle
    func sceneDidDisconnect(_ scene: UIScene) { }       // scene removed from memory
    func sceneDidBecomeActive(_ scene: UIScene) { }     // scene is now active (in foreground)
    func sceneWillResignActive(_ scene: UIScene) { }    // about to become inactive (call, notification)
    func sceneWillEnterForeground(_ scene: UIScene) { } // coming back from background

    // Called when app goes to background — CRITICAL for SecureRoom
    func sceneDidEnterBackground(_ scene: UIScene) {
        // 1. Save any unsaved Core Data changes
        (UIApplication.shared.delegate as? AppDelegate)?.saveContext()
        // 2. Lock the screen
        lockScreen()
    }

    // Replaces the window's root VC with the lock screen
    func lockScreen() {
        let storyboard = UIStoryboard(name: "LockScreen", bundle: nil)
        let lockVc = storyboard.instantiateInitialViewController()

        // DispatchQueue.main.async ensures UI work runs on the main thread
        DispatchQueue.main.async {
            self.window?.rootViewController = lockVc
        }
    }
}

Scene lifecycle order

App cold launch:
willConnectTo → sceneDidBecomeActive

User presses Home button:
sceneWillResignActive → sceneDidEnterBackground   ← SecureRoom locks here

User reopens app from App Switcher:
sceneWillEnterForeground → sceneDidBecomeActive

Key SceneDelegate concepts

Concept What it does
UIWindowSceneDelegate Protocol for per-window lifecycle callbacks. Introduced iOS 13.
UIWindow The invisible container that holds all views. Every app needs at least one. Normally created automatically via storyboard, but here created manually for full control.
UIWindowScene The scene type for iPhone/iPad windows (as opposed to CarPlay or Watch).
window.rootViewController The topmost view controller visible on screen. Swapping this is the most direct way to completely change screens in UIKit.
makeKeyAndVisible() Makes this window the key window (the one that receives keyboard input) and makes it visible. Without this, nothing appears.
UIStoryboard(name:bundle:) Loads a storyboard file by name. bundle: nil means the main app bundle.
instantiateInitialViewController() Creates the view controller marked as "Initial View Controller" in the storyboard. Returns UIViewController?.
DispatchQueue.main.async { } Schedules a closure to run on the main thread asynchronously. All UIKit updates must happen on the main thread. Failure to do this causes crashes or visual glitches.
UIApplication.shared.delegate as? AppDelegate Gets the global app delegate instance to call saveContext(). The as? is a conditional downcast — AppDelegate is a specific subclass; the delegate property is typed as the generic UIApplicationDelegate protocol.

Core Data Stack inside AppDelegate

Core Data is Apple's object graph persistence framework — it stores structured data locally on device in a SQLite database (by default).

NSPersistentContainer ("SecureRoom")
         │
         ├── NSManagedObjectModel      ← defines entities/attributes (from .xcdatamodeld file)
         ├── NSPersistentStoreCoordinator ← manages the actual SQLite file on disk
         └── NSManagedObjectContext (viewContext) ← the in-memory scratchpad you work with
                    │
                    ├── fetch → read objects from SQLite
                    ├── insert → create new objects
                    ├── delete → mark objects for deletion
                    └── save() → writes all changes to SQLite

How to use the context in a View Controller:

// Get the context from AppDelegate
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext

// Fetch all secrets
let request: NSFetchRequest<SecretEntity> = SecretEntity.fetchRequest()
let secrets = try? context.fetch(request)

// Insert a new object
let secret = SecretEntity(context: context)
secret.title = "My password"
secret.value = "hunter2"

// Save to disk
try? context.save()

Storyboards — Designing UI Visually

What is a Storyboard?

A Storyboard is an XML file (.storyboard) that describes your UI visually. Xcode renders it as a drag-and-drop canvas. At build time, Xcode compiles the XML into the app bundle, and UIKit reads it at runtime to instantiate views.

You never hand-edit the XML — Xcode's Interface Builder does it. But understanding the XML structure helps you debug connection issues.

Storyboard file → XML structure:

<document>                          ← the storyboard file
  <scenes>                          ← array of screens
    <scene>                         ← one screen / view controller
      <objects>
        <viewControllermaps to a UIViewController subclass
          customClass="LockScreenViewController"
        >
          <view>                    ← the root UIView
            <subviews>
              <label .../>          ← UILabel
              <textField .../>      ← UITextField
              <button .../>         ← UIButton
              <stackView>           ← UIStackView
                <subviews> ... </subviews>
              </stackView>
            </subviews>
            <constraints> ... </constraints>   ← Auto Layout rules
          </view>
          <connections>             ← IBOutlet and IBAction connections
            <outlet property="messageLabel" destination="labelID"/>
            <action selector="verify:" destination="vcID"/>
          </connections>
        </viewController>
      </objects>
    </scene>
  </scenes>
</document>

LockScreen.storyboard — Layout Breakdown

From the storyboard XML, the LockScreen contains:

UIView (root)  backgroundColor = systemBackground
│
├── UIImageView            — Logo image, centred, width = 50% of screen
│
├── UILabel"SecureRoom" title
│                            font: Copperplate-Bold, size 32
│
├── UIStackView            — horizontal, distribution: fillEqually, spacing: 20
│   ├── UITextField [1]    — one digit, borderStyle: roundedRect, centred
│   ├── UITextField [2]    — one digit
│   ├── UITextField [3]    — one digit
│   └── UITextField [4]    — one digit, textContentType: one-time-code
│
├── UIButton"Submit", style: filled, cornerStyle: large
│                            SF Symbol: arrowshape.right
│                            action → verifyWithSender:
│
└── UILabel (messageLabel) — "Welcome create a passcode" / "Enter passcode"
                             pinned to bottom safe area

Auto Layout constraint summary (from the XML):

  • Logo: top = safeArea.top, centerX = view.centerX, width = view.width * 0.5
  • Title: top = logo.bottom + 5, centerX = view.centerX
  • StackView: top = title.bottom + 40, leading = safeArea.leading + 30, trailing = safeArea.trailing - 30
  • Submit button: top = stackView.bottom + 30, centerX = view.centerX
  • Message label: bottom = safeArea.bottom - 30, centerX = view.centerX

HomeScreen.storyboard — UITabBarController

The home screen uses a UITabBarController as the root, containing three tabs wired to three view controllers:

UITabBarController (initialViewController)
│
├── Tab 1"Home"  (house.circle icon)
│   └── UIViewController (Dd7-d5-3aK)
│
├── Tab 2"Add"   (plus icon)
│   └── UIViewController (tBp-mc-axm)
│
└── Tab 3"Item"  (gearshape icon) ← settings/gear
    └── UIViewController (gDx-nH-5J7)

The three tabs are connected via <segue kind="relationship" relationship="viewControllers"> — a special storyboard relationship that populates the tab bar's viewControllers array.


IBOutlet — Connecting Storyboard UI to Code

@IBOutlet is how you create a reference from your Swift code to a UI element defined in the Storyboard.

// In LockScreenViewController.swift
@IBOutlet weak var textField1: UITextField!   // digit 1
@IBOutlet weak var textField2: UITextField!   // digit 2
@IBOutlet weak var textField3: UITextField!   // digit 3
@IBOutlet weak var textField4: UITextField!   // digit 4

@IBOutlet weak var messageLabel: UILabel!     // status/welcome message

The connection is stored in the storyboard XML <connections> section:

<outlet property="messageLabel" destination="8es-74-hrV" id="ylD-M4-Bto"/>
<outlet property="textField1"   destination="J9t-mY-Vbq" id="0he-5t-ky5"/>

Xcode's Interface Builder draws a line between the circle in the Swift editor margin and the UI element in the canvas to establish this.

Important notes about IBOutlets:

Concept Explanation
weak The view controller doesn't own the views — the view hierarchy does. weak prevents a retain cycle.
! (implicitly unwrapped optional) The outlet is nil before the storyboard loads. The ! means "I promise this won't be nil when I use it." If you access it before viewDidLoad, it crashes.
Access timing All @IBOutlet properties are set just before viewDidLoad() is called. Never access them in init.

Once connected, you update UI elements imperatively:

// Updating a label
messageLabel.text = "Enter your passcode to unlock secure room"
messageLabel.textColor = .systemRed
messageLabel.isHidden = true

// Updating a text field
textField1.text = ""
textField1.borderStyle = .roundedRect
textField1.backgroundColor = .systemGray6

IBAction — Connecting Buttons to Code

@IBAction marks a method as callable from the Storyboard. When a user interaction event fires (e.g. touchUpInside on a button), UIKit calls this method.

// In LockScreenViewController.swift

// sender: UIAction is the new iOS 15+ style using UIAction
@IBAction func verify(sender: UIAction) {
    verifyOtp()
}

// Traditional UIKit style (also valid):
@IBAction func verify(_ sender: UIButton) {
    verifyOtp()
}

In the storyboard XML this is:

<connections>
    <action selector="verifyWithSender:" destination="Y6W-OH-hqX"
            eventType="touchUpInside" id="Y0A-Uq-W4Y"/>
</connections>

eventType="touchUpInside" is the most common button event — fires when the user lifts their finger inside the button bounds. Other events: touchDown, valueChanged (for sliders/switches), editingChanged (for text fields).


Auto Layout & Constraints

Auto Layout is UIKit's constraint-based layout system. Instead of hardcoding pixel positions, you define rules (constraints) that the system solves at runtime for any screen size.

A constraint is a mathematical relationship between two layout attributes:

item1.attribute = multiplier × item2.attribute + constant

Common constraint examples (as they appear in code):

// Center horizontally in parent
NSLayoutConstraint(
    item: logoImageView,
    attribute: .centerX,
    relatedBy: .equal,
    toItem: view,
    attribute: .centerX,
    multiplier: 1.0,
    constant: 0
).isActive = true

// Width = 50% of parent width (multiplier: 0.5)
logoImageView.widthAnchor.constraint(
    equalTo: view.widthAnchor,
    multiplier: 0.5
).isActive = true

// Modern anchor syntax (preferred in code)
NSLayoutConstraint.activate([
    stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 30),
    stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -30),
    stackView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 40)
])

Safe Area Layout Guide — the area of the screen not covered by the notch, Dynamic Island, or Home indicator. Always constrain to safeAreaLayoutGuide for content that must be fully visible.

// Pin label to bottom safe area with 30pt margin
messageLabel.bottomAnchor.constraint(
    equalTo: view.safeAreaLayoutGuide.bottomAnchor,
    constant: -30
).isActive = true

Critical rule for programmatic views: Any view you create in code (not from storyboard) must have this set before adding constraints:

myView.translatesAutoresizingMaskIntoConstraints = false
// Without this, Auto Layout and the old autoresizingMask system conflict → broken layout

UIStackView

UIStackView is a layout container that arranges its arrangedSubviews either horizontally or vertically — automatically managing spacing and alignment. It is the UIKit equivalent of SwiftUI's HStack/VStack.

In the LockScreen storyboard, all four pin digit text fields are inside a UIStackView:

<stackView distribution="fillEqually" spacing="20">
    <subviews>
        <textField id="J9t-mY-Vbq"/> <!-- digit 1 -->
        <textField id="51S-e9-Mi9"/> <!-- digit 2 -->
        <textField id="28d-qL-eN1"/> <!-- digit 3 -->
        <textField id="3o3-O4-NwI"/> <!-- digit 4 -->
    </subviews>
</stackView>

Creating UIStackView in code:

let stackView = UIStackView(arrangedSubviews: [tf1, tf2, tf3, tf4])
stackView.axis = .horizontal           // .vertical for vertical stack
stackView.distribution = .fillEqually  // all arranged views get equal width
stackView.spacing = 20                 // 20pt gap between each view
stackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stackView)

distribution options:

Value Behaviour
.fill First subview fills available space; others use their intrinsic size
.fillEqually All subviews get the same size — perfect for a pin pad
.equalSpacing Equal spacing between views; views use intrinsic size
.equalCentering Centers of views are equally spaced

Controllers Layer — UIViewController

The UIViewController Lifecycle

UIViewController is the backbone of every UIKit screen. It manages a view hierarchy and responds to system events. Every screen is a UIViewController subclass.

                    ┌─────────────────────────────────┐
                    │       init / loadView            │  ← View created from storyboard
                    └──────────────┬──────────────────┘
                                   │
                    ┌──────────────▼──────────────────┐
                    │         viewDidLoad()            │  ← Called ONCE after view loads
                    │   ✅ Set up delegates            │     Safe to access @IBOutlets here
                    │   ✅ Configure UI               │
                    │   ✅ Add gesture recognisers     │
                    └──────────────┬──────────────────┘
                                   │
                    ┌──────────────▼──────────────────┐
                    │         viewWillAppear()         │  ← Before view becomes visible
                    │   ✅ Refresh data               │     Called every time (not just once)
                    │   ✅ Start animations           │
                    └──────────────┬──────────────────┘
                                   │
                    ┌──────────────▼──────────────────┐
                    │         viewDidAppear()          │  ← View is fully visible
                    │   ✅ Start expensive operations  │
                    │   ✅ Trigger onboarding flows   │
                    └──────────────┬──────────────────┘
                                   │  (user navigates away)
                    ┌──────────────▼──────────────────┐
                    │         viewWillDisappear()      │  ← About to hide
                    │   ✅ Save user input            │
                    │   ✅ Stop timers                │
                    └──────────────┬──────────────────┘
                                   │
                    ┌──────────────▼──────────────────┐
                    │         viewDidDisappear()       │  ← Fully hidden
                    └──────────────┬──────────────────┘
                                   │  (if memory pressure)
                    ┌──────────────▼──────────────────┐
                    │         deinit                   │
                    └─────────────────────────────────┘

Key rule: super.viewDidLoad() must always be called first. All Xcode templates include this. Forgetting it causes subtle bugs because the parent class performs essential setup.


LockScreenViewController.swift

This is the most complete file in the project — it demonstrates nearly every core UIKit concept.

import UIKit

class LockScreenViewController: UIViewController, UITextFieldDelegate {

    // ── IBOutlets ────────────────────────────────────────────────
    @IBOutlet weak var textField1: UITextField!
    @IBOutlet weak var textField2: UITextField!
    @IBOutlet weak var textField3: UITextField!
    @IBOutlet weak var textField4: UITextField!

    @IBOutlet weak var messageLabel: UILabel!

    // ── IBActions ────────────────────────────────────────────────
    @IBAction func verify(sender: UIAction) {
        verifyOtp()
    }

    @IBAction func setPassword(sender: UIAction) {
        // Hardcoded for development — saves "4321" as the passcode
        KeychainManager.save(key: passcodeKey, value: "4321")
    }

    // ── viewDidLoad ──────────────────────────────────────────────
    override func viewDidLoad() {
        super.viewDidLoad()

        let fields = [textField1, textField2, textField3, textField4]

        fields.forEach {
            $0?.delegate = self  // set this VC as the delegate for all 4 fields
            // addTarget: target-action pattern — calls textDidChange when text changes
            $0?.addTarget(self, action: #selector(textDidChange(_:)), for: .editingChanged)
        }

        // Tap anywhere on background to dismiss keyboard
        let uiTap = UIGestureRecognizer(target: self, action: #selector(dismissKeyboard))
        view.addGestureRecognizer(uiTap)

        // Show first field keyboard immediately
        textField1.becomeFirstResponder()

        // Contextual welcome message
        if KeychainManager.read(key: passcodeKey) == nil {
            messageLabel.text = "Welcome 🙏 create a passcode"
        } else {
            messageLabel.text = "Enter your passcode to unlock secure room"
        }
    }

    // ── Passcode Verification Logic ──────────────────────────────
    func verifyOtp() {
        let otp = "\(textField1.text ?? "")\(textField2.text ?? "")\(textField3.text ?? "")\(textField4.text ?? "")"

        guard otp.count == 4 else {
            showAlert(msg: "Please enter complete passcode")
            return
        }

        if let savedPass = KeychainManager.read(key: passcodeKey) {
            if savedPass == otp {
                goToHome()
            } else {
                showAlert(msg: "Incorrect passcode")
                clearFields()
            }
        } else {
            // First launch: no passcode exists yet — register mode
        }
    }

    // ── Alert ────────────────────────────────────────────────────
    func showAlert(msg: String) {
        let alert = UIAlertController(title: "Error", message: msg, preferredStyle: .alert)
        let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
        alert.addAction(okAction)
        present(alert, animated: true, completion: nil)
    }

    // ── Keyboard ─────────────────────────────────────────────────
    @objc func dismissKeyboard() {
        view.endEditing(true)  // tells every text field in the view to resign first responder
    }

    // ── UITextFieldDelegate — auto-advance & auto-backspace ──────
    @objc func textDidChange(_ textField: UITextField) {
        guard let text = textField.text, !text.isEmpty else { return }

        // Auto-advance: move focus to next field when a digit is entered
        if textField == textField1 {
            textField2.becomeFirstResponder()
        } else if textField == textField2 {
            textField3.becomeFirstResponder()
        } else if textField == textField3 {
            textField4.becomeFirstResponder()
        } else if textField == textField4 {
            if textField.text?.isEmpty == false {
                verifyOtp()
                dismissKeyboard()
            }
        }
    }

    // Called BEFORE a character is inserted or deleted
    // Returns true = allow the change, false = block it
    func textField(
        _ textField: UITextField,
        shouldChangeCharactersIn range: NSRange,
        replacementString string: String
    ) -> Bool {
        if string.isEmpty {
            // Backspace: move focus to previous field
            if textField == textField2 { textField1.becomeFirstResponder() }
            else if textField == textField3 { textField2.becomeFirstResponder() }
            else if textField == textField4 { textField3.becomeFirstResponder() }

            textField.text = ""
            return false   // block the default deletion (we already cleared manually)
        }

        // Only allow one character per field: if already has text, reject new input
        return textField.text?.isEmpty ?? true
    }

    // ── Helpers ──────────────────────────────────────────────────
    func clearFields() {
        [textField1, textField2, textField3, textField4].forEach {
            $0?.text = ""
        }
        textField1.becomeFirstResponder()
    }

    // ── Navigation ───────────────────────────────────────────────
    func goToHome() {
        // Get SceneDelegate to access the window object
        guard let sceneDelegate = view.window?.windowScene?.delegate as? SceneDelegate else {
            return
        }

        let storyboard = UIStoryboard(name: "HomeScreen", bundle: nil)
        let homeVc = storyboard.instantiateInitialViewController()

        DispatchQueue.main.async {
            sceneDelegate.window?.rootViewController = homeVc
        }
    }
}

HomeViewController.swift

The home view controller is currently a scaffold — it demonstrates the minimum valid UIViewController:

import UIKit

class HomeViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // UI setup code goes here
    }
}

This is intentional. In a production app, HomeViewController would likely contain a UITableView or UICollectionView showing the stored secrets, with NSFetchedResultsController feeding data from Core Data.


UIKit has several navigation patterns. SecureRoom uses the most powerful one: directly swapping window.rootViewController.


Programmatic Navigation via UIWindow

Swapping window.rootViewController is the nuclear option — it completely replaces everything on screen. It's perfect for authentication flows where you never want the user to be able to swipe back.

// From LockScreenViewController — navigate to home after successful PIN
func goToHome() {
    guard let sceneDelegate = view.window?.windowScene?.delegate as? SceneDelegate else {
        return
    }

    let storyboard = UIStoryboard(name: "HomeScreen", bundle: nil)
    let homeVc = storyboard.instantiateInitialViewController()

    DispatchQueue.main.async {
        sceneDelegate.window?.rootViewController = homeVc
        // Optional: add a transition animation
        // UIView.transition(with: sceneDelegate.window!, duration: 0.3,
        //                   options: .transitionCrossDissolve, animations: nil)
    }
}

// From SceneDelegate — lock screen on background
func lockScreen() {
    let storyboard = UIStoryboard(name: "LockScreen", bundle: nil)
    let lockVc = storyboard.instantiateInitialViewController()

    DispatchQueue.main.async {
        self.window?.rootViewController = lockVc
    }
}

Access chain: view.window?.windowScene?.delegate as? SceneDelegate

UIViewController.view     ← the ViewController's root UIView
        │ .window
UIWindow                  ← the window that contains the view
        │ .windowScene
UIWindowScene             ← the scene (multi-window support)
        │ .delegate
UIWindowSceneDelegate     ← the scene's delegate (cast to SceneDelegate)

UINavigationController

UINavigationController manages a stack of view controllers with a navigation bar and back button. This is the standard push/pop navigation pattern.

// Push — adds a new VC to the top of the stack (slides in from right)
let detailVC = DetailViewController()
navigationController?.pushViewController(detailVC, animated: true)

// Pop — removes the top VC (slides back left)
navigationController?.popViewController(animated: true)

// Pop to root — removes everything except the first VC
navigationController?.popToRootViewController(animated: true)

// Passing data forward — set properties before pushing
let detailVC = DetailViewController()
detailVC.secretTitle = "My Password"
navigationController?.pushViewController(detailVC, animated: true)

// Passing data back — use a delegate protocol or closure
class ListViewController: UIViewController, DetailDelegate {
    func didUpdateSecret(_ secret: SecretModel) {
        // handle the update
    }
}

UITabBarController

From the HomeScreen.storyboard, the app uses a UITabBarController with three tabs. This is the standard pattern for apps with distinct top-level sections.

// Programmatic setup (alternative to Storyboard)
let tabBarController = UITabBarController()

let homeVC = HomeViewController()
homeVC.tabBarItem = UITabBarItem(
    title: "Home",
    image: UIImage(systemName: "house.circle"),
    selectedImage: UIImage(systemName: "house.circle.fill")
)

let addVC = AddViewController()
addVC.tabBarItem = UITabBarItem(
    title: "Add",
    image: UIImage(systemName: "plus"),
    tag: 1
)

let settingsVC = SettingsViewController()
settingsVC.tabBarItem = UITabBarItem(
    tabBarSystemItem: .more,
    tag: 2
)

tabBarController.viewControllers = [homeVC, addVC, settingsVC]
window?.rootViewController = tabBarController

Each tab can independently wrap its own UINavigationController:

tabBarController.viewControllers = [
    UINavigationController(rootViewController: homeVC),
    UINavigationController(rootViewController: addVC),
    UINavigationController(rootViewController: settingsVC)
]

Segues

Segues are Storyboard-based transitions between view controllers. Though SecureRoom uses manual rootViewController swaps, segues are the most common approach in storyboard-heavy apps.

// Trigger a segue programmatically
performSegue(withIdentifier: "showDetail", sender: self)

// Prepare data before the segue fires (called automatically before every segue)
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if segue.identifier == "showDetail" {
        let destination = segue.destination as! DetailViewController
        destination.secret = selectedSecret  // pass data forward
    }
}

// Unwind segue — go back without data (define in destination VC)
@IBAction func unwindToHome(_ segue: UIStoryboardSegue) {
    // called when the segue unwinds to this VC
}

Segue types:

Type Behaviour
show Push on NavigationController stack, or modal on others
showDetail iPad: shows in detail pane; iPhone: push
present modally Slides up as a modal sheet
present as popover iPad: popover bubble; iPhone: modal
unwind Goes back through the hierarchy

Helpers Layer

The Helpers/ folder holds utility classes and constants that don't belong to any single screen. This is one of the key separations that keeps ViewControllers lean.


Constants.swift

// A file-scope global constant — accessible everywhere in the module without import
let passcodeKey: String = "passcode"

This is the Keychain key used to store and retrieve the passcode. Defining it as a global constant instead of a string literal prevents typos — if you mistype passcodeKy in Constants.swift, the compiler catches it immediately.

Best practice pattern — group related constants in an enum namespace:

// Better pattern for larger apps — prevents global namespace pollution
enum Keys {
    static let passcode = "passcode"
    static let biometricEnabled = "biometric_enabled"
    static let lastLockTime = "last_lock_time"
}

// Usage
KeychainManager.save(key: Keys.passcode, value: "1234")

KeychainManager.swift — iOS Keychain API

The Keychain is iOS's hardware-backed secure storage. Unlike UserDefaults (which stores data in plain text in a .plist file), Keychain data is encrypted at rest and protected by the device's Secure Enclave.

import Security
import Foundation

class KeychainManager {

    // ── SAVE ─────────────────────────────────────────────────────
    static func save(key: String, value: String) -> Bool {
        // Convert String to raw bytes
        let data = value.data(using: .utf8)!

        // Build the query dictionary — kSec constants are CFString keys from Security.framework
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,  // "generic password" item type
            kSecAttrAccount as String: key,                 // the key (like a dictionary key)
            kSecValueData as String: data,                  // the value (raw bytes)
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked
            // ↑ "accessible when device is unlocked" — most common security level
        ]

        // Delete any existing item first (update = delete + insert in Keychain API)
        SecItemDelete(query as CFDictionary)

        // Add the new item
        let status = SecItemAdd(query as CFDictionary, nil)
        return status == errSecSuccess
    }

    // ── READ ─────────────────────────────────────────────────────
    static func read(key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,         // return the raw data
            kSecMatchLimit as String: kSecMatchLimitOne  // return at most 1 result
        ]

        var dataTypeRef: AnyObject?
        // SecItemCopyMatching writes result into dataTypeRef via inout pointer
        let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)

        if status == errSecSuccess,
           let data = dataTypeRef as? Data,
           let string = String(data: data, encoding: .utf8) {
            return string
        }

        return nil  // key not found or decode failed
    }

    // ── DELETE ────────────────────────────────────────────────────
    static func delete(key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key
        ]
        SecItemDelete(query as CFDictionary)
    }
}

Keychain concepts in depth

Concept Explanation
Security framework Low-level Apple framework for cryptography, Keychain, certificates. Imported with import Security.
kSecClass / kSecClassGenericPassword Tells Keychain this is a generic password item (as opposed to an internet password, certificate, key, etc.).
kSecAttrAccount The "username" field — here repurposed as the key name.
kSecValueData The raw Data blob to store. Encrypted at rest by the OS.
kSecAttrAccessible Determines when the item is accessible. kSecAttrAccessibleWhenUnlocked = readable when device is unlocked; wiped on device restore without backup.
CFDictionary Core Foundation dictionary type. The Keychain API predates Swift, so it requires as CFDictionary casts.
SecItemAdd Inserts a new Keychain item. Returns OSStatus (an integer status code).
SecItemCopyMatching Queries Keychain and returns matching items. The &dataTypeRef is an inout UnsafeMutablePointer.
SecItemDelete Deletes matching Keychain items.
errSecSuccess The OSStatus value 0 — means the operation succeeded.
as String: kSecClass as String kSecClass is typed as CFString. Cast to String to use as a Swift dictionary key.

Keychain vs UserDefaults

Keychain UserDefaults
Storage Encrypted SQLite, Secure Enclave backed Plain .plist XML file
Security Hardware encrypted, survives app reinstall Cleared on app delete
Use for Passwords, tokens, sensitive data Settings, preferences, non-sensitive state
Access Complex C API (via Security framework) Simple set(_:forKey:) / string(forKey:)
iCloud sync Opt-in via kSecAttrSynchronizable Not available

Models Layer

Secret.swift

import Foundation

// A simple value type to represent a stored secret
struct SecretModel: Codable {
    let title: String   // e.g. "Gmail password"
    let value: String   // e.g. "hunter2"
}

This follows the same principles as SwiftUI models:

  • struct (value type) — safe to pass around and copy; no reference counting
  • Codable — can be encoded to JSON (Encodable) and decoded from JSON (Decodable)
  • No UIKit imports — fully portable, testable in isolation
  • Foundation only — the minimum necessary import

How this model would be used with Core Data:

In a production app, SecretModel would complement a Core Data Entity. The entity stores data on disk; the struct serves as a lightweight in-memory representation:

// Convert CoreData entity → Swift struct (the "DTO" pattern)
extension SecretEntity {
    func toModel() -> SecretModel {
        SecretModel(title: self.title ?? "", value: self.value ?? "")
    }
}

Assets.xcassets — UIKit Edition

The assets catalog works identically in UIKit and SwiftUI. In UIKit, you reference assets through UIImage and UIColor:

// Load image from asset catalog
let logo = UIImage(named: "Logo")
imageView.image = logo

// SF Symbol (system icon, no asset needed)
let icon = UIImage(systemName: "lock.shield")
imageView.image = icon

// Tinted SF Symbol (iOS 15+)
let tinted = UIImage(systemName: "lock.shield")?.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)

// Named colour from asset catalog
let brandColor = UIColor(named: "BrandBlue")
view.backgroundColor = brandColor

// System semantic colours (automatically adapt to Dark Mode)
view.backgroundColor = .systemBackground          // white in Light, dark in Dark
label.textColor = .label                          // black in Light, white in Dark
textField.backgroundColor = .systemGray6

The Logo image in SecureRoom is referenced in LockScreen.storyboard as an UIImageView:

<imageView contentMode="scaleAspectFit" image="Logo"
           translatesAutoresizingMaskIntoConstraints="NO">

At runtime, UIKit looks up "Logo" in the asset catalog and loads the correct @1x, @2x, or @3x variant based on the device's screen scale.


UIKit UI Components Glossary

Component UIKit Class Description
Text label UILabel Non-interactive text. Set .text, .font, .textColor, .numberOfLines.
Text input UITextField Single-line editable text. Set .placeholder, .borderStyle, .delegate.
Multi-line text UITextView Scrollable multi-line text area. Supports rich text.
Button UIButton Tappable element. buttonType: .system for text, .custom for images.
Image UIImageView Displays UIImage. Set .contentMode for scaling behaviour.
Container UIView The base class for all views. Can be used directly as a coloured box or container.
Stack UIStackView Horizontal or vertical layout of arranged subviews.
Scroll UIScrollView Scrollable content area. Basis for UITableView and UICollectionView.
List UITableView Vertical scrolling list with reusable cells. The UIKit workhorse.
Grid UICollectionView Flexible grid/custom layout. More powerful than UITableView.
Navigation bar UINavigationBar Top bar with title and back/action buttons. Managed by UINavigationController.
Tab bar UITabBar Bottom tab selection. Managed by UITabBarController.
Alert UIAlertController Modal alert or action sheet. Always presented with present().
Progress UIActivityIndicatorView Spinning loader. .startAnimating() / .stopAnimating().
Switch UISwitch Toggle on/off. .isOn property + .valueChanged action.
Slider UISlider Continuous value selector. .value + .valueChanged action.
Segmented UISegmentedControl Multi-option tab strip. .selectedSegmentIndex.
Picker UIPickerView Spinning wheel selector. Requires UIPickerViewDataSource + UIPickerViewDelegate.

UITextField & UITextFieldDelegate Deep Dive

UITextField is the primary single-line text input in UIKit. LockScreenViewController uses a rich set of its features to implement the 4-digit PIN pad.

Setup (viewDidLoad)

// Set the delegaterequired for shouldChangeCharactersIn
textField1.delegate = self

// Add a target-action for programmatic change notification
textField1.addTarget(self, action: #selector(textDidChange(_:)), for: .editingChanged)
// .editingChanged fires every time the text value changes
// .editingDidBegin fires when the field gains focus
// .editingDidEnd fires when focus leaves

// Make a field active (show keyboard)
textField1.becomeFirstResponder()

// Dismiss keyboard from a specific field
textField1.resignFirstResponder()

// Dismiss keyboard from any field in the view
view.endEditing(true)

UITextFieldDelegate Protocol Methods

Delegates are a core UIKit pattern: the UITextField calls methods on its delegate at specific points. The delegate (here LockScreenViewController) implements the protocol methods it cares about.

// Called BEFORE a character change is applied
// Return true to allow it, false to block it
func textField(
    _ textField: UITextField,
    shouldChangeCharactersIn range: NSRange,
    replacementString string: String
) -> Bool {
    if string.isEmpty {
        // string = "" means backspace
        // Move focus to previous field
        if textField == textField2 { textField1.becomeFirstResponder() }
        else if textField == textField3 { textField2.becomeFirstResponder() }
        else if textField == textField4 { textField3.becomeFirstResponder() }
        textField.text = ""
        return false  // block the default deletion
    }
    // Only allow one character: reject if field already has content
    return textField.text?.isEmpty ?? true
}

// Called when the Return key is tapped
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
    textField.resignFirstResponder()
    return true
}

// Called when field is about to become active
func textFieldShouldBeginEditing(_ textField: UITextField) -> Bool {
    return true  // return false to prevent editing
}

Target-Action for @objc methods

// The #selector mechanism — requires @objc for Objective-C runtime interop
@objc func textDidChange(_ textField: UITextField) {
    guard let text = textField.text, !text.isEmpty else { return }
    // auto-advance to next field
}

// Selector syntax: #selector(textDidChange(_:))
// The (_:) means the method takes one unlabelled argument

#selector is required for all target-action callbacks because UIKit is built on Objective-C runtime. The @objc attribute exposes the Swift method to Objective-C. Without it, the app crashes at runtime with an "unrecognised selector" exception.


Keyboard Management in UIKit

Unlike SwiftUI (which handles keyboard avoidance automatically), UIKit requires manual keyboard management.

Dismiss keyboard on background tap

// In viewDidLoad:
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
view.addGestureRecognizer(tapGesture)

@objc func dismissKeyboard() {
    view.endEditing(true)  // resigns first responder from any active text field
}

Scroll view up when keyboard appears (common pattern)

override func viewDidLoad() {
    super.viewDidLoad()

    // Observe keyboard notifications
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(keyboardWillShow(_:)),
        name: UIResponder.keyboardWillShowNotification,
        object: nil
    )
    NotificationCenter.default.addObserver(
        self,
        selector: #selector(keyboardWillHide),
        name: UIResponder.keyboardWillHideNotification,
        object: nil
    )
}

@objc func keyboardWillShow(_ notification: Notification) {
    guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
    let keyboardHeight = keyboardFrame.height
    scrollView.contentInset.bottom = keyboardHeight
}

@objc func keyboardWillHide() {
    scrollView.contentInset.bottom = 0
}

UIAlertController

UIAlertController is UIKit's system for modal alerts and action sheets. SecureRoom uses it to show error messages.

func showAlert(msg: String) {
    // preferredStyle: .alert → centred modal dialog
    // preferredStyle: .actionSheet → slides up from bottom (use for destructive actions)
    let alert = UIAlertController(title: "Error", message: msg, preferredStyle: .alert)

    let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
    // UIAlertAction styles:
    // .default → normal blue text
    // .destructive → red text (for delete/irreversible actions)
    // .cancel → bold text, always last

    alert.addAction(okAction)

    // present() is how ALL modal VCs are shown in UIKit
    present(alert, animated: true, completion: nil)
}

// Action with handler (confirmDelete example)
let deleteAction = UIAlertAction(title: "Delete", style: .destructive) { _ in
    self.deleteSecret()
}
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
alert.addAction(deleteAction)
alert.addAction(cancelAction)
present(alert, animated: true)

Data Flow: End-to-End

Here is the complete flow when a user enters their 4-digit passcode on launch:

1. [Launch]       SceneDelegate.scene(willConnectTo:) fires
                  → Creates UIWindow
                  → Loads LockScreen.storyboard
                  → Sets LockScreenViewController as window.rootViewController
                  → Calls window.makeKeyAndVisible()

2. [viewDidLoad]  LockScreenViewController.viewDidLoad() calledSets delegate and addTarget on all 4 text fields
                  → Adds tap gesture recogniser to dismiss keyboard
                  → textField1.becomeFirstResponder() → keyboard appears
                  → Reads Keychain: if no passcode"Welcome, create passcode"
                                    if passcode exists"Enter passcode to unlock"

3. [User types]   User enters "4" in textField1
                  → UITextField calls textField(_:shouldChangeCharactersIn:replacementString:)
                  → textField1.text is empty return true character inserted
                  → .editingChanged fires textDidChange(_:) called
                  → textField2.becomeFirstResponder() → keyboard focus moves to field 2

4. [User types]   Same for fields 2, 3, 4
                  → On field 4 entry: textDidChange calls verifyOtp() + dismissKeyboard()

5. [verifyOtp()]  Concatenates: otp = "\(tf1.text)\(tf2.text)\(tf3.text)\(tf4.text)"
                  → guard otp.count == 4 (always true at this point)
                  → KeychainManager.read(key: passcodeKey) → returns saved String?
                  → if savedPass == otp:
                       goToHome()
                    else:
                       showAlert(msg: "Incorrect passcode")
                       clearFields() → all fields .text = "" tf1.becomeFirstResponder()

6. [goToHome()]   Gets SceneDelegate via view.window?.windowScene?.delegate
                  → Loads HomeScreen.storyboard (UITabBarController)
                  → DispatchQueue.main.async:
                       sceneDelegate.window?.rootViewController = homeVc
                  → LockScreenViewController is deallocated
                  → UITabBarController with 3 tabs becomes visible

7. [Background]   User presses Home button
                  → SceneDelegate.sceneDidEnterBackground() fires
                  → (UIApplication.shared.delegate as? AppDelegate)?.saveContext()
                  → lockScreen() called:
                       Loads LockScreen.storyboard
                       DispatchQueue.main.async: window?.rootViewController = lockVc
                  → App is locked again

Key UIKit Concepts Glossary

Term Definition
UIViewController The base class for all screens. Manages a view hierarchy, responds to lifecycle events.
UIView The base class for all visual elements. Every button, label, image is a UIView subclass.
@IBOutlet A reference from Swift code to a UI element in the Storyboard. Connected via Interface Builder. Must be weak.
@IBAction A method in Swift that is called by a Storyboard UI event (button tap, etc.). Must be @objc accessible.
viewDidLoad() Called once after the view hierarchy is loaded from the Storyboard. First safe place to access @IBOutlets.
viewWillAppear() Called every time the view is about to become visible. Use for data refresh.
becomeFirstResponder() Makes a view the "first responder" — for text fields this shows the keyboard.
resignFirstResponder() Gives up first responder status — hides keyboard for text fields.
view.endEditing(true) Forces all subviews to resign first responder. The easiest way to dismiss keyboard.
@objc Exposes a Swift method to the Objective-C runtime. Required for #selector, IBAction, NSNotificationCenter.
#selector A compile-time checked reference to an Objective-C method. Used in addTarget(_:action:for:), addObserver.
DispatchQueue.main.async Schedules work on the main thread (required for all UIKit updates).
UIStoryboard(name:bundle:) Loads a compiled storyboard file. bundle: nil = main app bundle.
instantiateInitialViewController() Creates the Initial View Controller as configured in the storyboard.
window.rootViewController The topmost VC on screen. Replacing it swaps the entire UI instantly.
present(_:animated:) Shows a modal view controller on top of the current one. Used for alerts, sheets.
dismiss(animated:) Dismisses a modally presented view controller.
UIAlertController System modal alert or action sheet. Must be presented with present().
UITapGestureRecognizer Recognises a tap gesture on any view. Add with addGestureRecognizer().
delegate pattern Object A calls methods on object B (its delegate) at specific points. B implements a protocol. A has a weak var delegate.
NSLayoutConstraint A constraint between two view attributes defining their layout relationship.
translatesAutoresizingMaskIntoConstraints = false Must be set on code-created views before adding Auto Layout constraints.
safeAreaLayoutGuide The area of the screen not obscured by the notch, Dynamic Island, or Home indicator.
lazy var Property computed only when first accessed. Useful for expensive setup like NSPersistentContainer.
as? / as! Conditional (as?, safe) and forced (as!, crashes if wrong) type downcasting.
guard let Safely unwraps an optional; the else block must exit scope. The idiomatic Swift safety check.

UIKit vs SwiftUI — Side-by-Side

Since you've now seen both projects, here is a direct comparison of the same concepts:

Task UIKit (SecureRoom) SwiftUI (FinSightAI)
Entry point @main class AppDelegate @main struct FinSightAIApp: App
Root window SceneDelegate sets window.rootViewController WindowGroup { ... } in App.body
Define a screen class MyViewController: UIViewController struct MyView: View
UI layout Storyboard XML / addSubview + Auto Layout VStack, HStack, ZStack in body
Connect UI to code @IBOutlet + @IBAction $binding, .onChange(), Button(action:)
State var label: UILabel! + label.text = "..." @State var text = "" → auto-renders
Show keyboard textField.becomeFirstResponder() @FocusState + .focused($field)
Navigate forward pushViewController / window.rootViewController = NavigationStack + route.push(.home)
Pass data back delegate protocol @Binding
Show alert UIAlertController + present() .alert(isPresented:) modifier
App lifecycle AppDelegate + SceneDelegate @UIApplicationDelegateAdaptor + scene phase
Async work DispatchQueue.main.async { } @MainActor + Task { } + await
Secure storage Security framework directly Same — no SwiftUI equivalent
Local database Core Data NSPersistentContainer Same — no SwiftUI equivalent

Summary

SecureRoom demonstrates that UIKit, while more verbose than SwiftUI, gives you complete, explicit control over every aspect of your app. Here is what each layer contributes:

Layer Files Responsibility
App Lifecycle AppDelegate, SceneDelegate App-wide events, window management, lock-on-background, Core Data save
Controllers LockScreenViewController, HomeViewController Receive user events via @IBAction, update UI via @IBOutlet, orchestrate navigation
Storyboards LockScreen.storyboard, HomeScreen.storyboard Visual UI layout, Auto Layout constraints, IBOutlet/IBAction connections
Helpers KeychainManager, Constants Reusable utility logic. Keychain wraps the raw C Security API. Constants prevent string literal duplication.
Models Secret.swift Pure data structs. No UIKit. Fully portable and testable.
Assets Assets.xcassets Images, colours, app icon. Accessed via UIImage(named:) and UIColor(named:).

The most important UIKit principle is explicit control flow: every state change, every UI update, every navigation transition is written by you. This is more code than SwiftUI, but it also means behaviour is entirely predictable — there is no magic re-rendering engine to reason about.


Based on the open-source SecureRoom project by Lokesh Jangid.

Post a Comment

Previous Post Next Post

Subscribe Us


Get tutorials, Flutter news and other exclusive content delivered to your inbox. Join 1000+ growth-oriented Flutter developers subscribed to the newsletter

100% value, 0% spam. Unsubscribe anytime