
Migration Patterns
- 2 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Migration guides for CoreData to SwiftData, UIKit to SwiftUI, ObservableObject to @Observable, XCTest to Swift Testing, Objective-C to Swift, and StoreKit 1 to 2.
About
Provides before/after migration guides, coexistence strategies, and pitfalls for moving between Apple framework generations like CoreData to SwiftData and UIKit to SwiftUI. A developer uses it when modernizing an existing Apple codebase.
- Full before/after mappings with coexistence strategies
- Covers six major Apple framework migrations
Migration Patterns by the numbers
- 2 all-time installs (skills.sh)
- Ranked #888 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill migration-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Migration guides for CoreData to SwiftData, UIKit to SwiftUI, ObservableObject to @Observable, XCTest to Swift Testing, Objective-C to Swift, and StoreKit 1 to 2.
Files
Migration Patterns
Comprehensive guides for migrating between Apple framework generations. Each guide covers the full before/after mapping, coexistence strategies, and common pitfalls.
When This Skill Activates
- User asks how to migrate from CoreData to SwiftData
- User is moving UIKit code to SwiftUI (or embedding one in the other)
- User needs to update from ObservableObject/Combine to @Observable/AsyncSequence
- User is converting XCTest tests to Swift Testing
- User asks about coexistence strategies (running old and new frameworks side by side)
- User wants to know whether a migration is worth doing for their situation
- User is migrating Objective-C code to Swift (bridging headers, incremental migration)
- User is migrating from StoreKit 1 to StoreKit 2 (in-app purchases, subscriptions)
- User encounters errors during a framework migration
Decision Tree
What are you migrating?
|
+-- Data persistence layer
| +-- CoreData --> SwiftData
| | See coredata-to-swiftdata.md
| | Min: iOS 17 / macOS 14
| |
| +-- Still need CoreData features SwiftData lacks?
| Stay on CoreData or use coexistence mode
|
+-- UI framework
| +-- UIKit --> SwiftUI
| | See uikit-to-swiftui.md
| | Min: iOS 13 (basic), iOS 16+ (modern navigation)
| |
| +-- Full rewrite or incremental?
| Incremental is almost always better -- adopt screen by screen
|
+-- State management / observation
| +-- ObservableObject --> @Observable
| | See observable-migration.md
| | Min: iOS 17 / macOS 14
| |
| +-- Combine publishers --> AsyncSequence
| Also covered in observable-migration.md
|
+-- Programming language
| +-- Objective-C --> Swift
| | See objc-to-swift.md
| | Incremental: migrate leaves first, trunks last
| |
| +-- Mixed-language project?
| Both languages coexist via bridging headers
|
+-- In-app purchases
| +-- StoreKit 1 --> StoreKit 2
| | See storekit-migration.md
| | Min: iOS 15
| |
| +-- Using a third-party SDK (RevenueCat, etc.)?
| Check if SDK already supports StoreKit 2 internally
|
+-- Testing framework
+-- XCTest --> Swift Testing
See xctest-to-swift-testing.md
Min: Xcode 16 / Swift 6.0Quick Reference
| Migration | Reference File | Minimum OS | Risk Level |
|---|---|---|---|
| CoreData to SwiftData | coredata-to-swiftdata.md | iOS 17 / macOS 14 | High (data layer) |
| UIKit to SwiftUI | uikit-to-swiftui.md | iOS 13+ | Medium (incremental) |
| ObservableObject to @Observable | observable-migration.md | iOS 17 / macOS 14 | Low-Medium |
| Objective-C to Swift | objc-to-swift.md | Any | Medium (incremental) |
| StoreKit 1 to StoreKit 2 | storekit-migration.md | iOS 15 | Medium-High (payments) |
| XCTest to Swift Testing | xctest-to-swift-testing.md | Xcode 16 | Low |
Process
1. Assess the Migration
Before starting, determine:
- What is the minimum deployment target? Many migrations require iOS 17+.
- How large is the surface area? (number of models, screens, test files)
- Can you adopt incrementally or is it all-or-nothing?
- Are there third-party dependencies that assume the old framework?
2. Load Relevant Reference File
Based on the migration type, read from this directory:
coredata-to-swiftdata.md-- NSManagedObject to @Model, migration stages, coexistenceuikit-to-swiftui.md-- UIHostingController, Representable, incremental adoptionobservable-migration.md-- @Observable macro, @Environment injection, AsyncSequenceobjc-to-swift.md-- Bridging headers, @objc, incremental file-by-file migrationstorekit-migration.md-- StoreKit 2 async/await purchases, Transaction.currentEntitlements, JWSxctest-to-swift-testing.md-- @Test, #expect, #require, parameterized tests
3. Review the User's Code
Scan for old-framework patterns and map each to its modern equivalent using the reference file. Check for:
- [ ] Deprecated API usage that has a direct modern replacement
- [ ] Custom workarounds that are no longer needed with the new framework
- [ ] Third-party dependencies that may conflict with the migration
- [ ] Coexistence requirements (both frameworks running simultaneously)
4. Recommend a Migration Strategy
For each migration, decide between:
- Full migration: Replace all old-framework code at once. Best for small codebases or when old framework is causing problems.
- Incremental migration: Migrate piece by piece while both frameworks coexist. Best for large codebases, production apps, or when timeline is flexible.
- No migration: The old framework is still appropriate. See "When NOT to Migrate" in each reference file.
General Migration Principles
1. Migrate tests first. If you are migrating both app code and tests, migrate tests to Swift Testing first. This gives you a safety net for the app-code migration.
2. One migration at a time. Do not migrate CoreData to SwiftData and ObservableObject to @Observable in the same PR. Each migration should be independently reviewable and revertible.
3. Keep the old code compiling. During incremental migration, both old and new code must compile and run. Use coexistence patterns from each reference file.
4. Feature-flag large migrations. For production apps, consider gating new-framework code behind a feature flag so you can roll back without a code revert.
5. Write migration tests. For data-layer migrations (CoreData to SwiftData), write tests that verify data roundtrips correctly through both stacks.
References
CoreData to SwiftData Migration
SwiftData replaces CoreData with a declarative, Swift-native persistence framework built on the @Model macro. Requires iOS 17 / macOS 14 minimum.
Concept Mapping
| CoreData | SwiftData | Notes |
|---|---|---|
NSManagedObject subclass | @Model class | No codegen, no .xcdatamodeld |
NSPersistentContainer | ModelContainer | Configured in code or SwiftUI modifier |
NSManagedObjectContext | ModelContext | Injected via @Environment(\.modelContext) |
NSFetchRequest | FetchDescriptor | Uses Swift predicates, not NSPredicate |
@FetchRequest | @Query | SwiftUI property wrapper |
NSPredicate | #Predicate macro | Type-safe, compile-time checked |
NSSortDescriptor | SortDescriptor | Swift-native, type-safe |
.xcdatamodeld file | None | Schema defined in code via @Model |
| Lightweight migration | VersionedSchema + SchemaMigrationPlan | Explicit migration stages |
NSManagedObject to @Model
Before (CoreData)
// Requires .xcdatamodeld file with entity definition
// Codegen produces NSManagedObject subclass or you write manually:
class Item: NSManagedObject {
@NSManaged var title: String
@NSManaged var timestamp: Date
@NSManaged var isComplete: Bool
@NSManaged var tags: NSSet? // To-many relationship
}After (SwiftData)
import SwiftData
@Model
class Item {
var title: String
var timestamp: Date
var isComplete: Bool
var tags: [Tag] // Direct Swift array, not NSSet
init(title: String, timestamp: Date = .now, isComplete: Bool = false, tags: [Tag] = []) {
self.title = title
self.timestamp = timestamp
self.isComplete = isComplete
self.tags = tags
}
}Key differences:
- No .xcdatamodeld file needed. The schema is the Swift class itself.
- No
@NSManaged. Properties are plain stored properties. - Relationships use Swift arrays and optional types, not
NSSet. - You must provide an
init--@Modeldoes not synthesize one. @Modelautomatically makes all stored properties persistent.
Excluding Properties from Persistence
@Model
class Item {
var title: String
var timestamp: Date
// Not persisted
@Transient var isSelected: Bool = false
}Unique Constraints
@Model
class Item {
#Unique<Item>([\.title, \.timestamp])
var title: String
var timestamp: Date
}NSPersistentContainer to ModelContainer
Before (CoreData)
class PersistenceController {
let container: NSPersistentContainer
init() {
container = NSPersistentContainer(name: "MyApp")
container.loadPersistentStores { description, error in
if let error {
fatalError("Failed to load store: \(error)")
}
}
}
var viewContext: NSManagedObjectContext {
container.viewContext
}
}After (SwiftData)
// Option 1: In SwiftUI (preferred)
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: [Item.self, Tag.self])
}
}
// Option 2: Manual configuration
let container = try ModelContainer(
for: Item.self, Tag.self,
configurations: ModelConfiguration(
isStoredInMemoryOnly: false,
allowsSave: true
)
)The .modelContainer modifier injects both the ModelContainer and a ModelContext into the SwiftUI environment.
NSFetchRequest to FetchDescriptor
Before (CoreData)
let request: NSFetchRequest<Item> = Item.fetchRequest()
request.predicate = NSPredicate(format: "isComplete == %@ AND title CONTAINS[cd] %@", NSNumber(value: false), searchText)
request.sortDescriptors = [NSSortDescriptor(keyPath: \Item.timestamp, ascending: false)]
request.fetchLimit = 20
let items = try viewContext.fetch(request)After (SwiftData)
var descriptor = FetchDescriptor<Item>(
predicate: #Predicate<Item> { item in
!item.isComplete && item.title.localizedStandardContains(searchText)
},
sortBy: [SortDescriptor(\.timestamp, order: .reverse)]
)
descriptor.fetchLimit = 20
let items = try modelContext.fetch(descriptor)Key differences:
#Predicateis type-safe and checked at compile time. No format strings.SortDescriptoris Swift-native (notNSSortDescriptor).- Fetch is called on
ModelContext, notNSManagedObjectContext.
@FetchRequest to @Query
Before (CoreData)
struct ItemListView: View {
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: false)],
predicate: NSPredicate(format: "isComplete == NO"),
animation: .default
)
private var items: FetchedResults<Item>
var body: some View {
List(items) { item in
ItemRow(item: item)
}
}
}After (SwiftData)
struct ItemListView: View {
@Query(
filter: #Predicate<Item> { !$0.isComplete },
sort: \.timestamp,
order: .reverse
)
private var items: [Item]
var body: some View {
List(items) { item in
ItemRow(item: item)
}
}
}Key differences:
@Queryreturns a plain[Item]array, notFetchedResults.- Predicates use
#Predicatemacro. - Sort is specified with keypaths and order directly.
@Queryautomatically observes theModelContextfrom the environment.
Dynamic Queries
If the predicate or sort needs to change at runtime, pass them via init:
struct ItemListView: View {
@Query private var items: [Item]
init(showCompleted: Bool) {
let predicate: Predicate<Item>
if showCompleted {
predicate = #Predicate<Item> { _ in true }
} else {
predicate = #Predicate<Item> { !$0.isComplete }
}
_items = Query(filter: predicate, sort: \.timestamp, order: .reverse)
}
var body: some View {
List(items) { item in
ItemRow(item: item)
}
}
}NSManagedObjectContext to ModelContext
Before (CoreData)
// Insert
let item = Item(context: viewContext)
item.title = "New Item"
item.timestamp = Date()
// Save
try viewContext.save()
// Delete
viewContext.delete(item)
try viewContext.save()After (SwiftData)
// Insert
let item = Item(title: "New Item")
modelContext.insert(item)
// Save (automatic by default, or explicit)
try modelContext.save()
// Delete
modelContext.delete(item)
try modelContext.save()Key differences:
- Objects are created with a normal Swift
init, then inserted into the context. - SwiftData auto-saves by default. Explicit
save()is optional but recommended for critical operations. - No
context:parameter in the initializer.
Lightweight Migration (VersionedSchema + SchemaMigrationPlan)
SwiftData requires explicit migration stages when your schema changes between releases.
Define Versioned Schemas
enum ItemSchemaV1: VersionedSchema {
static var versionIdentifier = Schema.Version(1, 0, 0)
static var models: [any PersistentModel.Type] {
[Item.self]
}
@Model
class Item {
var title: String
var timestamp: Date
init(title: String, timestamp: Date) {
self.title = title
self.timestamp = timestamp
}
}
}
enum ItemSchemaV2: VersionedSchema {
static var versionIdentifier = Schema.Version(2, 0, 0)
static var models: [any PersistentModel.Type] {
[Item.self]
}
@Model
class Item {
var title: String
var timestamp: Date
var isComplete: Bool // New property
init(title: String, timestamp: Date, isComplete: Bool = false) {
self.title = title
self.timestamp = timestamp
self.isComplete = isComplete
}
}
}Define the Migration Plan
enum ItemMigrationPlan: SchemaMigrationPlan {
static var schemas: [any VersionedSchema.Type] {
[ItemSchemaV1.self, ItemSchemaV2.self]
}
static var stages: [MigrationStage] {
[migrateV1toV2]
}
static let migrateV1toV2 = MigrationStage.lightweight(
fromVersion: ItemSchemaV1.self,
toVersion: ItemSchemaV2.self
)
}Use the Migration Plan
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(
for: ItemSchemaV2.Item.self,
migrationPlan: ItemMigrationPlan.self
)
}
}For complex migrations that cannot be handled by lightweight migration, use .custom:
static let migrateV1toV2 = MigrationStage.custom(
fromVersion: ItemSchemaV1.self,
toVersion: ItemSchemaV2.self
) { context in
// willMigrate: runs before schema changes
// Transform data as needed with the OLD schema
} didMigrate: { context in
// didMigrate: runs after schema changes
// Transform data as needed with the NEW schema
let items = try context.fetch(FetchDescriptor<ItemSchemaV2.Item>())
for item in items {
item.isComplete = false
}
try context.save()
}Coexistence Strategy
You can run CoreData and SwiftData side by side during migration. This is the recommended approach for production apps with existing data.
Shared Persistent Store
Both frameworks can point to the same SQLite file:
let url = URL.applicationSupportDirectory.appending(path: "MyApp.store")
// CoreData
let description = NSPersistentStoreDescription()
description.url = url
let container = NSPersistentContainer(name: "MyApp")
container.persistentStoreDescriptions = [description]
// SwiftData
let config = ModelConfiguration(url: url)
let swiftDataContainer = try ModelContainer(for: Item.self, configurations: config)Migration Strategy for Production Apps
1. Phase 1: Add SwiftData models alongside CoreData entities. New screens use SwiftData. 2. Phase 2: Migrate existing screens one at a time from CoreData to SwiftData. 3. Phase 3: Write a one-time data migration that copies remaining CoreData records to SwiftData. 4. Phase 4: Remove CoreData stack, .xcdatamodeld file, and NSManagedObject subclasses.
One-Time Data Migration
func migrateData(from coreDataContext: NSManagedObjectContext, to modelContext: ModelContext) throws {
let request: NSFetchRequest<CDItem> = CDItem.fetchRequest()
let coreDataItems = try coreDataContext.fetch(request)
for cdItem in coreDataItems {
let item = Item(
title: cdItem.title ?? "",
timestamp: cdItem.timestamp ?? .now,
isComplete: cdItem.isComplete
)
modelContext.insert(item)
}
try modelContext.save()
}When NOT to Migrate
Stay on CoreData if:
- Your minimum deployment target is below iOS 17 / macOS 14.
- You rely on CoreData features SwiftData does not yet support (e.g., abstract entities, derived attributes, fetched properties, complex multi-store configurations).
- You have a large, stable CoreData stack that is well-tested and not causing issues.
- You use CloudKit syncing with CoreData and have not verified SwiftData's CloudKit support meets your needs.
- Third-party libraries in your project depend on NSManagedObject subclasses.
Common Mistakes
// ❌ Forgetting to provide init for @Model class
@Model
class Item {
var title: String
var timestamp: Date
// Compiler error: @Model requires explicit init
}
// ✅ Always provide init
@Model
class Item {
var title: String
var timestamp: Date
init(title: String, timestamp: Date = .now) {
self.title = title
self.timestamp = timestamp
}
}// ❌ Using NSPredicate format strings with @Query
@Query(filter: NSPredicate(format: "isComplete == NO")) // Wrong type
private var items: [Item]
// ✅ Use #Predicate macro
@Query(filter: #Predicate<Item> { !$0.isComplete })
private var items: [Item]// ❌ Creating model objects with context parameter (CoreData habit)
let item = Item(context: modelContext)
// ✅ Create normally, then insert
let item = Item(title: "New")
modelContext.insert(item)// ❌ Using NSSet for relationships
@Model
class Item {
var tags: NSSet? // Wrong, use Swift types
}
// ✅ Use Swift arrays
@Model
class Item {
var tags: [Tag]
}Checklist
- [ ] Minimum deployment target is iOS 17 / macOS 14
- [ ] All CoreData entities have equivalent @Model classes
- [ ] Relationships use Swift arrays/optionals, not NSSet
- [ ] All @Model classes have explicit
initmethods - [ ] NSPredicate replaced with #Predicate macro
- [ ] NSSortDescriptor replaced with SortDescriptor
- [ ] @FetchRequest replaced with @Query
- [ ] NSManagedObjectContext usage replaced with ModelContext
- [ ] VersionedSchema and SchemaMigrationPlan set up for existing data
- [ ] One-time data migration tested (if coexisting with CoreData)
- [ ] CloudKit syncing verified (if applicable)
- [ ] .xcdatamodeld file removed after full migration
Objective-C to Swift Migration
Incrementally migrating an Objective-C codebase to Swift while keeping the app functional throughout. Swift and Objective-C coexist in the same project using bridging headers, so you can migrate one file at a time without a big-bang rewrite.
When to Migrate
- New features should be written in Swift -- there is no reason to start new files in ObjC.
- Fixing bugs in an ObjC file is a good opportunity to migrate that file to Swift.
- ObjC code is hard to maintain (manual retain/release patterns, stringly-typed APIs, verbose syntax).
- You want Swift-only APIs: SwiftUI, async/await, actors,
@Observable, Swift Testing. - You want stronger type safety and fewer runtime crashes (optionals, enums, value types).
When NOT to Migrate
- ObjC code is stable, well-tested, and rarely touched. If it works and nobody needs to change it, leave it.
- Performance-critical C/C++ interop code. ObjC has zero-cost C interop; Swift requires bridging.
- Team is not yet comfortable with Swift. Training should come before migration.
- Massive codebase with a tight deadline. Migration takes time and introduces risk.
- Third-party ObjC libraries you do not control. Wrap them rather than rewrite them.
Concept Mapping
| Objective-C | Swift | Notes |
|---|---|---|
@interface / @implementation | class / struct / enum | Choose value types where possible |
@property (nonatomic, strong) | var | let for readonly equivalents |
@property (nonatomic, copy) | var (value types are copied) | Strings, arrays are value types in Swift |
@property (nonatomic, readonly) | let or private(set) var | Depends on mutability needs |
NSString | String | Bridged automatically |
NSArray / NSDictionary | [Element] / [Key: Value] | Typed collections |
NSNumber | Int, Double, Bool | Use native types |
NSError ** | throws | Error handling via try/catch |
Block (^) | Closure ({ }) | Syntax difference, same concept |
id | Any | Prefer specific types |
NS_ENUM | enum: Int (or CaseIterable) | Natively typed |
NS_OPTIONS | OptionSet | Struct-based |
dispatch_queue_t + GCD | async/await + actors | Modern concurrency |
| Category | Extension | Same concept, different syntax |
Protocol (@protocol) | Protocol | Swift protocols are more powerful |
#pragma mark - | // MARK: - | Section separators |
@selector | #selector | Compile-time checked |
@try/@catch | do/try/catch | Swift does not catch ObjC exceptions |
instancetype | Self | Return type inference |
nullable / nonnull | Optional / non-optional | Direct mapping |
Bridging Header Setup
ObjC to Swift (Bridging Header)
The bridging header lets Swift code import ObjC classes. Xcode creates it automatically when you first add a Swift file to an ObjC project.
// ProjectName-Bridging-Header.h
// Import ObjC headers you want visible to Swift
#import "NetworkManager.h"
#import "UserModel.h"
#import "DatabaseHelper.h"Build setting: SWIFT_OBJC_BRIDGING_HEADER = ProjectName/ProjectName-Bridging-Header.h
Rules:
- Keep the bridging header minimal. Only import headers that Swift code actually needs.
- Do not import every header -- it slows down compilation and creates unnecessary coupling.
- Remove headers from the bridging header as you migrate those classes to Swift.
Swift to ObjC (Generated Header)
Xcode auto-generates a ProjectName-Swift.h header that exposes Swift classes to ObjC. Import it in ObjC files:
// In any .m file that needs Swift classes
#import "ProjectName-Swift.h"
// Never import this in .h files (causes circular imports)Build setting: DEFINES_MODULE = YES
@objc Attribute
Swift classes and members are not visible to ObjC by default. Mark them explicitly:
// Single class with specific members visible to ObjC
@objc class UserManager: NSObject {
@objc var currentUser: User?
@objc func logOut() {
currentUser = nil
}
// Not visible to ObjC (no @objc)
func swiftOnlyMethod() { }
}@objcMembers
When most members need ObjC visibility, use @objcMembers on the class instead of marking each member:
@objcMembers
class LegacyService: NSObject {
var isReady: Bool = false // Visible to ObjC
func start() { } // Visible to ObjC
func stop() { } // Visible to ObjC
// Opt out specific members
@nonobjc func swiftOnlyHelper() { }
}Incremental Migration Strategy
Migrate leaves first, trunks last. Start with classes that have the fewest dependencies on other ObjC classes.
Recommended Order
1. Model classes -- Fewest dependencies, easiest to test. Convert NSObject subclasses to Swift structs or classes. 2. Utility / helper classes -- String formatters, date helpers, validators. Usually standalone. 3. Network layer -- API clients, request builders. Good candidate for async/await modernization. 4. ViewModels / Presenters -- Business logic layer. May have more dependencies on models and services. 5. View controllers -- Most complex, most dependencies. Migrate last.
For each file, the process is: 1. Create the new .swift file 2. Translate the ObjC code to Swift 3. Update the bridging header (remove the migrated header, add any new Swift @objc visibility) 4. Update all ObjC callers to use the Swift version (via ProjectName-Swift.h) 5. Delete the old .h and .m files 6. Run all tests
Common Translation Patterns
Blocks to Closures
// Objective-C
typedef void (^CompletionHandler)(NSData * _Nullable data, NSError * _Nullable error);
- (void)fetchDataWithCompletion:(CompletionHandler)completion {
[self.session dataTaskWithURL:self.url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
completion(data, error);
}];
}// Swift (direct translation)
func fetchData(completion: @escaping (Data?, Error?) -> Void) {
session.dataTask(with: url) { data, response, error in
completion(data, error)
}.resume()
}
// Swift (modern -- use async/await)
func fetchData() async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}NSError** to throws
// Objective-C
- (BOOL)saveToFile:(NSString *)path error:(NSError **)error {
NSData *data = [self serializedData];
if (!data) {
if (error) {
*error = [NSError errorWithDomain:@"MyApp" code:100 userInfo:@{
NSLocalizedDescriptionKey: @"Serialization failed"
}];
}
return NO;
}
return [data writeToFile:path options:NSDataWritingAtomic error:error];
}// Swift
enum FileError: LocalizedError {
case serializationFailed
var errorDescription: String? {
switch self {
case .serializationFailed: return "Serialization failed"
}
}
}
func save(to path: String) throws {
guard let data = serializedData() else {
throw FileError.serializationFailed
}
try data.write(to: URL(fileURLWithPath: path), options: .atomic)
}Delegates to Protocols
// Objective-C
@protocol DownloadDelegate <NSObject>
@required
- (void)downloadDidFinish:(Download *)download;
- (void)download:(Download *)download didFailWithError:(NSError *)error;
@optional
- (void)download:(Download *)download didUpdateProgress:(float)progress;
@end
@interface Download : NSObject
@property (nonatomic, weak) id<DownloadDelegate> delegate;
@end// Swift
protocol DownloadDelegate: AnyObject {
func downloadDidFinish(_ download: Download)
func download(_ download: Download, didFailWith error: Error)
func download(_ download: Download, didUpdateProgress progress: Float) // Optional via default impl
}
extension DownloadDelegate {
// Default implementation makes it optional
func download(_ download: Download, didUpdateProgress progress: Float) { }
}
class Download {
weak var delegate: DownloadDelegate?
}Enums (NS_ENUM and NS_OPTIONS)
// Objective-C
typedef NS_ENUM(NSInteger, Priority) {
PriorityLow,
PriorityMedium,
PriorityHigh
};
typedef NS_OPTIONS(NSUInteger, Permissions) {
PermissionsRead = 1 << 0,
PermissionsWrite = 1 << 1,
PermissionsDelete = 1 << 2
};// Swift
enum Priority: Int, CaseIterable {
case low
case medium
case high
}
struct Permissions: OptionSet {
let rawValue: UInt
static let read = Permissions(rawValue: 1 << 0)
static let write = Permissions(rawValue: 1 << 1)
static let delete = Permissions(rawValue: 1 << 2)
static let readWrite: Permissions = [.read, .write]
static let all: Permissions = [.read, .write, .delete]
}Singletons
// Objective-C
@implementation MyManager
+ (instancetype)sharedManager {
static MyManager *instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[MyManager alloc] init];
});
return instance;
}
@end// Swift
class MyManager {
static let shared = MyManager()
private init() { }
}Categories to Extensions
// Objective-C -- UIColor+Hex.h / UIColor+Hex.m
@interface UIColor (Hex)
+ (UIColor *)colorWithHex:(NSUInteger)hex;
@end
@implementation UIColor (Hex)
+ (UIColor *)colorWithHex:(NSUInteger)hex {
return [UIColor colorWithRed:((hex >> 16) & 0xFF) / 255.0
green:((hex >> 8) & 0xFF) / 255.0
blue:(hex & 0xFF) / 255.0
alpha:1.0];
}
@end// Swift
extension UIColor {
convenience init(hex: UInt) {
self.init(
red: CGFloat((hex >> 16) & 0xFF) / 255.0,
green: CGFloat((hex >> 8) & 0xFF) / 255.0,
blue: CGFloat(hex & 0xFF) / 255.0,
alpha: 1.0
)
}
}KVO to Combine or @Observable
// Objective-C (KVO)
[self.user addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
change:(NSDictionary *)change context:(void *)context {
if ([keyPath isEqualToString:@"name"]) {
[self updateNameLabel];
}
}// Swift (iOS 17+ -- @Observable)
@Observable
class User {
var name: String = ""
}
// SwiftUI view automatically observes changes
struct UserView: View {
var user: User
var body: some View {
Text(user.name) // Re-renders when name changes
}
}Nullability Annotations to Optionals
// Objective-C
@interface UserService : NSObject
- (nullable User *)findUserWithID:(nonnull NSString *)userID;
- (nonnull NSArray<User *> *)allUsers;
- (void)saveUser:(nonnull User *)user completion:(nullable void (^)(NSError * _Nullable))completion;
@end// Swift
class UserService {
func findUser(withID userID: String) -> User? {
// nullable return -> Optional
}
func allUsers() -> [User] {
// nonnull return -> non-optional
}
func saveUser(_ user: User, completion: ((Error?) -> Void)? = nil) {
// nullable block -> optional closure
}
}Mixed-Language Project Management
NS_SWIFT_NAME for Better Swift APIs
Improve how ObjC APIs appear in Swift without changing the ObjC interface:
// Objective-C
typedef NS_ENUM(NSInteger, ABCRecordType) {
ABCRecordTypePerson,
ABCRecordTypeOrganization
} NS_SWIFT_NAME(Record.RecordType);
@interface ABCManager : NSObject
+ (instancetype)managerWithConfiguration:(ABCConfiguration *)config
NS_SWIFT_NAME(init(configuration:));
- (void)fetchRecordsOfType:(ABCRecordType)type
NS_SWIFT_NAME(fetchRecords(ofType:));
@endNS_REFINED_FOR_SWIFT
Hide the ObjC version and provide a better Swift wrapper:
// Objective-C
@interface DataStore : NSObject
- (NSInteger)countForType:(NSString *)type NS_REFINED_FOR_SWIFT;
@end// Swift extension provides the refined API
extension DataStore {
// The ObjC method is available as __countForType(_:)
func count(for type: RecordType) -> Int {
return __count(forType: type.rawValue)
}
}Module Map Considerations
For framework targets or when using @import:
// module.modulemap
framework module MyFramework {
umbrella header "MyFramework.h"
export *
module * { export * }
}Build setting: DEFINES_MODULE = YES must be enabled for the -Swift.h header to be generated.
Testing During Migration
- Keep existing ObjC tests running. Do not delete ObjC tests until the Swift replacement is verified.
- Write new tests in Swift Testing or XCTest. All new test files should be Swift.
- Test the bridging layer. Write tests that exercise ObjC code calling Swift and Swift code calling ObjC to catch bridging issues.
- Verify no behavior changes. After migrating each file, run the full test suite. The migrated Swift code should produce identical results.
- Test nullability boundaries. ObjC and Swift handle
nildifferently. Verify thatnilvalues pass correctly across the bridge.
// Test that Swift class works when called from ObjC patterns
import XCTest
class BridgingTests: XCTestCase {
func testSwiftClassAccessibleFromObjC() {
// Verify @objc class can be instantiated and used
let manager = UserManager()
manager.logOut()
XCTAssertNil(manager.currentUser)
}
func testNullabilityBridging() {
// Verify nil handling across the bridge
let service = UserService()
let user = service.findUser(withID: "nonexistent")
XCTAssertNil(user) // Should be nil, not crash
}
}Common Mistakes
// ❌ Swift class not inheriting from NSObject (invisible to ObjC)
@objc class MyHelper {
@objc func doWork() { }
}
// Error: only classes that inherit from NSObject can be @objc
// ✅ Inherit from NSObject for ObjC visibility
@objc class MyHelper: NSObject {
@objc func doWork() { }
}// ❌ Importing -Swift.h in a .h file (circular import)
// MyClass.h
#import "ProjectName-Swift.h" // Causes circular dependency
// ✅ Use forward declaration in .h, import in .m
// MyClass.h
@class MySwiftClass; // Forward declaration
// MyClass.m
#import "ProjectName-Swift.h" // Import here// ❌ Assuming Swift structs/enums are visible to ObjC
@objc struct Point { // Error: structs cannot be @objc
var x: Double
var y: Double
}
// ✅ Use class inheriting from NSObject, or keep as Swift-only type
@objc class Point: NSObject {
@objc var x: Double
@objc var y: Double
@objc init(x: Double, y: Double) {
self.x = x
self.y = y
}
}// ❌ Using Swift-only types in @objc methods
@objc class DataService: NSObject {
@objc func process(items: [String: Any]) -> Result<Data, Error> {
// Error: Result is not representable in Objective-C
}
}
// ✅ Use ObjC-compatible types at the boundary
@objc class DataService: NSObject {
@objc func process(items: [String: Any]) throws -> Data {
// throws bridges to NSError** in ObjC
}
}// ❌ Not handling nil vs NSNull from ObjC collections
// ObjC NSDictionary can contain NSNull, which bridges to NSNull in Swift, not nil
let value = dict["key"] // Could be NSNull, not nil
// ✅ Check for NSNull explicitly
if let value = dict["key"], !(value is NSNull) {
// Safe to use value
}// ❌ Migrating everything at once (big-bang rewrite)
// This almost always fails for non-trivial apps
// ✅ Migrate one file at a time, test after each migration
// Keep both languages compiling and running throughoutChecklist
- [ ] Bridging header (
ProjectName-Bridging-Header.h) set up and minimal - [ ]
DEFINES_MODULE = YESin build settings - [ ] Migration order planned: models -> utilities -> network -> viewmodels -> view controllers
- [ ]
@objc/@objcMembersapplied where Swift classes need ObjC visibility - [ ] Swift classes that need ObjC visibility inherit from
NSObject - [ ]
-Swift.honly imported in.mfiles, never.hfiles (use forward declarations) - [ ]
NS_SWIFT_NAME/NS_REFINED_FOR_SWIFTused for ObjC APIs consumed from Swift - [ ] NSError** patterns replaced with
throws - [ ] Blocks replaced with closures (or async/await for modern code)
- [ ] NS_ENUM / NS_OPTIONS replaced with
enum/OptionSet - [ ] Nullability annotations mapped to Swift optionals
- [ ] Existing ObjC tests still pass after each file migration
- [ ] Bridging layer tested (ObjC calling Swift and Swift calling ObjC)
- [ ] Old
.hand.mfiles deleted after successful migration and testing - [ ] Bridging header updated (migrated headers removed)
ObservableObject to @Observable Migration
The @Observable macro (iOS 17 / macOS 14) replaces the ObservableObject protocol with a simpler, more performant observation system. Views only re-render when properties they actually read change, rather than when any @Published property changes.
Concept Mapping
| ObservableObject (Old) | @Observable (New) | Notes |
|---|---|---|
class MyModel: ObservableObject | @Observable class MyModel | Macro replaces protocol conformance |
@Published var name: String | var name: String | Plain stored properties, no wrapper |
@StateObject private var model | @State private var model | For owned instances |
@ObservedObject var model | var model: MyModel (direct reference) | No property wrapper needed |
@EnvironmentObject var model | @Environment(MyModel.self) var model | Or @Environment(\.myModel) with custom key |
objectWillChange.send() | Automatic | No manual notification |
model.$name (publisher) | AsyncSequence / withObservationTracking | No Combine dependency |
.environmentObject(model) | .environment(model) | Direct injection |
Basic Migration
Before (ObservableObject)
class UserSettings: ObservableObject {
@Published var username: String = ""
@Published var isLoggedIn: Bool = false
@Published var theme: Theme = .system
// Computed properties do not trigger updates
var displayName: String {
username.isEmpty ? "Anonymous" : username
}
func logOut() {
isLoggedIn = false
username = ""
}
}After (@Observable)
@Observable
class UserSettings {
var username: String = ""
var isLoggedIn: Bool = false
var theme: Theme = .system
// Computed properties based on observed properties DO trigger updates
var displayName: String {
username.isEmpty ? "Anonymous" : username
}
func logOut() {
isLoggedIn = false
username = ""
}
}Key differences:
- Remove
ObservableObjectconformance. Add@Observablemacro. - Remove all
@Publishedwrappers. Properties are plain stored properties. - Computed properties that read observed properties now automatically trigger view updates. With
ObservableObject, they did not. - No manual
objectWillChange.send()needed anywhere.
View Property Wrapper Migration
@StateObject to @State
@StateObject was needed to own an ObservableObject instance and keep it alive across view re-renders. With @Observable, use @State.
// Before
struct ContentView: View {
@StateObject private var settings = UserSettings()
var body: some View {
SettingsView(settings: settings)
}
}
// After
struct ContentView: View {
@State private var settings = UserSettings()
var body: some View {
SettingsView(settings: settings)
}
}@ObservedObject to Direct Reference
@ObservedObject subscribed a view to all @Published changes. With @Observable, a plain property reference is sufficient -- SwiftUI tracks which properties the view's body actually reads.
// Before
struct SettingsView: View {
@ObservedObject var settings: UserSettings
var body: some View {
Text(settings.username)
Toggle("Dark Mode", isOn: $settings.isDarkMode)
}
}
// After
struct SettingsView: View {
var settings: UserSettings
var body: some View {
Text(settings.username)
Toggle("Dark Mode", isOn: $settings.isDarkMode) // Compiler error: need @Bindable
}
}@Bindable for Bindings
When a view needs to create bindings ($ syntax) to an @Observable object it does not own, use @Bindable:
// ✅ Correct: use @Bindable for bindings to non-owned @Observable
struct SettingsView: View {
@Bindable var settings: UserSettings
var body: some View {
TextField("Username", text: $settings.username)
Toggle("Dark Mode", isOn: $settings.isDarkMode)
}
}Decision guide for which wrapper to use:
- View creates and owns the object:
@State - View receives the object and needs bindings:
@Bindable - View receives the object and only reads: plain
var(no wrapper)
@EnvironmentObject to @Environment
// Before
// Injection:
ContentView()
.environmentObject(settings)
// Usage:
struct ProfileView: View {
@EnvironmentObject var settings: UserSettings
var body: some View {
Text(settings.username)
}
}
// After
// Injection:
ContentView()
.environment(settings)
// Usage:
struct ProfileView: View {
@Environment(UserSettings.self) var settings
var body: some View {
Text(settings.username)
}
}If you need bindings to an environment-injected @Observable object, create a local @Bindable:
struct ProfileView: View {
@Environment(UserSettings.self) var settings
var body: some View {
@Bindable var settings = settings
TextField("Username", text: $settings.username)
}
}Performance Benefits
With ObservableObject, any @Published property change re-renders every view observing that object, even if the view does not use the changed property.
With @Observable, SwiftUI tracks exactly which properties each view's body reads and only re-renders when those specific properties change.
@Observable
class AppState {
var username: String = ""
var itemCount: Int = 0
var lastSyncDate: Date = .now
}
// This view only re-renders when `username` changes.
// Changes to `itemCount` or `lastSyncDate` do NOT cause re-render.
struct UsernameView: View {
var state: AppState
var body: some View {
Text(state.username)
}
}With the old ObservableObject, this view would re-render on every @Published change.
Combine Publishers to AsyncSequence
Before (Combine)
class SearchViewModel: ObservableObject {
@Published var query: String = ""
@Published var results: [Item] = []
private var cancellables = Set<AnyCancellable>()
init() {
$query
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.removeDuplicates()
.sink { [weak self] query in
self?.search(query)
}
.store(in: &cancellables)
}
private func search(_ query: String) {
// perform search
}
}After (AsyncSequence)
@Observable
class SearchViewModel {
var query: String = ""
var results: [Item] = []
// No Combine, no cancellables
}
// Debounce in the view using task
struct SearchView: View {
@State private var viewModel = SearchViewModel()
var body: some View {
List(viewModel.results) { item in
Text(item.title)
}
.searchable(text: $viewModel.query)
.task(id: viewModel.query) {
// task(id:) restarts when query changes
// Add a delay for debounce effect
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
viewModel.results = await performSearch(viewModel.query)
}
}
}Manual Observation (Outside SwiftUI)
For observing changes outside of SwiftUI views, use withObservationTracking:
func observeChanges(to settings: UserSettings) {
withObservationTracking {
// Access properties you want to track
_ = settings.username
_ = settings.theme
} onChange: {
// Called once when any tracked property changes
// Must re-register to observe again
print("Settings changed")
observeChanges(to: settings) // Re-register
}
}Note: withObservationTracking fires only once per registration. You must re-register in the onChange closure to continue observing. For continuous observation, consider AsyncStream:
extension UserSettings {
var usernameChanges: AsyncStream<String> {
AsyncStream { continuation in
@Sendable func observe() {
withObservationTracking {
continuation.yield(self.username)
} onChange: {
observe()
}
}
observe()
}
}
}Excluding Properties from Observation
If you have properties that should not trigger view updates:
@Observable
class ViewModel {
var visibleProperty: String = "" // Changes trigger updates
@ObservationIgnored
var internalCache: [String: Data] = [:] // Changes do NOT trigger updates
}Coexistence Strategy
During migration, you may have both ObservableObject and @Observable classes. They can coexist:
// Old class, not yet migrated
class LegacySettings: ObservableObject {
@Published var fontSize: Int = 14
}
// New class, already migrated
@Observable
class ModernSettings {
var theme: Theme = .system
}
struct ContentView: View {
@StateObject private var legacy = LegacySettings()
@State private var modern = ModernSettings()
var body: some View {
ChildView(legacy: legacy, modern: modern)
}
}
struct ChildView: View {
@ObservedObject var legacy: LegacySettings
var modern: ModernSettings
var body: some View {
Text("Font: \(legacy.fontSize)")
Text("Theme: \(modern.theme.rawValue)")
}
}Migration Order
1. Migrate models that are used by few views first. 2. Update each view that uses the migrated model (change @StateObject to @State, @ObservedObject to plain var or @Bindable, @EnvironmentObject to @Environment). 3. Remove Combine imports if no longer needed. 4. Repeat for the next model.
When NOT to Migrate
Stay on ObservableObject if:
- Your minimum deployment target is below iOS 17 / macOS 14.
- You heavily use Combine pipelines (
$property.debounce.map.sink) and prefer Combine's operator model. Note: you can still use Combine with@Observable, but@Publishedprojections are not available. - The class is stable, well-tested, and not causing performance issues. Migration is optional --
ObservableObjectis not deprecated. - You need to support watchOS 9 or tvOS 16 (these do not have
@Observable).
Common Mistakes
// ❌ Using @Published with @Observable (redundant, causes issues)
@Observable
class Settings {
@Published var name: String = "" // Wrong: @Published is for ObservableObject
}
// ✅ Plain stored properties with @Observable
@Observable
class Settings {
var name: String = ""
}// ❌ Using @ObservedObject with @Observable class
struct MyView: View {
@ObservedObject var settings: Settings // Wrong: @ObservedObject is for ObservableObject
var body: some View {
Text(settings.name)
}
}
// ✅ Plain var or @Bindable with @Observable class
struct MyView: View {
@Bindable var settings: Settings // Use @Bindable if you need $ bindings
var body: some View {
TextField("Name", text: $settings.name)
}
}// ❌ Using @StateObject with @Observable class
struct ParentView: View {
@StateObject private var settings = Settings() // Wrong
var body: some View { ChildView(settings: settings) }
}
// ✅ Use @State with @Observable class
struct ParentView: View {
@State private var settings = Settings()
var body: some View { ChildView(settings: settings) }
}// ❌ Using .environmentObject with @Observable class
ContentView()
.environmentObject(settings) // Wrong: .environmentObject is for ObservableObject
// ✅ Use .environment with @Observable class
ContentView()
.environment(settings)// ❌ Forgetting @Bindable when creating bindings to @Environment observable
struct ProfileView: View {
@Environment(Settings.self) var settings
var body: some View {
TextField("Name", text: $settings.name) // Compiler error
}
}
// ✅ Create local @Bindable
struct ProfileView: View {
@Environment(Settings.self) var settings
var body: some View {
@Bindable var settings = settings
TextField("Name", text: $settings.name)
}
}Checklist
- [ ] Minimum deployment target is iOS 17 / macOS 14
- [ ]
ObservableObjectprotocol replaced with@Observablemacro - [ ] All
@Publishedproperty wrappers removed - [ ]
@StateObjectreplaced with@State - [ ]
@ObservedObjectreplaced with plainvaror@Bindable - [ ]
@EnvironmentObjectreplaced with@Environment(Type.self) - [ ]
.environmentObject()replaced with.environment() - [ ]
@Bindableused where$bindings are needed - [ ] Manual
objectWillChange.send()calls removed - [ ] Combine
$propertypublishers replaced withtask(id:)orAsyncStream - [ ]
AnyCancellablesets removed if Combine is no longer used - [ ]
@ObservationIgnoredadded to properties that should not trigger updates
StoreKit 1 to StoreKit 2 Migration
StoreKit 2 (iOS 15+) replaces the original StoreKit API with a modern, async/await-based API for in-app purchases and subscriptions. Transactions are signed with JWS (JSON Web Signature) for simpler verification, and the entire flow is dramatically simpler than the delegate-based StoreKit 1 approach.
When to Migrate
- You want async/await instead of delegate callbacks for purchase flows.
- You need
Transaction.currentEntitlementsfor simpler entitlement checking. - You want JWS-based transaction verification (local or server-side) instead of receipt parsing.
- You are building new subscription features (offer codes, win-back offers, subscription status).
- You want StoreKit Testing in Xcode for local development without sandbox accounts.
- Your minimum deployment target is iOS 15 or later.
When NOT to Migrate
- You need to support iOS 14 or earlier. StoreKit 2 requires iOS 15+.
- Server-side receipt validation with
/verifyReceiptis deeply integrated and working. Note: Apple deprecated/verifyReceiptand recommends migrating to App Store Server API, but it still works. - Custom receipt validation logic (parsing PKCS#7, ASN.1) that is too complex to rewrite on a tight timeline.
- You are using a third-party SDK (e.g., RevenueCat) that handles StoreKit internally. Check if the SDK already supports StoreKit 2 before migrating yourself.
Concept Mapping
| StoreKit 1 | StoreKit 2 | Notes |
|---|---|---|
SKProduct | Product | Loaded via static method |
SKProductsRequest | Product.products(for:) | Async, no delegate |
SKPayment + SKPaymentQueue.add() | product.purchase() | Async, returns result |
SKPaymentTransaction | Transaction | JWS-signed |
SKPaymentTransactionObserver | Transaction.updates | AsyncSequence |
SKPaymentQueue.restoreCompletedTransactions() | Transaction.currentEntitlements or AppStore.sync() | No restore button needed |
SKReceiptRefreshRequest | AppStore.sync() | Syncs with App Store |
appStoreReceiptURL (receipt file) | Transaction.currentEntitlements | JWS per transaction |
SKStorefront | Storefront.current | Async property |
SKPaymentQueue.canMakePayments() | AppStore.canMakePayments | Same concept |
SKPaymentTransactionState | Product.PurchaseResult + Transaction.VerificationResult | Separated into purchase result and verification |
transaction.finishTransaction() | transaction.finish() | Still required |
Loading Products
Before (StoreKit 1)
class Store: NSObject, SKProductsRequestDelegate {
var products: [SKProduct] = []
func loadProducts() {
let request = SKProductsRequest(productIdentifiers: ["com.app.premium", "com.app.monthly"])
request.delegate = self
request.start()
}
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
products = response.products
// Update UI on main thread
}
func request(_ request: SKRequest, didFailWithError error: Error) {
// Handle error
}
}After (StoreKit 2)
class Store {
var products: [Product] = []
func loadProducts() async throws {
products = try await Product.products(for: ["com.app.premium", "com.app.monthly"])
}
}Purchasing
Before (StoreKit 1)
class Store: NSObject, SKPaymentTransactionObserver {
func purchase(_ product: SKProduct) {
let payment = SKPayment(product: product)
SKPaymentQueue.default().add(payment)
}
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
for transaction in transactions {
switch transaction.transactionState {
case .purchased:
// Validate receipt, unlock content
queue.finishTransaction(transaction)
case .failed:
if let error = transaction.error as? SKError, error.code != .paymentCancelled {
// Show error
}
queue.finishTransaction(transaction)
case .restored:
// Unlock content
queue.finishTransaction(transaction)
case .deferred:
// Ask-to-Buy: waiting for parent approval
break
case .purchasing:
break
@unknown default:
break
}
}
}
}After (StoreKit 2)
class Store {
func purchase(_ product: Product) async throws {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try checkVerified(verification)
// Unlock content
await transaction.finish()
case .userCancelled:
break
case .pending:
// Ask-to-Buy or SCA: waiting for external action
break
@unknown default:
break
}
}
func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let value):
return value
case .unverified(_, let error):
throw error
}
}
}Transaction Listener
StoreKit 1
// AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: ...) -> Bool {
SKPaymentQueue.default().add(transactionObserver)
return true
}
func applicationWillTerminate(_ application: UIApplication) {
SKPaymentQueue.default().remove(transactionObserver)
}StoreKit 2
The transaction listener must start at app launch to handle transactions that complete outside your app (renewals, Ask-to-Buy approvals, refunds, revocations).
@main
struct MyApp: App {
@State private var store = Store()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
.task {
await store.listenForTransactions()
}
}
}
}
@Observable
class Store {
private var updateListenerTask: Task<Void, Never>?
func listenForTransactions() async {
// Listen for transactions that happen outside the app
for await result in Transaction.updates {
do {
let transaction = try checkVerified(result)
await updateEntitlements(for: transaction)
await transaction.finish()
} catch {
// Transaction failed verification
}
}
}
}Critical rules:
- Start listening at app launch, not just when the purchase screen appears.
- Handle
Transaction.updatesfor the entire app lifetime. - Always call
transaction.finish()for every verified transaction. - During migration, this listener handles transactions from both StoreKit 1 and StoreKit 2.
Checking Entitlements
Before (StoreKit 1 -- Receipt Validation)
// Local receipt validation
func validateReceipt() throws -> [String] {
guard let receiptURL = Bundle.main.appStoreReceiptURL,
let receiptData = try? Data(contentsOf: receiptURL) else {
throw ReceiptError.noReceipt
}
// Send to your server
let encodedReceipt = receiptData.base64EncodedString()
// Server calls Apple's /verifyReceipt endpoint
// Server returns parsed receipt with active purchases
// Parse the response to determine entitlements
}After (StoreKit 2 -- Transaction.currentEntitlements)
func updateEntitlements() async {
var activeSubscriptions: Set<String> = []
var purchasedProducts: Set<String> = []
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
switch transaction.productType {
case .autoRenewable:
activeSubscriptions.insert(transaction.productID)
case .nonConsumable:
purchasedProducts.insert(transaction.productID)
default:
break
}
}
// Update app state
self.activeSubscriptions = activeSubscriptions
self.purchasedProducts = purchasedProducts
}Key differences:
- No receipt file to parse.
Transaction.currentEntitlementsprovides all active entitlements. - Each transaction is individually JWS-signed. Verification is built in (
VerificationResult). currentEntitlementsautomatically excludes expired subscriptions, refunded purchases, and revoked transactions.
Subscription Status
StoreKit 2 provides direct access to subscription status:
func checkSubscriptionStatus() async throws -> Product.SubscriptionInfo.Status? {
guard let product = try await Product.products(for: ["com.app.monthly"]).first,
let subscription = product.subscription else {
return nil
}
let statuses = try await subscription.status
// Find the most recent active status
return statuses.first { status in
status.state == .subscribed || status.state == .inGracePeriod
}
}Subscription states: .subscribed, .expired, .inBillingRetryPeriod, .inGracePeriod, .revoked.
Restore Purchases
Before (StoreKit 1)
// Required: "Restore Purchases" button calling:
SKPaymentQueue.default().restoreCompletedTransactions()
// Delegate callbacks:
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { }
func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { }After (StoreKit 2)
// Transaction.currentEntitlements already provides all active purchases.
// A restore button is typically not needed, but if required:
func restorePurchases() async throws {
try await AppStore.sync()
await updateEntitlements() // Re-check currentEntitlements
}AppStore.sync() forces a sync with the App Store. In most cases, Transaction.currentEntitlements is sufficient and stays up to date automatically. Apple still recommends providing a restore mechanism for App Review compliance.
Server-Side Migration
Before (StoreKit 1)
- Fetch receipt from
appStoreReceiptURL - Base64-encode and send to your server
- Server calls Apple's
/verifyReceiptendpoint - Parse the monolithic receipt response
After (StoreKit 2)
- Each
Transactioncontains ajwsRepresentation(signed JSON) - Send the JWS string to your server
- Server verifies the JWS signature using Apple's public key
- Use Apple's App Store Server API for server-to-server operations:
GET /inApps/v1/history/{transactionId}-- transaction historyGET /inApps/v1/subscriptions/{transactionId}-- subscription statusPUT /inApps/v1/refund/lookup/{transactionId}-- refund lookup- Set up App Store Server Notifications V2 for real-time updates:
- Subscription renewals, expirations, billing issues
- Refunds and revocations
- Offer redemptions
// Sending JWS to your server
func sendTransactionToServer(_ transaction: Transaction) async throws {
let jwsRepresentation = transaction.jwsRepresentation
var request = URLRequest(url: serverURL)
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(["jws": jwsRepresentation])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw ServerError.verificationFailed
}
}Testing with StoreKit Configuration File
StoreKit 2 provides a local testing environment that does not require a sandbox account.
Setup
1. File > New > File > StoreKit Configuration File 2. Add products: consumables, non-consumables, auto-renewable subscriptions 3. Edit > Scheme > Run > Options > StoreKit Configuration: select your .storekit file
Testing Scenarios
// In Xcode, you can simulate:
// - Successful purchases
// - Failed purchases
// - Subscription renewals (accelerated time)
// - Subscription cancellation
// - Subscription expiration
// - Refunds
// - Ask-to-Buy (deferred transactions)
// - Interrupted purchases (SCA - Strong Customer Authentication)
// - Price increases requiring consent
// - Offer code redemptionThe StoreKit configuration file lets you test the entire purchase flow locally. Xcode's transaction manager (Debug > StoreKit > Manage Transactions) lets you view, delete, and refund test transactions.
Unit Testing with StoreKit Test
import StoreKitTest
class PurchaseTests: XCTestCase {
var session: SKTestSession!
override func setUp() async throws {
session = try SKTestSession(configurationFileNamed: "Products")
session.disableDialogs = true
session.clearTransactions()
}
func testPurchaseNonConsumable() async throws {
let products = try await Product.products(for: ["com.app.premium"])
let product = try XCTUnwrap(products.first)
let result = try await product.purchase()
guard case .success(let verification) = result,
case .verified(let transaction) = verification else {
XCTFail("Purchase failed")
return
}
XCTAssertEqual(transaction.productID, "com.app.premium")
await transaction.finish()
}
}Phased Rollout Strategy
Both StoreKit APIs share the same underlying payment queue. Purchases made with either API are visible to both. This makes coexistence safe.
Phase 1: Add StoreKit 2 Code Alongside StoreKit 1
Keep existing StoreKit 1 code running. Add StoreKit 2 code for reading entitlements:
@Observable
class Store {
// Existing StoreKit 1 code continues to handle purchases
let paymentQueue = SKPaymentQueue.default()
// New: use StoreKit 2 for checking entitlements
func checkEntitlements() async {
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
// Update entitlements using StoreKit 2 data
}
}
}
}Phase 2: New Users Use StoreKit 2 Purchase Flow
Route new purchases through StoreKit 2 while keeping StoreKit 1 as a fallback:
func purchase(_ product: Product) async throws {
if #available(iOS 15, *) {
// StoreKit 2 purchase flow
let result = try await product.purchase()
// Handle result...
}
}Phase 3: Verify Entitlements Match
Run both systems in parallel and verify they agree:
func verifyEntitlementParity() async {
// StoreKit 2 entitlements
var sk2Entitlements: Set<String> = []
for await result in Transaction.currentEntitlements {
if case .verified(let transaction) = result {
sk2Entitlements.insert(transaction.productID)
}
}
// Compare with your existing entitlement system
let existingEntitlements = Set(legacyEntitlementManager.activeProductIDs)
if sk2Entitlements != existingEntitlements {
// Log discrepancy for investigation
logger.warning("Entitlement mismatch: SK2=\(sk2Entitlements), Legacy=\(existingEntitlements)")
}
}Phase 4: Remove StoreKit 1 Code
After verifying all transactions are handled correctly: 1. Remove SKPaymentTransactionObserver conformance 2. Remove SKProductsRequestDelegate code 3. Remove SKPaymentQueue usage 4. Remove receipt validation code (appStoreReceiptURL, /verifyReceipt calls) 5. Remove StoreKit import and re-add only StoreKit (StoreKit 2 types are in the same module)
Common Mistakes
// ❌ Not calling transaction.finish() (transaction stays pending, may re-deliver)
let result = try await product.purchase()
if case .success(let verification) = result {
let transaction = try checkVerified(verification)
unlockContent(for: transaction.productID)
// Missing: await transaction.finish()
}
// ✅ Always finish verified transactions
if case .success(let verification) = result {
let transaction = try checkVerified(verification)
unlockContent(for: transaction.productID)
await transaction.finish() // Required
}// ❌ Not listening for Transaction.updates at app startup
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
// Missing: .task { await store.listenForTransactions() }
}
}
}
// ✅ Start listening at app launch
struct MyApp: App {
@State private var store = Store()
var body: some Scene {
WindowGroup {
ContentView()
.task {
await store.listenForTransactions()
}
}
}
}// ❌ Ignoring unverified transactions silently
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue // Silently ignoring -- could hide real issues
}
}
// ✅ Log unverified transactions for debugging
for await result in Transaction.currentEntitlements {
switch result {
case .verified(let transaction):
// Process transaction
break
case .unverified(let transaction, let error):
logger.error("Unverified transaction \(transaction.productID): \(error)")
}
}// ❌ Using restoreCompletedTransactions() with StoreKit 2
SKPaymentQueue.default().restoreCompletedTransactions() // StoreKit 1 API
// ✅ Use Transaction.currentEntitlements or AppStore.sync()
try await AppStore.sync()
await updateEntitlements()// ❌ Assuming StoreKit config file behavior matches production exactly
// Local testing does not validate with App Store servers
// Subscription timing is accelerated (1 hour = a few minutes)
// ✅ Also test in sandbox environment before release
// Use sandbox accounts to verify real App Store behavior
// Test on device, not just simulator, for production-like behaviorChecklist
- [ ] Minimum deployment target is iOS 15+
- [ ]
Product.products(for:)replacesSKProductsRequest - [ ]
product.purchase()replacesSKPaymentQueue.add() - [ ]
Transaction.updateslistener running from app startup - [ ]
Transaction.currentEntitlementsused for entitlement checking - [ ]
transaction.finish()called for every processed transaction - [ ]
VerificationResultchecked for all transactions (.verifiedvs.unverified) - [ ] Receipt validation replaced with JWS verification (local or server-side)
- [ ] Server-side migrated to App Store Server API (from
/verifyReceipt) - [ ] App Store Server Notifications V2 configured (if using server notifications)
- [ ] StoreKit configuration file created for local testing
- [ ] Restore purchases uses
AppStore.sync()orTransaction.currentEntitlements - [ ] Phased rollout plan: both APIs coexist before removing StoreKit 1 code
- [ ] Entitlement parity verified between old and new systems
- [ ] Tested in sandbox environment (not just StoreKit config file)
UIKit to SwiftUI Migration
SwiftUI is Apple's declarative UI framework. Migration from UIKit is best done incrementally -- screen by screen -- using bridging APIs that let both frameworks coexist.
Concept Mapping
| UIKit | SwiftUI | Notes |
|---|---|---|
UIViewController | View struct | No lifecycle methods, use onAppear/task |
UINavigationController | NavigationStack | iOS 16+ for modern API |
UITabBarController | TabView | iOS 18+ for customizable tabs |
UITableView | List | Automatic diffing, no data source protocol |
UICollectionView | LazyVGrid / LazyHGrid | Or List for simple layouts |
UIStackView | VStack / HStack / ZStack | Declarative, no constraints |
UILabel | Text | Supports Markdown, attributed strings |
UIButton | Button | Action closure, not target-action |
UITextField | TextField | Two-way binding with $ |
UITextView | TextEditor | iOS 16+ for styled editing |
UIImageView | Image / AsyncImage | AsyncImage for remote URLs |
UIAlertController | .alert() / .confirmationDialog() | View modifier, not presented |
UIActivityIndicatorView | ProgressView | Determinate and indeterminate |
| Auto Layout constraints | Stack-based layout | No explicit constraints |
| Storyboards / XIBs | Swift code (declarative) | No Interface Builder |
| Delegate pattern | Closures, bindings, @Environment | No protocol conformance needed |
viewDidLoad | onAppear / task | task for async work |
viewWillAppear | onAppear | Called on every appearance |
viewDidDisappear | onDisappear | Called on every disappearance |
prepare(for segue:) | NavigationLink(value:) | Type-safe navigation |
UIHostingController: Embedding SwiftUI in UIKit
This is the primary tool for incremental migration. Wrap any SwiftUI view in a UIHostingController to use it inside a UIKit app.
Basic Usage
import SwiftUI
class MyViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let swiftUIView = ItemListView()
let hostingController = UIHostingController(rootView: swiftUIView)
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingController.didMove(toParent: self)
}
}Passing Data Back to UIKit
Use closures or a shared @Observable model:
// Closure approach
struct ItemListView: View {
var onItemSelected: (Item) -> Void
var body: some View {
List(items) { item in
Button(item.title) {
onItemSelected(item)
}
}
}
}
// In UIKit
let view = ItemListView { [weak self] item in
self?.navigateToDetail(item)
}
let hostingController = UIHostingController(rootView: view)// @Observable approach (iOS 17+)
@Observable
class AppState {
var selectedItem: Item?
}
// Shared between UIKit and SwiftUI
let state = AppState()
// SwiftUI reads/writes state
struct ItemListView: View {
@Bindable var state: AppState
var body: some View {
List(items) { item in
Button(item.title) { state.selectedItem = item }
}
}
}
// UIKit observes changes via withObservationTracking or KVOSizing
By default, UIHostingController sizes to fit its content. To control sizing:
hostingController.sizingOptions = .preferredContentSize // Uses preferredContentSize
hostingController.view.invalidateIntrinsicContentSize()UIViewRepresentable: Wrapping UIKit Views in SwiftUI
Use this when UIKit has a component SwiftUI does not (e.g., MKMapView, WKWebView, custom UIKit views).
Basic Pattern
struct MapView: UIViewRepresentable {
let coordinate: CLLocationCoordinate2D
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
let region = MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
mapView.setRegion(region, animated: true)
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, MKMapViewDelegate {
func mapView(_ mapView: MKMapView, didSelect annotation: any MKAnnotation) {
// Handle selection
}
}
}Key points:
makeUIViewcreates the UIKit view once.updateUIViewis called when SwiftUI state changes -- update the UIKit view here.Coordinatorbridges UIKit delegate callbacks to SwiftUI.- Never store SwiftUI state in the coordinator; pass it through the representable.
Common Mistake: Not Using Coordinator for Delegates
// ❌ Setting delegate to self (View is a struct, can't be a delegate)
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = self // Compiler error: struct cannot conform to NSObjectProtocol
return mapView
}
}
// ✅ Use Coordinator
struct MapView: UIViewRepresentable {
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func makeCoordinator() -> Coordinator { Coordinator() }
class Coordinator: NSObject, MKMapViewDelegate { }
}UIViewControllerRepresentable: Wrapping UIKit View Controllers in SwiftUI
For embedding entire UIKit view controllers (e.g., UIImagePickerController, PHPickerViewController):
struct ImagePicker: UIViewControllerRepresentable {
@Binding var selectedImage: UIImage?
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
config.selectionLimit = 1
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { }
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
if let provider = results.first?.itemProvider,
provider.canLoadObject(ofClass: UIImage.self) {
provider.loadObject(ofClass: UIImage.self) { image, _ in
DispatchQueue.main.async {
self.parent.selectedImage = image as? UIImage
}
}
}
parent.dismiss()
}
}
}Navigation Migration
Before (UIKit)
class ItemListViewController: UITableViewController {
var items: [Item] = []
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let item = items[indexPath.row]
let detailVC = ItemDetailViewController(item: item)
navigationController?.pushViewController(detailVC, animated: true)
}
}After (SwiftUI)
struct ItemListView: View {
let items: [Item]
var body: some View {
NavigationStack {
List(items) { item in
NavigationLink(value: item) {
ItemRow(item: item)
}
}
.navigationTitle("Items")
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
}
}Tab Bar Migration
// Before (UIKit)
let tabBarController = UITabBarController()
tabBarController.viewControllers = [
UINavigationController(rootViewController: HomeViewController()),
UINavigationController(rootViewController: SearchViewController()),
UINavigationController(rootViewController: ProfileViewController()),
]
// After (SwiftUI, iOS 18+)
TabView {
Tab("Home", systemImage: "house") {
NavigationStack { HomeView() }
}
Tab("Search", systemImage: "magnifyingglass") {
NavigationStack { SearchView() }
}
Tab("Profile", systemImage: "person") {
NavigationStack { ProfileView() }
}
}Table View / Collection View Migration
UITableView to List
// Before (UIKit) -- requires UITableViewDataSource, UITableViewDelegate
class ItemListViewController: UITableViewController {
var items: [Item] = []
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
items.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = items[indexPath.row].title
return cell
}
}
// After (SwiftUI) -- no data source, no cell reuse, no index paths
struct ItemListView: View {
let items: [Item]
var body: some View {
List(items) { item in
Text(item.title)
}
}
}UICollectionView to LazyVGrid
// Before (UIKit) -- UICollectionViewDataSource, UICollectionViewFlowLayout, etc.
// (50+ lines of boilerplate)
// After (SwiftUI)
struct PhotoGrid: View {
let photos: [Photo]
let columns = [GridItem(.adaptive(minimum: 100))]
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(photos) { photo in
AsyncImage(url: photo.url) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
ProgressView()
}
.frame(height: 100)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding()
}
}
}Delegate Pattern Migration
Before (UIKit Delegate)
protocol ItemSelectionDelegate: AnyObject {
func didSelectItem(_ item: Item)
func didDeselectItem(_ item: Item)
}
class ItemPickerViewController: UIViewController {
weak var delegate: ItemSelectionDelegate?
func selectItem(_ item: Item) {
delegate?.didSelectItem(item)
}
}After (SwiftUI Closure / Binding)
// Option 1: Closure
struct ItemPicker: View {
let items: [Item]
var onSelect: (Item) -> Void
var body: some View {
List(items) { item in
Button(item.title) { onSelect(item) }
}
}
}
// Usage
ItemPicker(items: items) { item in
selectedItem = item
}// Option 2: Binding (two-way)
struct ItemPicker: View {
let items: [Item]
@Binding var selection: Item?
var body: some View {
List(items, selection: $selection) { item in
Text(item.title)
}
}
}
// Usage
@State private var selection: Item?
ItemPicker(items: items, selection: $selection)Lifecycle Migration
// UIKit lifecycle
class MyViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
loadInitialData()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
refreshData()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
cancelTasks()
}
}
// SwiftUI equivalent
struct MyView: View {
@State private var data: [Item] = []
var body: some View {
List(data) { item in
ItemRow(item: item)
}
.task {
// Replaces viewDidLoad for async work
// Automatically cancelled when view disappears
data = await loadInitialData()
}
.onAppear {
// Replaces viewWillAppear for sync work
refreshData()
}
.onDisappear {
// Replaces viewDidDisappear
cancelTasks()
}
}
}Key differences:
taskis preferred overonAppearfor async work. It is automatically cancelled when the view is removed.- SwiftUI views are structs, not classes. No inheritance, no
supercalls. - There is no
viewDidLoadequivalent because SwiftUI views are recreated frequently. UsetaskoronAppear.
Incremental Adoption Strategy
Recommended Order
1. Start with leaf screens. Migrate simple detail views first (settings, about, profile). These have few dependencies.
2. Move to list screens. Replace UITableViewController / UICollectionViewController with SwiftUI List or LazyVGrid views wrapped in UIHostingController.
3. Migrate navigation last. Keep UIKit navigation (UINavigationController, UITabBarController) as the container while migrating individual screens to SwiftUI. Only replace the navigation layer when most screens are SwiftUI.
4. Replace the App/Scene delegate last. Move to @main struct with SwiftUI App and Scene once the entire UI is SwiftUI.
Screen-by-Screen Pattern
// Step 1: Replace a UIKit view controller with a SwiftUI screen
// in the UIKit navigation flow
class SettingsViewController: UIViewController {
// OLD: 200 lines of UIKit code
// NEW: Replace entire body with UIHostingController
override func viewDidLoad() {
super.viewDidLoad()
let settingsView = SettingsView()
let hosting = UIHostingController(rootView: settingsView)
addChild(hosting)
view.addSubview(hosting.view)
hosting.view.frame = view.bounds
hosting.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hosting.didMove(toParent: self)
}
}This lets you migrate one screen at a time while the rest of the app stays UIKit.
When NOT to Migrate
Stay on UIKit (or keep a hybrid) if:
- Your minimum deployment target is below iOS 15 (SwiftUI is usable but limited before iOS 15).
- You rely on advanced UIKit features without SwiftUI equivalents (e.g., complex
UICollectionViewCompositionalLayout, customUIViewControllertransitions with interactive gestures). - Your team is deeply experienced in UIKit and the app is stable. Migration is not free.
- You have a large codebase with many custom UIKit subclasses. Incremental migration will take months/years.
- Performance-critical views (e.g., custom OpenGL/Metal rendering) are better managed directly in UIKit.
It is perfectly fine to have a hybrid app indefinitely. Apple supports both frameworks and they interoperate well.
Common Mistakes
// ❌ Putting NavigationStack inside UINavigationController (double navigation bar)
// UIKit side:
let swiftUIView = NavigationStack { ContentView() } // Has its own nav bar
let hosting = UIHostingController(rootView: swiftUIView)
navigationController?.pushViewController(hosting, animated: true) // UIKit nav bar too
// ✅ If UIKit provides navigation, don't add NavigationStack
let swiftUIView = ContentView() // No NavigationStack
let hosting = UIHostingController(rootView: swiftUIView)
navigationController?.pushViewController(hosting, animated: true)// ❌ Trying to access UIKit view hierarchy from SwiftUI
struct MyView: View {
var body: some View {
Text("Hello")
.onAppear {
// Cannot access self.view, superview, etc.
}
}
}
// ✅ Use UIViewRepresentable if you need UIKit access// ❌ Migrating everything at once in a big-bang rewrite
// This almost always fails for non-trivial apps
// ✅ Migrate incrementally, one screen at a timeChecklist
- [ ] Identified leaf screens to migrate first
- [ ] UIHostingController used to embed SwiftUI in UIKit navigation
- [ ] UIViewRepresentable / UIViewControllerRepresentable for UIKit components needed in SwiftUI
- [ ] Delegates replaced with closures or bindings
- [ ] No double navigation bars (NavigationStack inside UINavigationController)
- [ ] Lifecycle methods mapped to onAppear / task / onDisappear
- [ ] Shared state uses @Observable (iOS 17+) or ObservableObject for both frameworks
- [ ] Each migrated screen tested independently before moving to next
- [ ] UIKit navigation container kept until majority of screens are SwiftUI
XCTest to Swift Testing Migration
Swift Testing is a modern testing framework (Xcode 16 / Swift 6.0) that replaces XCTest with a more expressive, macro-based API. Both frameworks can coexist in the same test target.
Concept Mapping
| XCTest | Swift Testing | Notes |
|---|---|---|
import XCTest | import Testing | New module |
class MyTests: XCTestCase | struct MyTests or @Suite struct MyTests | Structs preferred, no inheritance |
func testSomething() | @Test func something() | No test prefix required |
XCTAssertEqual(a, b) | #expect(a == b) | Single macro for all comparisons |
XCTAssertTrue(x) | #expect(x) | Same macro |
XCTAssertFalse(x) | #expect(!x) | Same macro |
XCTAssertNil(x) | #expect(x == nil) | Same macro |
XCTAssertNotNil(x) | #expect(x != nil) | Same macro |
XCTAssertGreaterThan(a, b) | #expect(a > b) | Same macro |
XCTAssertThrowsError(expr) | #expect(throws: MyError.self) { expr } | Type-safe |
XCTAssertNoThrow(expr) | #expect(throws: Never.self) { expr } | Explicit "no throw" |
XCTUnwrap(optional) | try #require(optional) | Throws on nil, stops test |
XCTFail("message") | Issue.record("message") | Records a test failure |
setUp() / setUpWithError() | init() / init() throws | Struct initializer |
tearDown() | deinit (class) or not needed (struct) | Structs clean up automatically |
setUpWithError() throws | init() async throws | Async init supported |
addTeardownBlock { } | defer { } or test cleanup in deinit | Standard Swift patterns |
func testPerformance() with measure { } | Not available | Use XCTest for performance tests |
XCTestExpectation + wait(for:) | Built-in async/await | @Test func x() async { } |
continueAfterFailure = false | try #require() | Stops test on failure |
| Test class inheritance | @Suite with shared init | No inheritance, use composition |
Basic Migration
Before (XCTest)
import XCTest
class CalculatorTests: XCTestCase {
var calculator: Calculator!
override func setUp() {
calculator = Calculator()
}
override func tearDown() {
calculator = nil
}
func testAddition() {
let result = calculator.add(2, 3)
XCTAssertEqual(result, 5)
}
func testDivisionByZero() {
XCTAssertThrowsError(try calculator.divide(10, by: 0)) { error in
XCTAssertEqual(error as? CalculatorError, .divisionByZero)
}
}
func testOptionalResult() throws {
let result = try XCTUnwrap(calculator.lastResult)
XCTAssertGreaterThan(result, 0)
}
}After (Swift Testing)
import Testing
@Suite
struct CalculatorTests {
let calculator: Calculator
init() {
calculator = Calculator()
}
// No tearDown needed -- struct deallocates automatically
@Test
func addition() {
let result = calculator.add(2, 3)
#expect(result == 5)
}
@Test
func divisionByZero() {
#expect(throws: CalculatorError.divisionByZero) {
try calculator.divide(10, by: 0)
}
}
@Test
func optionalResult() throws {
let result = try #require(calculator.lastResult)
#expect(result > 0)
}
}Key differences:
- Struct instead of class. No inheritance from
XCTestCase. init()replacessetUp(). NotearDown()needed for structs.@Testattribute replacestestprefix naming convention.#expectreplaces allXCTAssert*variants with natural Swift expressions.#requirereplacesXCTUnwrapand also stops the test on failure (likecontinueAfterFailure = false).
#expect: The Universal Assertion
#expect accepts any boolean expression. When it fails, Swift Testing shows the actual values in the failure message.
// All of these work with #expect
#expect(result == 42)
#expect(result != 0)
#expect(array.count > 5)
#expect(string.contains("hello"))
#expect(array.isEmpty)
#expect(!flag)
#expect(value >= minimum && value <= maximum)Failure messages show values automatically:
Expectation failed: (result == 42)
result: 37No need for custom messages in most cases. If you want one:
#expect(result == 42, "Expected 42 but got \(result) for input \(input)")#require: Stop Test on Failure
#require works like #expect but throws on failure, stopping the test. Use it when subsequent assertions depend on this one passing.
@Test
func userProfile() throws {
let user = try #require(fetchUser(id: 123)) // Stops test if nil
#expect(user.name == "Alice") // Only runs if user was found
#expect(user.email.contains("@"))
}This replaces two XCTest patterns:
XCTUnwrapfor unwrapping optionalscontinueAfterFailure = falsefor stopping on first failure
Async Tests
Before (XCTest)
class NetworkTests: XCTestCase {
func testFetchUser() {
let expectation = expectation(description: "Fetch user")
var fetchedUser: User?
networkService.fetchUser(id: 123) { result in
if case .success(let user) = result {
fetchedUser = user
}
expectation.fulfill()
}
wait(for: [expectation], timeout: 5.0)
XCTAssertNotNil(fetchedUser)
XCTAssertEqual(fetchedUser?.name, "Alice")
}
}After (Swift Testing)
@Suite
struct NetworkTests {
@Test
func fetchUser() async throws {
let user = try await networkService.fetchUser(id: 123)
#expect(user.name == "Alice")
}
}No expectations, no callbacks, no timeouts. Just async/await.
Parameterized Tests
Swift Testing supports parameterized tests natively. This replaces the XCTest pattern of writing multiple near-identical test methods.
Before (XCTest)
class ParserTests: XCTestCase {
func testParseInteger() {
XCTAssertEqual(parse("42"), .integer(42))
}
func testParseNegativeInteger() {
XCTAssertEqual(parse("-7"), .integer(-7))
}
func testParseZero() {
XCTAssertEqual(parse("0"), .integer(0))
}
func testParseFloat() {
XCTAssertEqual(parse("3.14"), .float(3.14))
}
}After (Swift Testing)
@Suite
struct ParserTests {
@Test(arguments: [
("42", Token.integer(42)),
("-7", Token.integer(-7)),
("0", Token.integer(0)),
("3.14", Token.float(3.14)),
])
func parseToken(input: String, expected: Token) {
#expect(parse(input) == expected)
}
}Each argument combination runs as a separate test case in the test navigator.
Multiple Argument Collections
@Test(arguments: ["GET", "POST", "PUT"], [200, 404, 500])
func httpResponse(method: String, statusCode: Int) async throws {
let response = try await makeRequest(method: method, expectedStatus: statusCode)
#expect(response.statusCode == statusCode)
}
// Runs 9 test cases (3 methods x 3 status codes)Zip Instead of Cartesian Product
@Test(arguments: zip(["GET", "POST"], [200, 201]))
func httpResponse(method: String, statusCode: Int) async throws {
// Runs 2 test cases: (GET, 200) and (POST, 201)
}Tags for Organization
Tags replace XCTest's informal naming conventions for organizing tests.
extension Tag {
@Tag static var networking: Self
@Tag static var database: Self
@Tag static var ui: Self
@Tag static var slow: Self
}
@Suite
struct APITests {
@Test(.tags(.networking))
func fetchUsers() async throws {
// ...
}
@Test(.tags(.networking, .slow))
func uploadLargeFile() async throws {
// ...
}
}
@Suite(.tags(.database)) // All tests in suite get this tag
struct DatabaseTests {
@Test
func insertRecord() throws {
// Inherits .database tag
}
}You can filter by tag in Xcode's test navigator or via command line:
swift test --filter .tags:networkingDisplay Names
@Test("Addition of two positive integers")
func addition() {
#expect(Calculator.add(2, 3) == 5)
}
@Suite("Calculator Edge Cases")
struct EdgeCases {
@Test("Division by zero throws an error")
func divisionByZero() {
#expect(throws: CalculatorError.divisionByZero) {
try Calculator.divide(10, by: 0)
}
}
}Test Conditions
Run tests conditionally based on runtime conditions:
@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] != nil))
func integrationTest() async throws {
// Only runs on CI
}
@Test(.disabled("Server is down for maintenance"))
func serverTest() async throws {
// Skipped with a reason
}
@Test(.bug("https://github.com/org/repo/issues/123", "Crashes on empty input"))
func knownBugTest() {
// Marked as related to a known bug
}Suites
@Suite groups related tests. Suites can be nested:
@Suite
struct MathTests {
@Suite
struct AdditionTests {
@Test func positiveNumbers() { #expect(add(2, 3) == 5) }
@Test func negativeNumbers() { #expect(add(-2, -3) == -5) }
}
@Suite
struct MultiplicationTests {
@Test func positiveNumbers() { #expect(multiply(2, 3) == 6) }
@Test func byZero() { #expect(multiply(5, 0) == 0) }
}
}Shared Setup via Init
@Suite
struct DatabaseTests {
let database: Database
init() async throws {
database = try await Database.createTestDatabase()
try await database.migrate()
}
@Test
func insertUser() async throws {
try await database.insert(User(name: "Alice"))
let count = try await database.count(User.self)
#expect(count == 1)
}
@Test
func deleteUser() async throws {
let user = User(name: "Bob")
try await database.insert(user)
try await database.delete(user)
let count = try await database.count(User.self)
#expect(count == 0)
}
}Each @Test function gets its own instance of the @Suite struct, so each test gets a fresh database.
Coexistence
XCTest and Swift Testing can coexist in the same test target. This is the recommended approach for incremental migration.
// Same test target can have both:
// XCTest (keep existing tests)
import XCTest
class LegacyTests: XCTestCase {
func testOldFeature() {
XCTAssertEqual(1 + 1, 2)
}
}
// Swift Testing (new tests and migrated tests)
import Testing
@Suite
struct NewTests {
@Test
func newFeature() {
#expect(1 + 1 == 2)
}
}Rules for coexistence:
- Both
import XCTestandimport Testingcan appear in different files in the same target. - Do NOT mix them in the same file. Each file should use one framework.
- Do NOT subclass
XCTestCaseand use@Testin the same type. - XCTest performance tests (
measure { }) must stay in XCTest. Swift Testing does not have a performance measurement API.
Migration Order
1. Start with simple assertion-only tests (no setUp/tearDown, no expectations). 2. Then migrate tests with setUp/tearDown (convert to struct init). 3. Then migrate async tests with XCTestExpectation (convert to async/await). 4. Leave performance tests in XCTest. 5. Delete XCTest imports only when all tests in a file are migrated.
When NOT to Migrate
Stay on XCTest if:
- You need performance testing (
measure { }) -- Swift Testing does not support this. - You need UI testing (
XCUIApplication,XCUIElement) -- these are XCTest-only. - Your CI system does not yet support Xcode 16 / Swift 6.0.
- You rely on
XCTestCasesubclassing for shared test infrastructure across many test classes. - Your test target uses Objective-C test helpers that rely on
XCTestCase.
Common Mistakes
// ❌ Using XCTest assertions in Swift Testing
import Testing
@Test
func myTest() {
XCTAssertEqual(1, 1) // Wrong framework's assertion
}
// ✅ Use #expect
import Testing
@Test
func myTest() {
#expect(1 == 1)
}// ❌ Prefixing test functions with "test" (unnecessary but harmless)
@Test
func testAddition() { } // Works but the "test" prefix is redundant
// ✅ Clean function name
@Test
func addition() { }
// ✅ Or use a display name
@Test("Addition of positive integers")
func addition() { }// ❌ Using XCTestExpectation for async tests
@Test
func fetchData() {
// Cannot use XCTestExpectation in Swift Testing
}
// ✅ Use async/await
@Test
func fetchData() async throws {
let data = try await service.fetch()
#expect(!data.isEmpty)
}// ❌ Using class with var properties for test state
@Suite
class MyTests {
var counter = 0
@Test
func incrementOnce() {
counter += 1
#expect(counter == 1) // May fail if tests share state
}
}
// ✅ Use struct -- each test gets its own instance
@Suite
struct MyTests {
var counter = 0
@Test
mutating func incrementOnce() {
counter += 1
#expect(counter == 1) // Always passes, fresh instance per test
}
}// ❌ Mixing XCTest and Swift Testing in the same type
class MyTests: XCTestCase {
@Test // Cannot use @Test on XCTestCase methods
func something() { }
}
// ✅ Keep them in separate types (can be in the same file or different files)
class LegacyTests: XCTestCase {
func testSomething() { XCTAssertTrue(true) }
}
@Suite
struct ModernTests {
@Test func something() { #expect(true) }
}Checklist
- [ ] Xcode 16+ / Swift 6.0+ available
- [ ]
import Testingadded (not replacingimport XCTestin existing files -- use new files) - [ ] Test classes converted to structs with
@Suite - [ ]
setUp()converted toinit()(orinit() async throws) - [ ]
tearDown()removed (structs clean up automatically) - [ ]
XCTAssert*replaced with#expect - [ ]
XCTUnwrapreplaced withtry #require - [ ]
XCTestExpectation+wait(for:)replaced withasync/await - [ ] Duplicate test methods replaced with
@Test(arguments:)parameterized tests - [ ] Tags added for test organization
- [ ] Performance tests left in XCTest (no Swift Testing equivalent)
- [ ] UI tests left in XCTest (no Swift Testing equivalent)
- [ ] No mixing of
XCTestCaseand@Testin the same type