Understanding the Migration from SwiftUI to UIKit
Migrating from SwiftUI to UIKit involves transitioning an application's user interface layer from Apple's declarative framework to its imperative, UIKit-based counterpart. This process is not simply about rewriting views — it requires a fundamental shift in how you think about UI construction, state management, and view lifecycle. Whether you're moving a single screen or an entire application, understanding both frameworks deeply is essential for a smooth transition.
This guide walks you through the complete migration process, including bridging strategies, view conversion patterns, navigation restructuring, data binding replacements, and performance considerations. You'll learn how to systematically replace SwiftUI components with UIKit equivalents while preserving user experience and code maintainability.
Why Migrate from SwiftUI to UIKit?
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →While SwiftUI offers rapid development and cross-platform consistency, several practical reasons might compel a team to migrate back to UIKit:
- Legacy integration requirements — enterprise apps with extensive UIKit dependencies may need a unified UIKit codebase
- Advanced customization needs — UIKit provides granular control over animations, transitions, and layout that SwiftUI still struggles with
- iOS version support — UIKit supports all iOS versions back to iOS 9, while SwiftUI requires iOS 13+ and many advanced features need iOS 15+
- Performance optimization — complex collections, large lists, and heavy rendering scenarios often perform better with UIKit's mature optimization pathways
- Third-party SDK compatibility — many SDKs still rely on UIView hierarchies and UIViewController-based presentation
- Team expertise — teams with deep UIKit experience may maintain velocity better in their comfort zone
The migration does not need to be all-or-nothing. You can adopt a gradual approach, converting screens one at a time while using SwiftUI-to-UIKit bridge patterns during the transition period.
Step-by-Step Migration Process
Step 1: Audit Your SwiftUI Codebase
Before writing any UIKit code, catalog every SwiftUI component in your application. Map out the view hierarchy, identify data flow patterns (ObservableObject, @State, @EnvironmentObject), and document all SwiftUI-specific modifiers and behaviors. Create a spreadsheet or diagram that groups components by screen and functionality.
Pay special attention to:
- Custom View structs and their body implementations
- ObservableObject classes that serve as view models
- @State and @Binding property wrappers and their roles
- SwiftUI lifecycle hooks like onAppear and onDisappear
- Navigation patterns using NavigationView/NavigationStack
- List and ScrollView configurations with custom cell layouts
This audit serves as your migration roadmap. For each SwiftUI construct, you'll identify the equivalent UIKit pattern in subsequent steps.
Step 2: Set Up the UIKit Foundation
Create the UIKit application scaffold. If your project started as a pure SwiftUI app using the SwiftUI App protocol, you'll need to replace it with a traditional UIApplicationDelegate-based entry point.
Replace your @main App struct with a UIApplicationDelegate class. Create a new file called AppDelegate.swift:
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
// Create your root view controller
let rootViewController = MainViewController()
let navigationController = UINavigationController(rootViewController: rootViewController)
window?.rootViewController = navigationController
window?.makeKeyAndVisible()
return true
}
}
Remove the SwiftUI @main struct and any WindowGroup or ContentView references from the app entry point. Update your target's deployment info to ensure the storyboard is not required (or remove it entirely from Info.plist if you're going fully programmatic).
Step 3: Convert View Models from ObservableObject to Plain Classes
SwiftUI view models typically conform to ObservableObject and use @Published property wrappers to trigger view updates. In UIKit, you'll convert these to plain reference types and use delegation, closures, or a reactive framework to notify views of state changes.
Consider this SwiftUI view model:
import SwiftUI
import Combine
class ProfileViewModel: ObservableObject {
@Published var username: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
func fetchProfile() {
isLoading = true
// network call...
// on success: update username, set isLoading = false
// on failure: set errorMessage, set isLoading = false
}
}
The UIKit equivalent uses a delegate protocol or a simple closure-based callback pattern:
import UIKit
protocol ProfileViewModelDelegate: AnyObject {
func profileViewModelDidUpdate(_ viewModel: ProfileViewModel)
}
class ProfileViewModel {
weak var delegate: ProfileViewModelDelegate?
var username: String = "" {
didSet { delegate?.profileViewModelDidUpdate(self) }
}
var isLoading: Bool = false {
didSet { delegate?.profileViewModelDidUpdate(self) }
}
var errorMessage: String? {
didSet { delegate?.profileViewModelDidUpdate(self) }
}
func fetchProfile() {
isLoading = true
// network call...
// on completion, update properties which trigger didSet
}
}
If your SwiftUI codebase uses @Published extensively, consider adopting a reactive framework like Combine (which works perfectly with UIKit) or RxSwift to avoid rewriting all the observation logic. Combine's @Published can be retained by keeping your view models as ObservableObjects and subscribing to their objectWillChange publisher from UIKit controllers.
Step 4: Convert Views — The Core Migration Work
This is the most labor-intensive step. For each SwiftUI View struct, create an equivalent UIViewController subclass and build its view hierarchy using programmatic auto layout or Interface Builder. Map every SwiftUI layout primitive to its UIKit counterpart.
Here is a mapping reference for common SwiftUI components:
- VStack → UIStackView (vertical axis) or manual vertical constraints
- HStack → UIStackView (horizontal axis) or manual horizontal constraints
- ZStack → Overlapping views with appropriate z-ordering and constraints
- Text → UILabel
- Image → UIImageView
- Button → UIButton configured with addTarget action
- TextField / SecureField → UITextField
- List → UITableView or UICollectionView
- ScrollView → UIScrollView with content size constraints
- Toggle → UISwitch
- Slider → UISlider
- Picker → UIPickerView or custom dropdown
- NavigationView → UINavigationController
- TabView → UITabBarController
Let's walk through a complete conversion example. Here's a SwiftUI login screen:
import SwiftUI
struct LoginView: View {
@StateObject private var viewModel = LoginViewModel()
@State private var email = ""
@State private var password = ""
var body: some View {
VStack(spacing: 20) {
Text("Welcome Back")
.font(.largeTitle)
.fontWeight(.bold)
TextField("Email", text: $email)
.textFieldStyle(RoundedBorderTextFieldStyle())
.keyboardType(.emailAddress)
.autocapitalization(.none)
SecureField("Password", text: $password)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button("Sign In") {
viewModel.login(email: email, password: password)
}
.disabled(viewModel.isLoading)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
if let error = viewModel.errorMessage {
Text(error)
.foregroundColor(.red)
}
if viewModel.isLoading {
ProgressView()
}
}
.padding()
}
}
Now the UIKit conversion using programmatic auto layout:
import UIKit
class LoginViewController: UIViewController {
// MARK: - UI Components
private let titleLabel: UILabel = {
let label = UILabel()
label.text = "Welcome Back"
label.font = .systemFont(ofSize: 34, weight: .bold)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let emailTextField: UITextField = {
let textField = UITextField()
textField.placeholder = "Email"
textField.borderStyle = .roundedRect
textField.keyboardType = .emailAddress
textField.autocapitalizationType = .none
textField.translatesAutoresizingMaskIntoConstraints = false
return textField
}()
private let passwordTextField: UITextField = {
let textField = UITextField()
textField.placeholder = "Password"
textField.isSecureTextEntry = true
textField.borderStyle = .roundedRect
textField.translatesAutoresizingMaskIntoConstraints = false
return textField
}()
private let signInButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("Sign In", for: .normal)
button.backgroundColor = .systemBlue
button.setTitleColor(.white, for: .normal)
button.layer.cornerRadius = 8
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
private let errorLabel: UILabel = {
let label = UILabel()
label.textColor = .red
label.numberOfLines = 0
label.isHidden = true
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let activityIndicator: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView(style: .medium)
indicator.hidesWhenStopped = true
indicator.translatesAutoresizingMaskIntoConstraints = false
return indicator
}()
private lazy var stackView: UIStackView = {
let stack = UIStackView(arrangedSubviews: [
titleLabel, emailTextField, passwordTextField,
signInButton, errorLabel, activityIndicator
])
stack.axis = .vertical
stack.spacing = 20
stack.alignment = .fill
stack.distribution = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
return stack
}()
// MARK: - View Model
private let viewModel = LoginViewModel()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupBindings()
}
private func setupUI() {
view.backgroundColor = .systemBackground
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
signInButton.heightAnchor.constraint(equalToConstant: 44)
])
signInButton.addTarget(self, action: #selector(signInTapped), for: .touchUpInside)
emailTextField.addTarget(self, action: #selector(emailChanged), for: .editingChanged)
passwordTextField.addTarget(self, action: #selector(passwordChanged), for: .editingChanged)
}
private func setupBindings() {
viewModel.delegate = self
}
// MARK: - Actions
@objc private func signInTapped() {
viewModel.login(email: emailTextField.text ?? "", password: passwordTextField.text ?? "")
}
@objc private func emailChanged() {
// Update local state if needed
}
@objc private func passwordChanged() {
// Update local state if needed
}
}
extension LoginViewController: LoginViewModelDelegate {
func loginViewModelDidUpdate(_ viewModel: LoginViewModel) {
signInButton.isEnabled = !viewModel.isLoading
if viewModel.isLoading {
activityIndicator.startAnimating()
} else {
activityIndicator.stopAnimating()
}
if let error = viewModel.errorMessage {
errorLabel.text = error
errorLabel.isHidden = false
} else {
errorLabel.isHidden = true
}
}
}
Notice the pattern: SwiftUI's declarative modifiers become property configurations on UIKit objects. Two-way bindings ($email) become explicit action-target connections or delegate callbacks. The VStack becomes a UIStackView, and conditional view rendering (if let error) becomes isHidden toggling on always-present views.
Step 5: Handling Lists and Collections
SwiftUI's List and ForEach require careful conversion to UITableView or UICollectionView. The declarative data iteration must become an imperative data source pattern.
SwiftUI List example:
import SwiftUI
struct ItemListView: View {
@StateObject var viewModel = ItemListViewModel()
var body: some View {
List(viewModel.items) { item in
HStack {
Image(systemName: item.iconName)
Text(item.title)
Spacer()
Text(item.subtitle)
.foregroundColor(.gray)
}
}
.onAppear { viewModel.loadItems() }
}
}
The UIKit equivalent with UITableView:
import UIKit
class ItemListViewController: UIViewController {
private let tableView: UITableView = {
let table = UITableView(frame: .zero, style: .plain)
table.translatesAutoresizingMaskIntoConstraints = false
table.register(ItemCell.self, forCellReuseIdentifier: "ItemCell")
return table
}()
private let viewModel = ItemListViewModel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
view.addSubview(tableView)
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
tableView.dataSource = self
tableView.delegate = self
viewModel.delegate = self
viewModel.loadItems()
}
}
extension ItemListViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "ItemCell", for: indexPath) as! ItemCell
let item = viewModel.items[indexPath.row]
cell.configure(with: item)
return cell
}
}
extension ItemListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
// Handle selection
}
}
extension ItemListViewController: ItemListViewModelDelegate {
func itemListViewModelDidUpdate(_ viewModel: ItemListViewModel) {
tableView.reloadData()
}
}
For the custom cell, create a UITableViewCell subclass that mirrors the SwiftUI HStack layout:
import UIKit
class ItemCell: UITableViewCell {
private let iconImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
private let titleLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 16)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private let subtitleLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 14)
label.textColor = .gray
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(iconImageView)
contentView.addSubview(titleLabel)
contentView.addSubview(subtitleLabel)
NSLayoutConstraint.activate([
iconImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
iconImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
iconImageView.widthAnchor.constraint(equalToConstant: 24),
iconImageView.heightAnchor.constraint(equalToConstant: 24),
titleLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 12),
titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
subtitleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
subtitleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(with item: Item) {
iconImageView.image = UIImage(systemName: item.iconName)
titleLabel.text = item.title
subtitleLabel.text = item.subtitle
}
}
Step 6: Convert Navigation Patterns
SwiftUI uses NavigationView (or NavigationStack in iOS 16+) with NavigationLink to push screens. UIKit uses UINavigationController with pushViewController. Convert each navigation destination to a UIViewController and replace navigation links with push calls.
SwiftUI navigation:
NavigationView {
List(items) { item in
NavigationLink(destination: DetailView(item: item)) {
ItemRow(item: item)
}
}
.navigationTitle("Items")
}
UIKit equivalent:
// In ItemListViewController's delegate method:
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let item = viewModel.items[indexPath.row]
let detailVC = DetailViewController(item: item)
navigationController?.pushViewController(detailVC, animated: true)
}
For modal presentations, convert .sheet modifiers to present(_:animated:completion:):
// SwiftUI: .sheet(isPresented: $showSheet) { SheetView() }
// UIKit:
let sheetVC = SheetViewController()
present(sheetVC, animated: true)
For full-screen covers (.fullScreenCover), use modalPresentationStyle = .fullScreen before presenting.
Step 7: Replace SwiftUI Modifiers with UIKit Configuration
SwiftUI modifiers apply styling declaratively. In UIKit, you configure properties directly on views, often grouping related styling into helper methods or extensions. Create a systematic approach to maintain consistency.
Common modifier-to-property mappings:
// SwiftUI modifiers and their UIKit equivalents:
// .font(.title) → label.font = .systemFont(ofSize: 28, weight: .bold)
// .foregroundColor(.red) → label.textColor = .red
// .background(Color.blue) → view.backgroundColor = .systemBlue
// .cornerRadius(10) → view.layer.cornerRadius = 10
// .shadow(radius: 5) → view.layer.shadowRadius = 5
// .padding() → constraints with constant spacing
// .frame(width: 100, height: 50) → widthAnchor/heightAnchor constraints
// .opacity(0.5) → view.alpha = 0.5
// .clipShape(Circle()) → view.layer.cornerRadius = min(width, height) / 2
For complex styling, consider creating a configuration struct or using a builder pattern to keep styling organized:
struct ViewStyle {
let font: UIFont
let textColor: UIColor
let backgroundColor: UIColor
let cornerRadius: CGFloat
func apply(to label: UILabel) {
label.font = font
label.textColor = textColor
label.backgroundColor = backgroundColor
label.layer.cornerRadius = cornerRadius
label.clipsToBounds = true
}
}
let titleStyle = ViewStyle(
font: .systemFont(ofSize: 28, weight: .bold),
textColor: .label,
backgroundColor: .clear,
cornerRadius: 0
)
titleStyle.apply(to: titleLabel)
Step 8: Managing State and Lifecycle
SwiftUI's @State variables and lifecycle modifiers (onAppear, onDisappear, onChange) require explicit handling in UIKit. Map these to view controller lifecycle methods and instance variables.
Mapping reference:
onAppear→viewWillAppearorviewDidAppearonDisappear→viewWillDisappearorviewDidDisappear@State var isExpanded = false→private var isExpanded = falsewith manual updates.onChange(of: value)→ property observerdidSetor Combine publisher subscription@Environment(\.colorScheme)→traitCollection.userInterfaceStyle
Example conversion of a SwiftUI view with lifecycle hooks:
// SwiftUI
struct AnalyticsTrackedView: View {
var body: some View {
Text("Content")
.onAppear { Analytics.log("view_appeared") }
.onDisappear { Analytics.log("view_disappeared") }
}
}
// UIKit
class AnalyticsTrackedViewController: UIViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Analytics.log("view_appeared")
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
Analytics.log("view_disappeared")
}
}
Step 9: Bridge Remaining SwiftUI Components During Transition
During the migration, you may need to keep some SwiftUI views alive while surrounding them with UIKit. Use UIHostingController to embed SwiftUI views within a UIKit view hierarchy. This is invaluable for phased migrations.
import SwiftUI
import UIKit
class HybridViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
// Add UIKit components
let label = UILabel()
label.text = "UIKit Header"
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
// Embed SwiftUI view
let swiftUIView = ModernSwiftUICard()
let hostingController = UIHostingController(rootView: swiftUIView)
addChild(hostingController)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
hostingController.view.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 20),
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.heightAnchor.constraint(equalToConstant: 200)
])
}
}
This pattern lets you migrate incrementally — convert the easy screens first, keep complex SwiftUI views embedded temporarily, then tackle them later.
Step 10: Testing the Migrated Screens
After converting each screen, verify behavior thoroughly. Test on multiple device sizes and iOS versions. Key areas to validate:
- Layout correctness across all iPhone screen sizes and orientations
- Dark mode appearance (check
traitCollection.userInterfaceStylehandling) - Dynamic Type / accessibility text sizes — UIKit requires more manual work here than SwiftUI's automatic scaling
- Navigation flow integrity — ensure no orphaned navigation states
- Memory management — check for retain cycles, especially with delegate patterns
- Animation smoothness — UIKit animations may need explicit configuration to match SwiftUI's implicit animations
Create snapshot tests or UI tests to catch regressions. Since you're changing the UI framework entirely, visual differences are inevitable — document acceptable deviations and flag unexpected ones.
Best Practices for a Successful Migration
Adopt a Phased Approach
Never attempt a big-bang rewrite. Migrate screen by screen, starting with the simplest views (settings screens, about pages) before tackling complex, data-rich screens. Keep both frameworks running side by side using UIHostingController during the transition. This minimizes risk and allows you to ship working increments.
Preserve Business Logic Separation
Keep your view models, network layers, and business logic completely framework-agnostic. A well-architected SwiftUI app should already have view models that don't import SwiftUI — if yours import SwiftUI solely for ObservableObject conformance, refactor them first. This separation makes migration dramatically easier because you're only rewriting the view layer, not the entire application.
Use Coordinator Pattern for Navigation
SwiftUI's navigation is declarative and state-driven. UIKit navigation is imperative. To maintain testability and avoid tightly coupling view controllers, adopt the Coordinator pattern. A coordinator object manages navigation flow, keeping view controllers ignorant of their neighbors:
protocol Coordinator: AnyObject {
var navigationController: UINavigationController { get }
func start()
}
class AppCoordinator: Coordinator {
let navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let homeVC = HomeViewController()
homeVC.coordinator = self
navigationController.pushViewController(homeVC, animated: false)
}
func showDetail(for item: Item) {
let detailVC = DetailViewController(item: item)
navigationController.pushViewController(detailVC, animated: true)
}
}
This pattern replaces the implicit navigation of SwiftUI's NavigationLink with explicit, testable routing.
Standardize UI Configuration
Create a design system in code using configuration structs, factory methods, or a theme manager. SwiftUI's modifier chain encourages ad-hoc styling, but UIKit benefits from centralized style definitions. Define fonts, colors, spacing constants, and common view configurations in a single source of truth:
enum DesignSystem {
enum Colors {
static let primary = UIColor.systemBlue
static let error = UIColor.systemRed
static let background = UIColor.systemBackground
}
enum Fonts {
static let title = UIFont.systemFont(ofSize: 28, weight: .bold)
static let body = UIFont.systemFont(ofSize: 16)
}
enum Spacing {
static let standard: CGFloat = 16
static let tight: CGFloat = 8
static let wide: CGFloat = 24
}
}
Handle Accessibility Proactively
SwiftUI provides automatic accessibility labels and Dynamic Type scaling. UIKit requires explicit configuration. For every UILabel, set adjustsFontForContentSizeCategory = true. For every UIView, set meaningful accessibilityLabel and accessibilityTraits. Test with VoiceOver and larger text sizes throughout the migration, not just at the end.
Leverage Combine or RxSwift for Reactivity
If your SwiftUI codebase already uses Combine publishers, keep them. UIKit works beautifully with Combine — subscribe to publishers in viewDidLoad and store cancellables in a Set<AnyCancellable>. This preserves the reactive data flow and reduces the amount of delegate boilerplate needed:
import Combine
class ReactiveViewController: UIViewController {
private var cancellables = Set<AnyCancellable>()
private let viewModel = ProfileViewModel()
override func viewDidLoad() {
super.viewDidLoad()
viewModel.$username
.receive(on: DispatchQueue.main)
.sink { [weak self] username in
self?.usernameLabel.text = username
}
.store(in: &cancellables)
viewModel.$isLoading
.receive(on: DispatchQueue.main)
.sink { [weak self] loading in
self?.activityIndicator.isHidden = !loading
}
.store(in: &cancellables)
}
}
Document the Migration Decisions
For each converted screen, document why certain UIKit patterns were chosen over others. Record the SwiftUI-to-UIKit mapping decisions, any compromises made, and performance comparisons. This documentation helps future team members understand the rationale and maintains consistency across the codebase.
Common Pitfalls and How to Avoid Them
Several traps await developers migrating from SwiftUI to UIKit. Being aware of them upfront saves significant debugging time.
Retain cycles with delegation: Always declare delegate properties as weak var. Unlike SwiftUI's environment objects which are reference types managed by the framework, UIKit delegate references can easily cause retain cycles if not marked weak.
Missing main thread updates: SwiftUI automatically batches and dispatches UI updates to the main thread via its View graph diffing. UIKit requires explicit DispatchQueue.main.async for any UI property change triggered from background threads. Failing to do this causes subtle, hard-to-debug glitches.
Overlooking trait collection changes: SwiftUI automatically rebuilds views on environment changes like dark mode toggle. UIKit requires overriding traitCollectionDidChange or using UIView dynamic appearance properties. Test dark mode thoroughly.
Ignoring safe area layout guides: SwiftUI's .padding() automatically respects safe areas. In UIKit, you must explicitly constraint to view.safeAreaLayoutGuide rather than view directly, especially on iPhone X and newer devices with notch/dynamic island.
Animation mismatch: SwiftUI animations are implicit and spring-loaded by default. UIKit animations require explicit UIView.animate blocks. Pay attention to animation curves (easing vs spring) to match the original SwiftUI feel.
Conclusion
Migrating from SwiftUI to UIKit is a significant engineering undertaking that requires careful planning, systematic conversion, and rigorous testing. By auditing your codebase thoroughly, converting view models to framework-agnostic classes, mapping SwiftUI components to their UIKit equivalents methodically, and adopting a phased migration strategy with UIHostingController bridges, you can execute this transition with minimal disruption to your users.
The key insight is that migration is not merely a syntax translation — it's a paradigm shift from declarative to imperative UI construction. Embrace UIKit's strengths: precise layout control, mature performance optimizations, and extensive customization capabilities. Document every conversion decision, preserve business logic separation, and leverage reactive patterns with Combine to maintain the reactivity that made your SwiftUI codebase responsive. With patience and discipline, your UIKit application will be as robust and maintainable as the SwiftUI original — and in many cases, more flexible and performant.