| name | ios-development |
| description | iOS native development expertise with Swift, SwiftUI, UIKit, and modern Apple platform patterns. Use when building iOS applications, designing UI with SwiftUI, implementing MVVM architecture, or integrating Apple frameworks. Use when this capability is needed. |
| metadata | {"author":"bendourthe"} |
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 of ObservableObject/@Published for 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
@State for view-local state, @Binding for child-to-parent communication, and @Environment for shared values
- Prefer the
format: parameter on Text for 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
LazyVStack and LazyHStack for lists with more than a few dozen items. Lazy stacks create views on demand as they scroll into the viewport
- Use
NavigationStack with NavigationPath for programmatic, type-safe navigation. Avoid the deprecated NavigationView
- Centralize routing logic in a
Router object injected via @Environment so that any view can trigger navigation without passing closures through the hierarchy
- Use
.presentationDetents on sheets to control their height. Half-height sheets (.medium) work well for quick forms
- Prefer
confirmationDialog over alert for 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
UICollectionViewDiffableDataSource instead of the older UITableViewDataSource delegate 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 custom UICollectionViewFlowLayout subclasses
- Bridge UIKit views into SwiftUI with
UIViewRepresentable and SwiftUI views into UIKit with UIHostingController. 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
@MainActor and @Observable. The @MainActor annotation 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
.task modifier 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
let method: HTTPMethod
let queryItems: [URLQueryItem]?
let body: (any Encodable)?
let headers: [String: String]
init(
path: String,
method: HTTPMethod = .get,
queryItems: [URLQueryItem]? = nil,
body: (any Encodable)? = nil,
headers: [String: String] = [:]
) {
self.path = path
self.method = method
self.queryItems = queryItems
self.body = body
self.headers = headers
}
}
enum APIError: Error, LocalizedError {
case invalidURL
case httpError(statusCode: Int, data: Data)
case decodingError(DecodingError)
case networkError(URLError)
case unauthorized
case serverError(statusCode: Int)
var errorDescription: String? {
switch self {
case .invalidURL:
return "Invalid request URL."
case .httpError(let code, _):
return "Server returned status \(code)."
case .decodingError:
return "Failed to parse server response."
case .networkError(let urlError):
return urlError.localizedDescription
case .unauthorized:
return "Your session has expired. Please sign in again."
case .serverError(let code):
return "Server error (\(code)). Please try again later."
}
}
}
actor APIClient {
private let baseURL: URL
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
private let tokenProvider: TokenProviderProtocol
init(
baseURL: URL,
session: URLSession = .shared,
tokenProvider: TokenProviderProtocol
) {
self.baseURL = baseURL
self.session = session
self.tokenProvider = tokenProvider
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder = decoder
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.keyEncodingStrategy = .convertToSnakeCase
self.encoder = encoder
}
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
var components = URLComponents(url: baseURL.appending(path: endpoint.path), resolvingAgainstBaseURL: false)
components?.queryItems = endpoint.queryItems
guard let url = components?.url else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let token = try await tokenProvider.currentToken()
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
for (key, value) in endpoint.headers {
request.setValue(value, forHTTPHeaderField: key)
}
if let body = endpoint.body {
request.httpBody = try encoder.encode(body)
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(for: request)
} catch let error as URLError {
throw APIError.networkError(error)
}
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidURL
}
switch httpResponse.statusCode {
case 200..<300:
break
case 401:
throw APIError.unauthorized
case 500..<600:
throw APIError.serverError(statusCode: httpResponse.statusCode)
default:
throw APIError.httpError(statusCode: httpResponse.statusCode, data: data)
}
do {
return try decoder.decode(T.self, from: data)
} catch let error as DecodingError {
throw APIError.decodingError(error)
}
}
}
Repository Pattern Combining Remote and Local Data:
import Foundation
import SwiftData
struct TransactionRepository: TransactionRepositoryProtocol {
private let apiClient: APIClient
private let modelContainer: ModelContainer
init(apiClient: APIClient, modelContainer: ModelContainer) {
self.apiClient = apiClient
self.modelContainer = modelContainer
}
func fetchTransactions(for accountID: Account.ID) async throws -> [Transaction] {
// Try network first, fall back to cache
do {
let response: TransactionsResponse = try await apiClient.request(
Endpoint(path: "/accounts/\(accountID)/transactions")
)
try await cacheTransactions(response.transactions, for: accountID)
return response.transactions
} catch is APIError {
return try await loadCachedTransactions(for: accountID)
}
}
@ModelActor
private actor DataStoreActor {
func cacheTransactions(_ transactions: [Transaction], for accountID: Account.ID) throws {
for transaction in transactions {
modelContext.insert(transaction)
}
try modelContext.save()
}
func loadCachedTransactions(for accountID: Account.ID) throws -> [Transaction] {
let descriptor = FetchDescriptor<Transaction>(
predicate: #Predicate { $0.account?.id == accountID },
sortBy: [SortDescriptor(\.date, order: .reverse)]
)
return try modelContext.fetch(descriptor)
}
}
private func cacheTransactions(_ transactions: [Transaction], for accountID: Account.ID) async throws {
let actor = DataStoreActor(modelContainer: modelContainer)
try await actor.cacheTransactions(transactions, for: accountID)
}
private func loadCachedTransactions(for accountID: Account.ID) async throws -> [Transaction] {
let actor = DataStoreActor(modelContainer: modelContainer)
return try await actor.loadCachedTransactions(for: accountID)
}
}
Key Persistence and Networking Principles:
- Use SwiftData for structured local persistence. It integrates with SwiftUI via
@Query and @Model, providing automatic change tracking
- Store authentication tokens and sensitive credentials in the Keychain, never in UserDefaults or plain files
- Make the
APIClient an actor to ensure thread-safe access to mutable state such as token refresh logic
- Use the Repository pattern to abstract whether data comes from the network or local cache. Views and view models never call the API client directly
- Configure
JSONDecoder with .convertFromSnakeCase and JSONEncoder with .convertToSnakeCase once at the client level to avoid per-endpoint boilerplate
Step 7: Apple Frameworks Integration
iOS provides specialized frameworks that require careful integration. Each framework has its own permission model, lifecycle requirements, and best practices.
Push Notifications:
import UserNotifications
import UIKit
final class NotificationManager: NSObject, UNUserNotificationCenterDelegate, Sendable {
static let shared = NotificationManager()
func requestAuthorization() async throws -> Bool {
let center = UNUserNotificationCenter.current()
center.delegate = self
let granted = try await center.requestAuthorization(options: [.alert, .badge, .sound])
if granted {
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
return granted
}
func scheduleLocalNotification(
title: String,
body: String,
triggerDate: Date,
identifier: String
) async throws {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
let components = Calendar.current.dateComponents(
[.year, .month, .day, .hour, .minute],
from: triggerDate
)
let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger)
try await UNUserNotificationCenter.current().add(request)
}
// Handle notification when app is in foreground
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
[.banner, .badge, .sound]
}
// Handle notification tap
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
// Route to appropriate screen based on notification payload
if let transactionID = userInfo["transaction_id"] as? String {
await MainActor.run {
NotificationCenter.default.post(
name: .didTapTransactionNotification,
object: nil,
userInfo: ["transactionID": transactionID]
)
}
}
}
}
Background Tasks with BGTaskScheduler:
import BackgroundTasks
enum BackgroundTaskIdentifier {
static let refresh = "com.myapp.refresh"
static let sync = "com.myapp.datasync"
}
final class BackgroundTaskManager {
static func registerTasks() {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: BackgroundTaskIdentifier.refresh,
using: nil
) { task in
guard let appRefreshTask = task as? BGAppRefreshTask else { return }
handleAppRefresh(task: appRefreshTask)
}
BGTaskScheduler.shared.register(
forTaskWithIdentifier: BackgroundTaskIdentifier.sync,
using: nil
) { task in
guard let processingTask = task as? BGProcessingTask else { return }
handleDataSync(task: processingTask)
}
}
static func scheduleAppRefresh() {
let request = BGAppRefreshTaskRequest(identifier: BackgroundTaskIdentifier.refresh)
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) // 15 minutes
do {
try BGTaskScheduler.shared.submit(request)
} catch {
// Log scheduling failure; do not crash
}
}
private static func handleAppRefresh(task: BGAppRefreshTask) {
scheduleAppRefresh() // Schedule the next refresh
let refreshTask = Task {
do {
let repository = DependencyContainer.shared.makeTransactionRepository()
_ = try await repository.fetchTransactions(for: .currentUser)
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = {
refreshTask.cancel()
}
}
private static func handleDataSync(task: BGProcessingTask) {
let syncTask = Task {
do {
let syncService = DependencyContainer.shared.makeSyncService()
try await syncService.performFullSync()
task.setTaskCompleted(success: true)
} catch {
task.setTaskCompleted(success: false)
}
}
task.expirationHandler = {
syncTask.cancel()
}
}
}
StoreKit 2 In-App Purchases:
import StoreKit
@Observable
@MainActor
final class StoreManager {
private(set) var products: [Product] = []
private(set) var purchasedProductIDs: Set<String> = []
private var transactionListener: Task<Void, Never>?
private let productIDs: Set<String> = [
"com.myapp.premium.monthly",
"com.myapp.premium.yearly",
]
init() {
transactionListener = listenForTransactions()
}
deinit {
transactionListener?.cancel()
}
func loadProducts() async {
do {
products = try await Product.products(for: productIDs)
.sorted { $0.price < $1.price }
} catch {
products = []
}
}
func purchase(_ product: Product) async throws -> StoreKit.Transaction? {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
purchasedProductIDs.insert(transaction.productID)
await transaction.finish()
return transaction
case .userCancelled:
return nil
case .pending:
return nil
@unknown default:
return nil
}
}
func restorePurchases() async {
for await result in Transaction.currentEntitlements {
if let transaction = try? checkVerified(result) {
purchasedProductIDs.insert(transaction.productID)
}
}
}
var isPremium: Bool {
!purchasedProductIDs.isEmpty
}
private func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
for await result in Transaction.updates {
if let transaction = try? self?.checkVerified(result) {
await MainActor.run {
self?.purchasedProductIDs.insert(transaction.productID)
}
await transaction.finish()
}
}
}
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .unverified:
throw StoreError.failedVerification
case .verified(let value):
return value
}
}
}
enum StoreError: Error {
case failedVerification
}
App Intents for Shortcuts:
import AppIntents
struct ViewBalanceIntent: AppIntent {
static let title: LocalizedStringResource = "View Account Balance"
static let description = IntentDescription("Shows the current balance of a selected account.")
@Parameter(title: "Account")
var account: AccountEntity
func perform() async throws -> some IntentResult & ProvidesDialog {
let repository = DependencyContainer.shared.makeAccountRepository()
let balance = try await repository.fetchBalance(for: account.id)
let formatted = balance.formatted(.currency(code: account.currencyCode))
return .result(dialog: "Your \(account.name) balance is \(formatted).")
}
}
struct AccountEntity: AppEntity {
static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Account")
static let defaultQuery = AccountQuery()
let id: UUID
let name: String
let currencyCode: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
}
struct AccountQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [AccountEntity] {
let repository = DependencyContainer.shared.makeAccountRepository()
let accounts = try await repository.fetchAccounts()
return accounts
.filter { identifiers.contains($0.id) }
.map { AccountEntity(id: $0.id, name: $0.name, currencyCode: $0.currencyCode) }
}
func suggestedEntities() async throws -> [AccountEntity] {
let repository = DependencyContainer.shared.makeAccountRepository()
let accounts = try await repository.fetchAccounts()
return accounts.map { AccountEntity(id: $0.id, name: $0.name, currencyCode: $0.currencyCode) }
}
}
struct MyAppShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(
intent: ViewBalanceIntent(),
phrases: [
"Show my balance in \(.applicationName)",
"Check \(\.$account) balance in \(.applicationName)",
],
shortTitle: "View Balance",
systemImageName: "dollarsign.circle"
)
}
}
Key Framework Integration Principles:
- Always request permissions at the moment of use, not at app launch. Explain why the permission is needed before showing the system prompt
- Register background tasks in
application(_:didFinishLaunchingWithOptions:) or early in the app lifecycle. Always reschedule the next occurrence inside the handler
- Use StoreKit 2 (the async/await API) for all new in-app purchase implementations. StoreKit 2 handles receipt validation server-side via
VerificationResult
- App Intents enable Siri Shortcuts and Spotlight integration. Define intents for the three to five most common actions users perform in your app
- Always listen for
Transaction.updates on app launch to handle purchases that completed while the app was not running
Step 8: Testing
Comprehensive testing on iOS combines unit tests with XCTest or Swift Testing, UI tests with XCUITest, and protocol-based mocking for dependency isolation.
Unit Tests with Swift Testing Framework:
import Testing
@testable import MyApp
@Suite("TransactionListViewModel")
struct TransactionListViewModelTests {
let mockRepository: MockTransactionRepository
init() {
mockRepository = MockTransactionRepository()
}
@Test("loads transactions on initial fetch")
@MainActor
func loadTransactions() async {
let expected = [
Transaction.preview(merchantName: "Coffee Shop", amount: -4.50),
Transaction.preview(merchantName: "Salary", amount: 5000.00),
]
mockRepository.stubbedTransactions = expected
let viewModel = TransactionListViewModel(
accountID: UUID(),
repository: mockRepository
)
await viewModel.loadTransactions()
#expect(viewModel.transactions.count == 2)
#expect(viewModel.transactions[0].merchantName == "Coffee Shop")
#expect(viewModel.isLoading == false)
#expect(viewModel.error == nil)
}
@Test("sets error state when fetch fails")
@MainActor
func loadTransactionsFailure() async {
mockRepository.stubbedError = APIError.networkError(URLError(.notConnectedToInternet))
let viewModel = TransactionListViewModel(
accountID: UUID(),
repository: mockRepository
)
await viewModel.loadTransactions()
#expect(viewModel.transactions.isEmpty)
#expect(viewModel.error != nil)
#expect(viewModel.isLoading == false)
}
@Test("deletes transaction with optimistic update")
@MainActor
func deleteTransaction() async {
let transactions = [
Transaction.preview(merchantName: "Item 1"),
Transaction.preview(merchantName: "Item 2"),
Transaction.preview(merchantName: "Item 3"),
]
mockRepository.stubbedTransactions = transactions
let viewModel = TransactionListViewModel(
accountID: UUID(),
repository: mockRepository
)
await viewModel.loadTransactions()
await viewModel.deleteTransaction(at: IndexSet(integer: 1))
#expect(viewModel.transactions.count == 2)
#expect(viewModel.transactions.contains(where: { $0.merchantName == "Item 2" }) == false)
}
@Test("rolls back optimistic delete on failure")
@MainActor
func deleteTransactionRollback() async {
let transactions = [
Transaction.preview(merchantName: "Item 1"),
Transaction.preview(merchantName: "Item 2"),
]
mockRepository.stubbedTransactions = transactions
mockRepository.stubbedDeleteError = APIError.serverError(statusCode: 500)
let viewModel = TransactionListViewModel(
accountID: UUID(),
repository: mockRepository
)
await viewModel.loadTransactions()
await viewModel.deleteTransaction(at: IndexSet(integer: 0))
#expect(viewModel.transactions.count == 2, "Transaction should be restored after failed delete")
#expect(viewModel.error != nil)
}
}
Protocol-Based Mocking:
@testable import MyApp
final class MockTransactionRepository: TransactionRepositoryProtocol, @unchecked Sendable {
var stubbedTransactions: [Transaction] = []
var stubbedError: Error?
var stubbedDeleteError: Error?
var fetchCallCount = 0
var deletedIDs: [Transaction.ID] = []
func fetchTransactions(for accountID: Account.ID) async throws -> [Transaction] {
fetchCallCount += 1
if let error = stubbedError {
throw error
}
return stubbedTransactions
}
func deleteTransaction(id: Transaction.ID) async throws {
if let error = stubbedDeleteError {
throw error
}
deletedIDs.append(id)
}
}
final class MockAPIClient: APIClientProtocol, @unchecked Sendable {
var stubbedResponses: [String: Any] = [:]
var stubbedErrors: [String: Error] = [:]
var requestLog: [(path: String, method: HTTPMethod)] = []
func request<T: Decodable>(_ endpoint: Endpoint) async throws -> T {
requestLog.append((path: endpoint.path, method: endpoint.method))
if let error = stubbedErrors[endpoint.path] {
throw error
}
guard let response = stubbedResponses[endpoint.path] as? T else {
throw APIError.decodingError(
DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No stub"))
)
}
return response
}
}
XCTest Unit Tests (for projects not yet on Swift Testing):
import XCTest
@testable import MyApp
final class APIClientTests: XCTestCase {
private var sut: APIClient!
private var mockSession: URLSession!
private var mockTokenProvider: MockTokenProvider!
override func setUp() {
super.setUp()
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
mockSession = URLSession(configuration: configuration)
mockTokenProvider = MockTokenProvider(token: "test-token")
sut = APIClient(
baseURL: URL(string: "https://api.example.com")!,
session: mockSession,
tokenProvider: mockTokenProvider
)
}
override func tearDown() {
MockURLProtocol.requestHandler = nil
sut = nil
super.tearDown()
}
func testSuccessfulRequest() async throws {
let expectedUser = UserDTO(id: "user-1", name: "Alice", email: "alice@example.com")
let data = try JSONEncoder().encode(expectedUser)
MockURLProtocol.requestHandler = { request in
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-token")
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (response, data)
}
let result: UserDTO = try await sut.request(Endpoint(path: "/users/user-1"))
XCTAssertEqual(result.id, "user-1")
XCTAssertEqual(result.name, "Alice")
}
func testUnauthorizedThrowsError() async {
MockURLProtocol.requestHandler = { request in
let response = HTTPURLResponse(
url: request.url!,
statusCode: 401,
httpVersion: nil,
headerFields: nil
)!
return (response, Data())
}
do {
let _: UserDTO = try await sut.request(Endpoint(path: "/me"))
XCTFail("Expected APIError.unauthorized")
} catch {
XCTAssertTrue(error is APIError)
if case APIError.unauthorized = error { } else {
XCTFail("Expected .unauthorized, got \(error)")
}
}
}
}
final class MockURLProtocol: URLProtocol {
nonisolated(unsafe) static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
guard let handler = Self.requestHandler else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
return
}
do {
let (response, data) = try handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
}
UI Testing with XCUITest:
import XCTest
final class TransactionFlowUITests: XCTestCase {
private var app: XCUIApplication!
override func setUp() {
super.setUp()
continueAfterFailure = false
app = XCUIApplication()
app.launchArguments = ["--uitesting"]
app.launchEnvironment = ["API_BASE_URL": "http://localhost:8080"]
app.launch()
}
func testViewTransactionDetail() {
// Navigate to transactions tab
let transactionsTab = app.tabBars.buttons["Transactions"]
XCTAssertTrue(transactionsTab.waitForExistence(timeout: 5))
transactionsTab.tap()
// Wait for list to load
let firstTransaction = app.cells.firstMatch
XCTAssertTrue(firstTransaction.waitForExistence(timeout: 10))
// Tap to view detail
firstTransaction.tap()
// Verify detail screen
let merchantLabel = app.staticTexts["merchantName"]
XCTAssertTrue(merchantLabel.waitForExistence(timeout: 5))
let amountLabel = app.staticTexts["amount"]
XCTAssertTrue(amountLabel.exists)
}
func testDeleteTransaction() {
let transactionsTab = app.tabBars.buttons["Transactions"]
XCTAssertTrue(transactionsTab.waitForExistence(timeout: 5))
transactionsTab.tap()
let firstCell = app.cells.firstMatch
XCTAssertTrue(firstCell.waitForExistence(timeout: 10))
let initialCount = app.cells.count
// Swipe to delete
firstCell.swipeLeft()
let deleteButton = app.buttons["Delete"]
XCTAssertTrue(deleteButton.waitForExistence(timeout: 3))
deleteButton.tap()
// Confirm deletion dialog
let confirmButton = app.buttons["Delete"].firstMatch
if confirmButton.waitForExistence(timeout: 3) {
confirmButton.tap()
}
// Verify count decreased
let expectation = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "count < %d", initialCount),
object: app.cells
)
wait(for: [expectation], timeout: 5)
}
func testPullToRefresh() {
let transactionsTab = app.tabBars.buttons["Transactions"]
XCTAssertTrue(transactionsTab.waitForExistence(timeout: 5))
transactionsTab.tap()
let firstCell = app.cells.firstMatch
XCTAssertTrue(firstCell.waitForExistence(timeout: 10))
// Pull to refresh
firstCell.swipeDown()
// Verify refresh indicator appeared and data reloaded
let refreshedCell = app.cells.firstMatch
XCTAssertTrue(refreshedCell.waitForExistence(timeout: 10))
}
}
Async Test Patterns:
import Testing
@testable import MyApp
@Suite("APIClient Async Patterns")
struct APIClientAsyncTests {
@Test("cancels in-flight request when task is cancelled")
func cancellation() async {
let client = APIClient(
baseURL: URL(string: "https://api.example.com")!,
session: .shared,
tokenProvider: MockTokenProvider(token: "token")
)
let task = Task {
let _: UserDTO = try await client.request(
Endpoint(path: "/slow-endpoint")
)
}
// Cancel immediately
task.cancel()
let result = await task.result
switch result {
case .success:
Issue.record("Expected cancellation error")
case .failure(let error):
#expect(error is CancellationError || (error as? URLError)?.code == .cancelled)
}
}
@Test("retries request up to max attempts on transient failure")
func retryLogic() async throws {
var attemptCount = 0
let mockSession = MockRetryURLSession { _ in
attemptCount += 1
if attemptCount < 3 {
throw URLError(.networkConnectionLost)
}
return (
HTTPURLResponse(
url: URL(string: "https://api.example.com/data")!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!,
try JSONEncoder().encode(["status": "ok"])
)
}
// Test with retry-enabled client
#expect(attemptCount == 0)
// After performing request with retry logic...
#expect(attemptCount == 3)
}
}
Key Testing Principles:
- Use Swift Testing (
@Test, #expect, @Suite) for all new tests. It provides clearer syntax, parameterized tests, and better diagnostics than XCTest
- Mock at the protocol boundary. Define protocols for all external dependencies (network, persistence, system services) and inject mock implementations in tests
- Use
MockURLProtocol to intercept URLSession requests without a real server. This avoids flaky tests caused by network conditions
- Mark tests that mutate
@MainActor-isolated view models with @MainActor to satisfy Swift 6 concurrency requirements
- Keep UI tests focused on critical user flows (three to five scenarios). UI tests are slow and brittle, so cover edge cases with unit tests instead
- Use launch arguments (
--uitesting) to configure the app for UI testing: stub network responses, seed test data, and disable animations
Best Practices
- Start with SwiftUI, bridge to UIKit when needed: Use SwiftUI for all new screens. Drop into UIKit only for complex custom layouts, map annotations, or camera integration where UIKit provides capabilities SwiftUI lacks
- Adopt @Observable over ObservableObject: The @Observable macro (iOS 17+) provides fine-grained observation without
@Published wrappers and eliminates unnecessary view re-renders
- Use Swift concurrency everywhere: Replace completion handlers, Combine pipelines, and GCD with async/await and structured concurrency. Enable strict concurrency checking to catch data races at compile time
- Design for offline first: Cache API responses locally with SwiftData. Show cached data immediately and refresh in the background. Display clear indicators when data is stale
- Follow the principle of least privilege for permissions: Request camera, location, notification, and health data permissions at the moment of use with a pre-prompt explaining why the permission is needed
- Invest in accessibility from day one: Use semantic SwiftUI modifiers (
.accessibilityLabel, .accessibilityHint, .accessibilityValue). Test with VoiceOver enabled during development
- Keep the main thread free: Move all I/O, parsing, and heavy computation off the main actor. Use
Task.detached or custom actors for CPU-intensive work
- Pin deployment targets explicitly: Set the minimum iOS version in xcconfig files, not in Xcode project settings, to prevent accidental changes from UI clicks
- Use SwiftData for new projects, Core Data only for existing ones: SwiftData is the modern replacement for Core Data with simpler syntax and SwiftUI integration
- Automate with Xcode Cloud or CI: Run tests, linting (SwiftLint), and archive builds on every pull request. Catch regressions before they reach the main branch
Common Rationalizations
| Rationalization | Reality |
|---|
| "I'll store the auth token in UserDefaults, it's simpler" | UserDefaults is an unencrypted plist any jailbroken device or backup reads in plaintext. Tokens and credentials belong in the Keychain, which is the only Apple-provided encrypted store. |
| "Strong-capturing self in this closure is fine" | A strong self in an escaping closure that the object also retains creates a reference cycle that leaks the view controller forever. Capture [weak self] and the Instruments leaks tool stays green. |
| "I'll do the network call on the main thread, it's just one request" | A synchronous request on the main actor blocks the UI and trips the watchdog into a hang termination. async/await off the main actor with a main-actor UI update is the pattern that keeps the app responsive. |
| "SwiftUI previews are nice-to-have, skip them" | Previews are the fastest feedback loop for state and layout bugs; skipping them pushes recomposition and binding errors to device builds that take 10x longer to iterate. |
Verification
Related Skills
- [[architecture-design]] -- system decomposition and trade-off analysis for complex apps
- [[security-review]] -- security assessment for authentication and data protection
- [[testing-review]] -- test coverage and strategy evaluation
- [[code-quality]] -- code quality metrics and maintainability assessment
- [[android-development]] -- the Android counterpart when the same product targets both platforms
Version: 1.0.0
Last Updated: March 2026
Iterative Refinement Strategy
This skill is optimized for an iterative approach:
- Execute: Perform the core steps defined above.
- Review: Critically analyze the output (coverage, quality, completeness).
- Refine: If targets are not met, repeat the specific implementation steps with improved context.
- Loop: Continue until the definition of done is satisfied.
Source: bendourthe/Nexus-Hub — distributed by TomeVault.