After working with iOS for 8 years, I've been part of several UIKit to SwiftUI migrations. The most challenging one involved a production app with over 100,000 lines of UIKit code, complex custom UI components, and a user base that couldn't tolerate disruptions. Here's the strategic framework I developed for tackling this kind of migration without burning down your project.
Why This Isn't Just "Rewrite Everything in SwiftUI"
The biggest mistake I see teams make is treating SwiftUI migration as a binary choice: stay in UIKit or rewrite everything. That's a false dichotomy.
The reality:
- Complete rewrites rarely succeed (see Netscape, Basecamp stories)
- UIKit isn't going away anytime soon
- SwiftUI and UIKit can coexist peacefully
- Incremental migration reduces risk dramatically
Your goal isn't to eliminate all UIKit codeβit's to strategically introduce SwiftUI where it provides the most value while maintaining a stable, shippable product throughout the transition.
The Migration Framework: 5 Phases
Phase 1: Foundation and Preparation (2-4 weeks)
Before writing a single line of SwiftUI, you need to prepare your codebase and team.
1.1 Audit Your Current Architecture
Create a map of your app's architecture:
// Document your current structure
App Architecture Audit
βββ View Controllers (127 files)
β βββ Simple views (good candidates): 45
β βββ Complex views (migrate later): 52
β βββ Legacy code (refactor first): 30
βββ Custom UI Components (89 files)
β βββ UIKit wrappers needed: 23
β βββ Can be SwiftUI native: 66
βββ Data Layer
β βββ Network (already clean): β
β βββ Persistence (needs some work): β οΈ
β βββ Business logic (mixed with UI): β
βββ Dependencies
βββ Third-party libraries: 34
βββ SwiftUI compatibility: Need to verify
1.2 Establish Minimum iOS Version
SwiftUI evolves rapidly. You need to decide:
// iOS 14+: Basic SwiftUI, some limitations
// iOS 15+: Better List performance, async/await
// iOS 16+: NavigationStack, Layout protocol
// iOS 17+: Observable macro, animations improvements
// For production apps, I recommend iOS 15+ minimum
// Gives you 90%+ market coverage and mature SwiftUI APIs
Decision Matrix:
- Check your user base iOS distribution
- Balance features needed vs. user coverage
- Consider support timeline (2-3 years forward)
My recommendation: iOS 15+ for new migrations in 2024
1.3 Create a SwiftUI Style Guide
Before anyone writes SwiftUI code, establish standards:
// β
GOOD: Consistent, predictable patterns
struct UserProfileView: View {
@StateObject private var viewModel: UserProfileViewModel
var body: some View {
content
.navigationTitle("Profile")
}
@ViewBuilder
private var content: some View {
ScrollView {
VStack(spacing: 16) {
headerSection
detailsSection
actionsSection
}
.padding()
}
}
private var headerSection: some View {
// Component implementation
}
}
// β AVOID: Everything in body, hard to read/test
struct UserProfileView: View {
@StateObject private var viewModel: UserProfileViewModel
var body: some View {
ScrollView {
VStack {
if let user = viewModel.user {
HStack {
AsyncImage(url: user.imageURL) { image in
image.resizable()
} placeholder: {
ProgressView()
}
.frame(width: 80, height: 80)
// 200 more lines of UI code...
}
}
}
}
}
}
1.4 Set Up Bridging Infrastructure
Create the bridge between UIKit and SwiftUI:
// UIHostingController wrapper for consistency
class HostingController: UIHostingController {
override func viewDidLoad() {
super.viewDidLoad()
// Consistent appearance across app
view.backgroundColor = .systemBackground
// Disable safe area if managed by UIKit
if #available(iOS 16.0, *) {
sizingOptions = .intrinsicContentSize
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Sync navigation bar appearance
navigationController?.navigationBar.prefersLargeTitles = false
}
}
// Usage wrapper for easy integration
extension UIViewController {
func embedSwiftUIView(_ view: Content) -> UIViewController {
let hosting = HostingController(rootView: view)
addChild(hosting)
self.view.addSubview(hosting.view)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hosting.view.topAnchor.constraint(equalTo: self.view.topAnchor),
hosting.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
hosting.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor)
])
hosting.didMove(toParent: self)
return hosting
}
}
Phase 2: Start with New Features (Weeks 1-8)
Don't touch existing code yet. Prove SwiftUI works with new features first.
2.1 Choose the Right First Feature
Ideal characteristics:
- Relatively isolated (minimal dependencies)
- Not on critical path (safe to experiment)
- Visible to users (builds team excitement)
- Has clear success metrics
Example from my experience:
We chose a new "Activity Feed" feature. It was:
- New code (no migration needed)
- Standalone screen (easy to isolate)
- Good SwiftUI use case (dynamic list with updates)
- Non-critical (main flows unaffected)
Phase 3: Strategic View Migration (Months 2-6)
Now start migrating existing screens, but be strategic about it.
3.1 Prioritization Framework
Score each view controller on these dimensions:
// Migration Priority Calculator
struct ViewControllerMigrationScore {
let name: String
let complexity: Int // 1-10 (lower is easier)
let userImpact: Int // 1-10 (higher is more visible)
let uiKitDependencies: Int // Number of UIKit-specific features
let businessValue: Int // 1-10 (strategic importance)
let bugCount: Int // Known issues that migration might fix
var migrationScore: Double {
let complexityPenalty = Double(complexity) * -1.5
let userImpactBonus = Double(userImpact) * 2.0
let dependencyPenalty = Double(uiKitDependencies) * -1.0
let businessValueBonus = Double(businessValue) * 1.5
let bugBonus = Double(bugCount) * 0.5
return userImpactBonus + businessValueBonus + bugBonus +
complexityPenalty + dependencyPenalty
}
}
Migration order: Highest score to lowest
3.2 The Incremental Migration Pattern
Don't rewrite entire view controllers at once. Use composition:
// Phase 1: Keep UIKit shell, SwiftUI content
class ProfileViewController: UIViewController {
private var hostingController: UIHostingController?
override func viewDidLoad() {
super.viewDidLoad()
// Keep UIKit navigation bar customization
setupNavigationBar()
// Embed SwiftUI content
let swiftUIView = ProfileContentView(userId: userId)
let hosting = UIHostingController(rootView: swiftUIView)
addChild(hosting)
view.addSubview(hosting.view)
// ... layout code ...
hosting.didMove(toParent: self)
hostingController = hosting
}
private func setupNavigationBar() {
// Complex UIKit navigation bar customization
// That's hard to replicate in SwiftUI
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "ellipsis"),
style: .plain,
target: self,
action: #selector(showMenu)
)
}
}
// Phase 2: Eventually migrate to full SwiftUI when navigation supports it
struct ProfileView: View {
let userId: String
var body: some View {
ProfileContentView(userId: userId)
.navigationTitle("Profile")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
// Show menu
} label: {
Image(systemName: "ellipsis")
}
}
}
}
}
Phase 4: Data Layer Modernization (Parallel with Phase 3)
SwiftUI works best with clean data flow. Use migration as an opportunity to modernize.
4.1 From Delegates to Combine/Async-Await
// OLD: Delegate-based networking
protocol NetworkServiceDelegate: AnyObject {
func didReceiveUsers(_ users: [User])
func didFailWithError(_ error: Error)
}
class NetworkService {
weak var delegate: NetworkServiceDelegate?
func fetchUsers() {
// ... URLSession code ...
}
}
// NEW: Async/await based
actor NetworkService {
func fetchUsers() async throws -> [User] {
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([User].self, from: data)
}
}
// SwiftUI usage
struct UsersView: View {
@State private var users: [User] = []
@State private var isLoading = false
@State private var error: Error?
var body: some View {
List(users) { user in
UserRow(user: user)
}
.task {
await loadUsers()
}
}
private func loadUsers() async {
isLoading = true
defer { isLoading = false }
do {
users = try await NetworkService.shared.fetchUsers()
} catch {
self.error = error
}
}
}
4.2 Observable Architecture
// Modern observable pattern with iOS 17+
@Observable
class ProfileViewModel {
var user: User?
var isLoading = false
var error: Error?
private let service: UserService
init(service: UserService = .shared) {
self.service = service
}
func loadUser(id: String) async {
isLoading = true
defer { isLoading = false }
do {
user = try await service.fetchUser(id: id)
} catch {
self.error = error
}
}
}
// Usage in SwiftUI - automatic updates
struct ProfileView: View {
@State private var viewModel = ProfileViewModel()
let userId: String
var body: some View {
Group {
if viewModel.isLoading {
ProgressView()
} else if let user = viewModel.user {
UserDetailsView(user: user)
} else if viewModel.error != nil {
ErrorView()
}
}
.task {
await viewModel.loadUser(id: userId)
}
}
}
Phase 5: Navigation Unification (Months 6-12)
The final piece is unifying navigation between UIKit and SwiftUI.
5.1 Navigation Strategy
// Navigation coordinator that works with both
enum AppRoute: Hashable {
case profile(userId: String)
case settings
case editProfile
case chat(conversationId: String)
}
class NavigationCoordinator: ObservableObject {
@Published var path = NavigationPath()
weak var navigationController: UINavigationController?
func push(_ route: AppRoute) {
// If SwiftUI navigation is available
if #available(iOS 16.0, *) {
path.append(route)
} else {
// Fallback to UIKit
pushUIKit(route)
}
}
private func pushUIKit(_ route: AppRoute) {
let viewController: UIViewController
switch route {
case .profile(let userId):
viewController = UIHostingController(
rootView: ProfileView(userId: userId)
)
case .settings:
viewController = SettingsViewController()
case .editProfile:
viewController = UIHostingController(
rootView: EditProfileView()
)
case .chat(let conversationId):
viewController = ChatViewController(conversationId: conversationId)
}
navigationController?.pushViewController(viewController, animated: true)
}
}
Testing Strategy
Migration without comprehensive testing is a disaster waiting to happen.
Unit Testing SwiftUI Views
// Extract testable view models
@MainActor
class ProfileViewModelTests: XCTestCase {
func testUserLoading() async {
let mockService = MockUserService()
let viewModel = ProfileViewModel(service: mockService)
mockService.userToReturn = User(id: "123", name: "Test")
await viewModel.loadUser(id: "123")
XCTAssertEqual(viewModel.user?.name, "Test")
XCTAssertFalse(viewModel.isLoading)
XCTAssertNil(viewModel.error)
}
}
Common Pitfalls and Solutions
Pitfall 1: Over-Eager Migration
Problem: Trying to migrate too much too fast
Solution:
- Stick to the 20% rule: New features in SwiftUI, migrate 20% of views per quarter
- Maintain stable main branch
- Use feature branches for migration work
Pitfall 2: Inconsistent Patterns
Problem: Every engineer migrating views differently
Solution:
- Strict style guide
- Code review checklist for SwiftUI migrations
- Pair programming for first few migrations
- Create reusable templates
Timeline and Estimates
For a 100,000-line UIKit app:
Phase 1 (Foundation): 1 month
- Audit codebase
- Set standards
- Create tooling
Phase 2 (New Features): 2 months
- 3-5 new features in SwiftUI
- Build confidence
- Create reusable components
Phase 3 (Migration): 6-12 months
- Migrate 40-60% of screens
- Focus on high-value, low-complexity views
- Keep 40% UIKit for complex legacy views
Total realistic timeline: 12-18 months
Success Metrics
Track these to measure migration success:
Technical Metrics:
- SwiftUI code percentage (target: 60% by month 12)
- Build time (should improve with SwiftUI)
- App size (might increase initially, should stabilize)
- Crash rate (should remain stable or improve)
- UI test coverage (maintain or improve)
Conclusion
Migrating a large UIKit app to SwiftUI is a marathon, not a sprint. Success comes from:
- Strategic planning - Don't rewrite, evolve
- Incremental progress - Small, frequent wins
- Team alignment - Consistent patterns and standards
- Quality focus - Never compromise stability
- Patience - 12-18 months is normal and healthy
The goal isn't to eliminate all UIKit codeβit's to strategically leverage SwiftUI where it provides the most value while maintaining a stable, shippable product.
After multiple migrations, I can say confidently: the apps that succeed are the ones that treat migration as a long-term investment in developer velocity and user experience, not a trendy rewrite project.