iOS Development
Structured guidance for building native iOS applications with Swift, SwiftUI, UIKit, and modern Apple platform patterns. Covers project setup, declarative UI, navigation, UIKit interop, architecture, data persistence, networking, Apple framework integration, and testing strategies for production iOS applications.
When to Use This Skill
Use this skill for:
- Setting up a new iOS project with proper Xcode configuration and Swift Package Manager dependencies
- Building declarative user interfaces with SwiftUI views, modifiers, and state management
- Implementing navigation with NavigationStack, sheets, alerts, and deep linking
- Working with UIKit view controllers, table views, collection views, and Auto Layout
- Designing MVVM architecture with @Observable, coordinators, and dependency injection
- Persisting data with SwiftData, Core Data, UserDefaults, or Keychain
- Building async/await networking layers with URLSession and Codable serialization
- Integrating Apple frameworks such as HealthKit, MapKit, StoreKit 2, or App Intents
- Writing unit tests, UI tests, and snapshot tests for iOS applications
Trigger phrases: "iOS app", "SwiftUI", "UIKit", "Swift Package Manager", "MVVM iOS", "SwiftData", "Core Data", "HealthKit", "MapKit", "StoreKit", "App Intents", "XCTest", "XCUITest", "NavigationStack", "async await networking", "Codable", "@Observable", "@State", "@Binding", "UICollectionView", "diffable data source", "Keychain", "push notification", "background task"
What This Skill Does
Provides iOS development patterns including:
- Project Setup: Xcode project configuration, Swift Package Manager, module organization, build settings, Info.plist
- SwiftUI Fundamentals: Views, modifiers, property wrappers, @State/@Binding/@Observable, previews
- Layouts and Navigation: VStack/HStack/ZStack, LazyStacks, NavigationStack, sheets, alerts, deep linking
- UIKit Patterns: View controller lifecycle, diffable data sources, Auto Layout, UIKit-SwiftUI interop
- Architecture: MVVM with @Observable, Coordinator pattern, dependency injection, Repository pattern
- Data and Networking: SwiftData, Core Data, Keychain, URLSession async/await, Codable
- Apple Frameworks: Notifications, background tasks, HealthKit, MapKit, StoreKit 2, App Intents
- Testing: XCTest, Swift Testing, XCUITest, snapshot testing, async test patterns, protocol-based mocking
Instructions
Step 1: Project Structure and Configuration
A well-organized iOS project separates features into modules, configures build settings for each environment, and manages dependencies through Swift Package Manager.
Recommended Project Structure:
MyApp/
MyApp.xcodeproj
MyApp/
App/
MyAppApp.swift -- @main entry point
AppDelegate.swift -- UIKit lifecycle hooks (if needed)
Info.plist
Features/
Home/
HomeView.swift
HomeViewModel.swift
Settings/
SettingsView.swift
SettingsViewModel.swift
Core/
Networking/
APIClient.swift
Endpoint.swift
Persistence/
DataStore.swift
Models/
User.swift
Transaction.swift
SharedUI/
Components/
PrimaryButton.swift
LoadingOverlay.swift
Modifiers/
ShimmerModifier.swift
Resources/
Assets.xcassets
Localizable.xcstrings
MyAppTests/
Features/
HomeViewModelTests.swift
Core/
APIClientTests.swift
Helpers/
TestFixtures.swift
MyAppUITests/
HomeFlowTests.swift
SettingsFlowTests.swift
Packages/
MyAppKit/ -- local Swift package for shared logic
Package.swift
Sources/MyAppKit/
Tests/MyAppKitTests/
App Entry Point (SwiftUI lifecycle):
import SwiftUI
import SwiftData
@main
struct MyAppApp: App {
private let container: ModelContainer
init() {
do {
let schema = Schema([User.self, Transaction.self])
let configuration = ModelConfiguration(
"MyApp",
schema: schema,
isStoredInMemoryOnly: false
)
container = try ModelContainer(for: schema, configurations: [configuration])
} catch {
fatalError("Failed to create ModelContainer: \(error)")
}
}
var body: some Scene {
WindowGroup {
ContentView()
.modelContainer(container)
}
}
}
Swift Package Manager Configuration (local package):
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "MyAppKit",
platforms: [.iOS(.v17)],
products: [
.library(name: "MyAppKit", targets: ["MyAppKit"]),
],
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.0"),
],
targets: [
.target(
name: "MyAppKit",
dependencies: [
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "Algorithms", package: "swift-algorithms"),
]
),
.testTarget(
name: "MyAppKitTests",
dependencies: ["MyAppKit"]
),
]
)
Build Configuration with xcconfig Files:
// Shared.xcconfig
IPHONEOS_DEPLOYMENT_TARGET = 17.0
SWIFT_VERSION = 6.0
SWIFT_STRICT_CONCURRENCY = complete
ENABLE_USER_SCRIPT_SANDBOXING = YES
// Debug.xcconfig
#include "Shared.xcconfig"
SWIFT_OPTIMIZATION_LEVEL = -Onone
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG
OTHER_SWIFT_FLAGS = -warn-concurrency
// Release.xcconfig
#include "Shared.xcconfig"
SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_COMPILATION_MODE = wholemodule
ENABLE_TESTABILITY = NO
Info.plist Essentials:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
</dict>
<key>NSCameraUsageDescription</key>
<string>We need camera access to scan documents.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to show nearby results.</string>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>remote-notification</string>
</array>
Key Project Setup Principles:
- Set the deployment target to iOS 17+ to use @Observable and SwiftData without backward-compatibility shims
- Enable Swift 6 strict concurrency checking (
SWIFT_STRICT_CONCURRENCY = complete) from day one to catch data races at compile time - Use local Swift packages to extract shared logic into testable modules with explicit dependency boundaries
- Keep the main app target thin: it should wire together feature modules but contain minimal logic itself
- Configure separate xcconfig files for Debug and Release to avoid conditional compilation scattered through code
Step 2: SwiftUI Fundamentals
SwiftUI uses a declarative syntax where views are lightweight value types that describe the desired UI state. The framework diffs the view hierarchy and updates only what changed.
View Composition and Modifiers:
import SwiftUI
struct TransactionRow: View {
let transaction: Transaction
@Environment(\.dynamicTypeSize) private var typeSize
var body: some View {
HStack(spacing: 12) {
Image(systemName: transaction.category.iconName)
.font(.title2)
.foregroundStyle(transaction.category.color)
.frame(width: 40, height: 40)
.background(transaction.category.color.opacity(0.12))
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(transaction.merchantName)
.font(.body)
.fontWeight(.medium)
.lineLimit(1)
Text(transaction.date.formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(transaction.amount, format: .currency(code: transaction.currencyCode))
.font(.body.monospacedDigit())
.foregroundStyle(transaction.amount < 0 ? .primary : .green)
}
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(transaction.merchantName), \(transaction.amount.formatted(.currency(code: transaction.currencyCode))), \(transaction.date.formatted(date: .abbreviated, time: .shortened))")
}
}
State Management with @State, @Binding, and @Observable:
import SwiftUI
import Observation
@Observable
final class AuthViewModel {
var email = ""
var password = ""
var isLoading = false
var errorMessage: String?
private let authService: AuthServiceProtocol
init(authService: AuthServiceProtocol) {
self.authService = authService
}
func signIn() async {
guard !email.isEmpty, !password.isEmpty else {
errorMessage = "Email and password are required."
return
}
isLoading = true
errorMessage = nil
do {
try await authService.signIn(email: email, password: password)
} catch let error as AuthError {
errorMessage = error.userFacingMessage
} catch {
errorMessage = "An unexpected error occurred. Please try again."
}
isLoading = false
}
}
struct SignInView: View {
@State private var viewModel: AuthViewModel
init(authService: AuthServiceProtocol) {
_viewModel = State(initialValue: AuthViewModel(authService: authService))
}
var body: some View {
Form {
Section {
TextField("Email", text: $viewModel.email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
SecureField("Password", text: $viewModel.password)
.textContentType(.password)
}
if let errorMessage = viewModel.errorMessage {
Section {
Label(errorMessage, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
Section {
Button {
Task { await viewModel.signIn() }
} label: {
if viewModel.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text("Sign In")
.frame(maxWidth: .infinity)
}
}
.disabled(viewModel.isLoading)
}
}
.navigationTitle("Sign In")
}
}
Custom Property Wrapper for UserDefaults:
import SwiftUI
@propertyWrapper
struct AppStorage<Value: Codable> {
private let key: String
private let defaultValue: Value
private let store: UserDefaults
init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) {
self.key = key
self.defaultValue = wrappedValue
self.store = store
}
var wrappedValue: Value {
get {
guard let data = store.data(forKey: key),
let decoded = try? JSONDecoder().decode(Value.self, from: data) else {
return defaultValue
}
return decoded
}
set {
if let data = try? JSONEncoder().encode(newValue) {
store.set(data, forKey: key)
}
}
}
}
SwiftUI Previews with Sample Data:
#Preview("Transaction Row - Expense") {
List {
TransactionRow(transaction: .preview(
merchantName: "Whole Foods Market",
amount: -87.32,
category: .groceries
))
TransactionRow(transaction: .preview(
merchantName: "Monthly Salary",
amount: 5200.00,
category: .income
))
}
}
extension Transaction {
static func preview(
merchantName: String = "Preview Merchant",
amount: Double = -25.00,
category: TransactionCategory = .general,
currencyCode: String = "USD"
) -> Transaction {
Transaction(
id: UUID(),
merchantName: merchantName,
amount: Decimal(amount),
currencyCode: currencyCode,
category: category,
date: .now
)
}
}
Key SwiftUI Principles:
- Use
@Observable(iOS 17+) instead ofObservableObject/@Publishedfor simpler, more efficient observation with fine-grained tracking - Keep views small and composable. Extract subviews when a view exceeds 40 lines or when a section is reused
- Always provide accessibility labels for non-text elements and use
.accessibilityElement(children: .combine)for composite rows - Use
@Statefor view-local state,@Bindingfor child-to-parent communication, and@Environmentfor shared values - Prefer the
format:parameter onTextfor locale-aware formatting of numbers, dates, and currencies
Step 3: SwiftUI Layouts and Navigation
SwiftUI provides stack-based layouts for composition, lazy containers for performance with large data sets, and NavigationStack for type-safe, path-based navigation.
Stack-Based Layouts:
import SwiftUI
struct DashboardView: View {
let accounts: [Account]
let recentTransactions: [Transaction]
var body: some View {
ScrollView {
VStack(spacing: 20) {
// Horizontal scrolling account cards
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16) {
ForEach(accounts) { account in
AccountCard(account: account)
.containerRelativeFrame(
.horizontal,
count: 1,
spacing: 16
)
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.contentMargins(.horizontal, 20)
// Recent transactions list
LazyVStack(alignment: .leading, spacing: 0) {
Section {
ForEach(recentTransactions) { transaction in
TransactionRow(transaction: transaction)
if transaction.id != recentTransactions.last?.id {
Divider()
.padding(.leading, 52)
}
}
} header: {
Text("Recent Transactions")
.font(.headline)
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
}
}
.padding(.vertical)
}
}
}
struct AccountCard: View {
let account: Account
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(account.name)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Image(systemName: account.type.iconName)
.foregroundStyle(.secondary)
}
Text(account.balance, format: .currency(code: account.currencyCode))
.font(.title.bold().monospacedDigit())
Text("Updated \(account.lastUpdated, format: .relative(presentation: .named))")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
}
}
NavigationStack with Type-Safe Path-Based Routing:
import SwiftUI
enum AppRoute: Hashable {
case transactionDetail(Transaction.ID)
case accountDetail(Account.ID)
case settings
case profile
}
@Observable
final class Router {
var path = NavigationPath()
func navigate(to route: AppRoute) {
path.append(route)
}
func popToRoot() {
path = NavigationPath()
}
func pop() {
guard !path.isEmpty else { return }
path.removeLast()
}
}
struct ContentView: View {
@State private var router = Router()
@State private var selectedTab: Tab = .home
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .transactionDetail(let id):
TransactionDetailView(transactionID: id)
case .accountDetail(let id):
AccountDetailView(accountID: id)
case .settings:
SettingsView()
case .profile:
ProfileView()
}
}
}
.tabItem { Label("Home", systemImage: "house") }
.tag(Tab.home)
NavigationStack {
SearchView()
}
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(Tab.search)
}
.environment(router)
}
}
Sheets, Alerts, and Confirmations:
import SwiftUI
struct TransactionDetailView: View {
let transactionID: Transaction.ID
@State private var showDeleteConfirmation = false
@State private var showEditSheet = false
@State private var alertItem: AlertItem?
var body: some View {
List {
// Transaction detail sections...
}
.navigationTitle("Transaction")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button("Edit", systemImage: "pencil") {
showEditSheet = true
}
Button("Delete", systemImage: "trash", role: .destructive) {
showDeleteConfirmation = true
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $showEditSheet) {
EditTransactionView(transactionID: transactionID)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.confirmationDialog(
"Delete Transaction",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
Task { await deleteTransaction() }
}
} message: {
Text("This action cannot be undone.")
}
.alert(item: $alertItem) { item in
Alert(
title: Text(item.title),
message: Text(item.message),
dismissButton: .default(Text("OK"))
)
}
}
private func deleteTransaction() async {
// deletion logic
}
}
struct AlertItem: Identifiable {
let id = UUID()
let title: String
let message: String
}
Key Layout and Navigation Principles:
- Use
LazyVStackandLazyHStackfor lists with more than a few dozen items. Lazy stacks create views on demand as they scroll into the viewport - Use
NavigationStackwithNavigationPathfor programmatic, type-safe navigation. Avoid the deprecatedNavigationView - Centralize routing logic in a
Routerobject injected via@Environmentso that any view can trigger navigation without passing closures through the hierarchy - Use
.presentationDetentson sheets to control their height. Half-height sheets (.medium) work well for quick forms - Prefer
confirmationDialogoveralertfor destructive actions because it presents as an action sheet on iPhone
Step 4: UIKit Patterns
UIKit remains essential for complex custom layouts, advanced collection view compositions, and brownfield projects. Understanding view controller lifecycle, modern diffable data sources, and UIKit-SwiftUI interop is critical.
View Controller Lifecycle:
import UIKit
final class TransactionsViewController: UIViewController {
private let viewModel: TransactionsViewModel
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Transaction.ID>!
enum Section: Int, CaseIterable {
case pending
case completed
}
init(viewModel: TransactionsViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
override func viewDidLoad() {
super.viewDidLoad()
title = "Transactions"
configureCollectionView()
configureDataSource()
bindViewModel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { await viewModel.loadTransactions() }
}
// MARK: - Collection View Setup
private func configureCollectionView() {
var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
configuration.headerMode = .supplementary
configuration.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
self?.trailingSwipeActions(for: indexPath)
}
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
private func trailingSwipeActions(for indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let transactionID = dataSource.itemIdentifier(for: indexPath) else {
return nil
}
let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completion in
Task {
await self?.viewModel.deleteTransaction(id: transactionID)
completion(true)
}
}
return UISwipeActionsConfiguration(actions: [deleteAction])
}
}
Diffable Data Source with Cell Registration:
extension TransactionsViewController {
private func configureDataSource() {
let cellRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Transaction.ID> {
[weak self] cell, indexPath, transactionID in
guard let transaction = self?.viewModel.transaction(for: transactionID) else { return }
var content = cell.defaultContentConfiguration()
content.text = transaction.merchantName
content.secondaryText = transaction.amount.formatted(
.currency(code: transaction.currencyCode)
)
content.image = UIImage(systemName: transaction.category.iconName)
content.imageProperties.tintColor = UIColor(transaction.category.color)
cell.contentConfiguration = content
cell.accessories = [.disclosureIndicator()]
}
let headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(
elementKind: UICollectionView.elementKindSectionHeader
) { [weak self] headerView, _, indexPath in
guard let section = Section(rawValue: indexPath.section) else { return }
var content = headerView.defaultContentConfiguration()
content.text = section == .pending ? "Pending" : "Completed"
headerView.contentConfiguration = content
}
dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) {
collectionView, indexPath, transactionID in
collectionView.dequeueConfiguredReusableCell(
using: cellRegistration, for: indexPath, item: transactionID
)
}
dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in
collectionView.dequeueConfiguredReusableSupplementary(
using: headerRegistration, for: indexPath
)
}
}
private func bindViewModel() {
viewModel.onTransactionsChanged = { [weak self] pending, completed in
guard let self else { return }
var snapshot = NSDiffableDataSourceSnapshot<Section, Transaction.ID>()
snapshot.appendSections(Section.allCases)
snapshot.appendItems(pending.map(\.id), toSection: .pending)
snapshot.appendItems(completed.map(\.id), toSection: .completed)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
UIKit-SwiftUI Interop with UIHostingController:
import SwiftUI
import UIKit
// Embedding SwiftUI in UIKit
final class SettingsHostingController: UIHostingController<SettingsView> {
init(viewModel: SettingsViewModel) {
let settingsView = SettingsView(viewModel: viewModel)
super.init(rootView: settingsView)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported")
}
}
// Embedding UIKit in SwiftUI
struct MapViewRepresentable: UIViewRepresentable {
let region: MKCoordinateRegion
let annotations: [MKAnnotation]
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
mapView.setRegion(region, animated: true)
mapView.removeAnnotations(mapView.annotations)
mapView.addAnnotations(annotations)
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
final class Coordinator: NSObject, MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
let identifier = "CustomPin"
let view = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
view.annotation = annotation
return view
}
}
}
Key UIKit Principles:
- Mark
init(coder:)as@available(*, unavailable)on programmatic view controllers to prevent accidental storyboard instantiation - Use
UICollectionViewDiffableDataSourceinstead of the olderUITableViewDataSourcedelegate pattern. Diffable data sources eliminate index-out-of-bounds crashes and provide smooth animations - Use compositional layout (
UICollectionViewCompositionalLayout) for all new collection views. It handles complex grid, list, and waterfall layouts without customUICollectionViewFlowLayoutsubclasses - Bridge UIKit views into SwiftUI with
UIViewRepresentableand SwiftUI views into UIKit withUIHostingController. Always implement the coordinator pattern for delegate callbacks
Step 5: Architecture Patterns
A clean architecture separates UI, business logic, and data access into distinct layers. On iOS, MVVM with @Observable provides the best balance of testability and SwiftUI integration.
MVVM with @Observable:
import Foundation
import Observation
// MARK: - Protocol for dependency injection and testing
protocol TransactionRepositoryProtocol: Sendable {
func fetchTransactions(for accountID: Account.ID) async throws -> [Transaction]
func deleteTransaction(id: Transaction.ID) async throws
}
// MARK: - ViewModel
@Observable
@MainActor
final class TransactionListViewModel {
private(set) var transactions: [Transaction] = []
private(set) var isLoading = false
private(set) var error: AppError?
private let repository: TransactionRepositoryProtocol
private let accountID: Account.ID
init(accountID: Account.ID, repository: TransactionRepositoryProtocol) {
self.accountID = accountID
self.repository = repository
}
func loadTransactions() async {
isLoading = true
error = nil
do {
transactions = try await repository.fetchTransactions(for: accountID)
} catch {
self.error = AppError(underlying: error)
}
isLoading = false
}
func deleteTransaction(at offsets: IndexSet) async {
let idsToDelete = offsets.map { transactions[$0].id }
// Optimistic UI update
var removedTransactions: [(Int, Transaction)] = []
for offset in offsets.sorted().reversed() {
removedTransactions.append((offset, transactions[offset]))
transactions.remove(at: offset)
}
do {
for id in idsToDelete {
try await repository.deleteTransaction(id: id)
}
} catch {
// Rollback on failure
for (index, transaction) in removedTransactions.reversed() {
transactions.insert(transaction, at: index)
}
self.error = AppError(underlying: error)
}
}
}
// MARK: - View
struct TransactionListView: View {
@State private var viewModel: TransactionListViewModel
init(accountID: Account.ID, repository: TransactionRepositoryProtocol) {
_viewModel = State(initialValue: TransactionListViewModel(
accountID: accountID,
repository: repository
))
}
var body: some View {
Group {
if viewModel.isLoading && viewModel.transactions.isEmpty {
ProgressView("Loading transactions...")
} else if let error = viewModel.error, viewModel.transactions.isEmpty {
ContentUnavailableView {
Label("Unable to Load", systemImage: "exclamationmark.triangle")
} description: {
Text(error.userMessage)
} actions: {
Button("Retry") {
Task { await viewModel.loadTransactions() }
}
}
} else {
List {
ForEach(viewModel.transactions) { transaction in
TransactionRow(transaction: transaction)
}
.onDelete { offsets in
Task { await viewModel.deleteTransaction(at: offsets) }
}
}
.refreshable {
await viewModel.loadTransactions()
}
}
}
.task { await viewModel.loadTransactions() }
.navigationTitle("Transactions")
}
}
Coordinator Pattern for Navigation:
import UIKit
protocol Coordinator: AnyObject {
var childCoordinators: [any Coordinator] { get set }
var navigationController: UINavigationController { get }
func start()
}
final class AppCoordinator: Coordinator {
var childCoordinators: [any Coordinator] = []
let navigationController: UINavigationController
private let dependencyContainer: DependencyContainer
init(navigationController: UINavigationController, dependencyContainer: DependencyContainer) {
self.navigationController = navigationController
self.dependencyContainer = dependencyContainer
}
func start() {
let homeCoordinator = HomeCoordinator(
navigationController: navigationController,
dependencyContainer: dependencyContainer
)
homeCoordinator.delegate = self
childCoordinators.append(homeCoordinator)
homeCoordinator.start()
}
}
extension AppCoordinator: HomeCoordinatorDelegate {
func homeCoordinatorDidRequestTransactionDetail(_ coordinator: HomeCoordinator, transactionID: Transaction.ID) {
let detailCoordinator = TransactionDetailCoordinator(
navigationController: navigationController,
transactionID: transactionID,
dependencyContainer: dependencyContainer
)
childCoordinators.append(detailCoordinator)
detailCoordinator.start()
}
}
Dependency Container:
import Foundation
@MainActor
final class DependencyContainer: Sendable {
private let apiClient: APIClient
private let modelContainer: ModelContainer
init(apiClient: APIClient, modelContainer: ModelContainer) {
self.apiClient = apiClient
self.modelContainer = modelContainer
}
func makeTransactionRepository() -> TransactionRepositoryProtocol {
TransactionRepository(apiClient: apiClient, modelContainer: modelContainer)
}
func makeAuthService() -> AuthServiceProtocol {
AuthService(apiClient: apiClient)
}
func makeTransactionListViewModel(accountID: Account.ID) -> TransactionListViewModel {
TransactionListViewModel(
accountID: accountID,
repository: makeTransactionRepository()
)
}
}
Key Architecture Principles:
- Define protocols for all services and repositories. View models depend on protocols, not concrete types, enabling test doubles
- Mark view models
@MainActorand@Observable. The@MainActorannotation guarantees all property updates happen on the main thread, which SwiftUI requires - Use optimistic UI updates for delete and toggle operations, rolling back if the server call fails
- Use the
.taskmodifier to kick off async work when a view appears. SwiftUI cancels the task automatically when the view disappears - The Coordinator pattern is most valuable in UIKit-heavy apps. In pure SwiftUI apps, the Router pattern from Step 3 serves the same purpose with less boilerplate
Step 6: Data Persistence and Networking
iOS apps typically need local persistence for offline support and caching, secure storage for credentials, and a networking layer for API communication.
SwiftData Model and CRUD Operations:
import Foundation
import SwiftData
@Model
final class Transaction {
@Attribute(.unique) var id: UUID
var merchantName: String
var amount: Decimal
var currencyCode: String
var category: TransactionCategory
var date: Date
var note: String?
@Relationship(deleteRule: .nullify, inverse: \Account.transactions)
var account: Account?
init(
id: UUID = UUID(),
merchantName: String,
amount: Decimal,
currencyCode: String,
category: TransactionCategory,
date: Date,
note: String? = nil
) {
self.id = id
self.merchantName = merchantName
self.amount = amount
self.currencyCode = currencyCode
self.category = category
self.date = date
self.note = note
}
}
@Model
final class Account {
@Attribute(.unique) var id: UUID
var name: String
var balance: Decimal
var currencyCode: String
var lastUpdated: Date
@Relationship(deleteRule: .cascade)
var transactions: [Transaction] = []
init(id: UUID = UUID(), name: String, balance: Decimal, currencyCode: String) {
self.id = id
self.name = name
self.balance = balance
self.currencyCode = currencyCode
self.lastUpdated = .now
}
}
// SwiftData queries in SwiftUI
struct TransactionListSwiftDataView: View {
@Query(
filter: #Predicate<Transaction> { $0.amount < 0 },
sort: \Transaction.date,
order: .reverse
)
private var expenses: [Transaction]
@Environment(\.modelContext) private var modelContext
var body: some View {
List {
ForEach(expenses) { transaction in
TransactionRow(transaction: transaction)
}
.onDelete(perform: deleteTransactions)
}
}
private func deleteTransactions(at offsets: IndexSet) {
for index in offsets {
modelContext.delete(expenses[index])
}
}
}
Keychain Wrapper for Secure Storage:
import Foundation
import Security
enum KeychainError: Error {
case duplicateItem
case itemNotFound
case unexpectedStatus(OSStatus)
case invalidData
}
struct KeychainManager {
static func save(_ data: Data, for key: String, accessGroup: String? = nil) throws {
var query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecValueData: data,
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
if let accessGroup {
query[kSecAttrAccessGroup] = accessGroup
}
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecDuplicateItem {
let updateQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
]
let updateAttributes: [CFString: Any] = [kSecValueData: data]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
guard updateStatus == errSecSuccess else {
throw KeychainError.unexpectedStatus(updateStatus)
}
} else if status != errSecSuccess {
throw KeychainError.unexpectedStatus(status)
}
}
static func load(for key: String) throws -> Data {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
if status == errSecItemNotFound {
throw KeychainError.itemNotFound
}
throw KeychainError.unexpectedStatus(status)
}
guard let data = result as? Data else {
throw KeychainError.invalidData
}
return data
}
static func delete(for key: String) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
}
}
Async/Await Networking Layer:
import Foundation
enum HTTPMethod: String {
case get = "GET"
case post = "POST"
case put = "PUT"
case delete = "DELETE"
}
struct Endpoint {
let path: String
…(truncated)