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 (
Securityframework) - Local Persistence: Core Data (
NSPersistentContainer) - Navigation:
UIWindow.rootViewControllerswaps +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.swift ← 4-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>
<viewController ← maps 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.
Navigation in UIKit
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 countingCodable— can be encoded to JSON (Encodable) and decoded from JSON (Decodable)- No UIKit imports — fully portable, testable in isolation
Foundationonly — 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 delegate — required 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() called
→ Sets 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.