Migrating UIKit apps to SwiftUI: lessons from production
By day 80 of building iOS apps, you've likely inherited or maintained a mature UIKit codebase. Jumping straight to SwiftUI for everything isn't realistic—most production apps need a gradual migration strategy. We've learned from migrating screens across e-commerce and SaaS platforms that the right approach combines UIHostingController for bridging, UIViewRepresentable for reuse, strategic new-screen adoption, and a unified design system that works for both frameworks.
Why gradual migration matters in production
A complete UIKit-to-SwiftUI rewrite is rarely justifiable. Your team knows UIKit patterns, your codebase has years of battle-tested logic, and your users need stability. Strategic migration lets you adopt SwiftUI's productivity gains without rewrites, reduces risk, and keeps shipping features. We've seen teams waste six months rebuilding screens only to find SwiftUI limitations that sent them back to UIKit—the gradual path lets you learn what works for your app's specific constraints before committing.
UIHostingController — Host SwiftUI screens in UIKit navigation
UIHostingController is your primary bridge. It wraps a SwiftUI view and integrates it into your UIKit navigation stack, letting new screens live alongside existing UIViewController code without touching the navigation architecture.
Imagine you're adding a new feature to a mature travel booking app. Your UINavigationController expects UIViewControllers, but you want to write the itinerary detail screen in SwiftUI. UIHostingController makes this seamless:
import SwiftUI
import UIKit
// Your new SwiftUI screen
struct ItineraryDetailView: View {
let tripId: String
@StateObject var viewModel = ItineraryViewModel()
var body: some View {
VStack {
List(viewModel.activities) { activity in
VStack(alignment: .leading) {
Text(activity.name)
.font(.headline)
Text(activity.time)
.font(.caption)
.foregroundColor(.gray)
}
}
}
.navigationTitle("Itinerary")
.onAppear {
viewModel.fetchActivities(for: tripId)
}
}
}
// In your UIKit view controller
class ExistingTripViewController: UIViewController {
func showItineraryDetail(tripId: String) {
let swiftUIView = ItineraryDetailView(tripId: tripId)
let hostingController = UIHostingController(rootView: swiftUIView)
navigationController?.pushViewController(hostingController, animated: true)
}
}The key benefit: you're not replacing your UINavigationController or refactoring existing screens. The SwiftUI view handles its own layout, state, and lifecycle while UIKit manages navigation flow. This works equally well with tab bar controllers, modal presentations, and split view controllers.
One production consideration: UIHostingController respects your hosting UIViewController's appearance proxy settings. If your app uses a custom navigation bar color, it carries through automatically. Test edge cases like large titles and translucent bars with your SwiftUI screens early.
UIViewRepresentable — Reuse UIKit views inside SwiftUI
UIViewRepresentable solves the reverse problem: you have a polished UIKit component (maybe a custom map interaction, a specialized camera view, or a vendor's UIView) and need to use it in SwiftUI screens. Rather than rewrite it, wrap it.
Let's say your ride-hailing app uses a custom UIView for drawing route polylines on a map. Multiple teams have spent weeks optimizing it. Your new SwiftUI booking screen needs it:
import SwiftUI
import MapKit
struct RoutePolylineView: UIViewRepresentable {
let route: Route
let polylineColor: UIColor
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
return mapView
}
func updateUIView(_ mapView: MKMapView, context: Context) {
mapView.removeOverlays(mapView.overlays)
let polyline = MKPolyline(coordinates: route.coordinates, count: route.coordinates.count)
polyline.title = route.name
mapView.addOverlay(polyline)
let region = MKCoordinateRegion(
center: route.centerCoordinate,
span: route.coordinateSpan
)
mapView.setRegion(region, animated: true)
}
func makeCoordinator() -> Coordinator {
Coordinator(polylineColor: polylineColor)
}
class Coordinator: NSObject, MKMapViewDelegate {
let polylineColor: UIColor
init(polylineColor: UIColor) {
self.polylineColor = polylineColor
}
func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
let renderer = MKPolylineRenderer(overlay: overlay)
renderer.strokeColor = polylineColor
renderer.lineWidth = 3
return renderer
}
}
}
// Use it directly in SwiftUI
struct BookingView: View {
@State var route: Route?
var body: some View {
VStack {
if let route = route {
RoutePolylineView(route: route, polylineColor: .systemBlue)
.frame(height: 300)
}
Text("Book your ride")
}
}
}The Coordinator pattern handles delegation and reference types that SwiftUI's value semantics don't naturally support. This approach keeps your UIView code untouched while giving SwiftUI access to its full functionality. The performance is identical—you're not reimplementing anything, just wrapping it safely.
Important: UIViewRepresentable updates happen frequently as SwiftUI reconciles state. Expensive operations like API calls belong in the parent SwiftUI view's @StateObject, not in updateUIView. Keep updateUIView focused on UI configuration only.
New screens first — Write new features in SwiftUI
The simplest migration strategy: commit to writing all new screens in SwiftUI going forward. Don't touch existing UIKit screens unless you're already there for a bug fix. This policy caps your UIKit surface area while letting your team build momentum with SwiftUI patterns.
In a CRM platform we worked on, the team designated new report types as SwiftUI-only. Over three quarters, they shipped five new screens in SwiftUI without disrupting the mature UIKit-based contact and deal management flows. This let junior engineers learn SwiftUI safely while preserving stability for heavily-used features.
The practical payoff: new code means new architecture decisions. You can establish SwiftUI conventions early—how you use StateObject vs ObservedObject, where you fetch data, how you handle errors—without reverse-engineering existing UIKit patterns. When you eventually migrate high-value screens, you have proven patterns to copy.
Set a explicit policy: "All screens created after [date] are SwiftUI unless technical blockers exist." Document those blockers. In our experience, they're rare. ARKit integration and highly customized gesture handling account for most exceptions.
Shared design system — Colors and fonts used by both
UIKit and SwiftUI won't coexist cleanly if they don't share visual language. A mismatched color palette makes your app feel broken mid-transition, especially on screens that host both frameworks.
Build your design system as a thin layer that both frameworks access. This doesn't require a dependency like Figma tokens—a simple extension library works:
// DesignSystem.swift - shared by both UIKit and SwiftUI
import UIKit
import SwiftUI
enum AppColors {
static let primary = UIColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1.0)
static let secondary = UIColor(red: 0.95, green: 0.95, blue: 0.97, alpha: 1.0)
static let error = UIColor(red: 0.8, green: 0.2, blue: 0.2, alpha: 1.0)
static let textPrimary = UIColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1.0)
static let textSecondary = UIColor(red: 0.5, green: 0.5, blue: 0.5, alpha: 1.0)
}
enum AppTypography {
static let headlineLarge = UIFont.systemFont(ofSize: 28, weight: .bold)
static let headlineSmall = UIFont.systemFont(ofSize: 16, weight: .semibold)
static let bodyRegular = UIFont.systemFont(ofSize: 16, weight: .regular)
static let captionSmall = UIFont.systemFont(ofSize: 12, weight: .regular)
}
// SwiftUI extensions
extension Color {
static let appPrimary = Color(UIColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1.0))
static let appError = Color(UIColor(red: 0.8, green: 0.2, blue: 0.2, alpha: 1.0))
}
extension Font {
static let appHeadlineLarge = Font.system(size: 28, weight: .bold)
static let appBodyRegular = Font.system(size: 16, weight: .regular)
}
// Usage in UIKit
class ExistingButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setTitleColor(AppColors.textPrimary, for: .normal)
backgroundColor = AppColors.primary
titleLabel?.font = AppTypography.headlineSmall
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// Usage in SwiftUI
struct NewButtonView: View {
var body: some View {
Button("Continue") {
// action
}
.foregroundColor(.white)
.backgroundColor(.appPrimary)
.font(.appHeadlineLarge)
}
}The real value isn't eliminating code duplication—it's single sourcing visual decisions. Change AppColors.primary once, and every button in your app updates. When your designer tweaks the secondary color, UIKit and SwiftUI stay synchronized automatically.
In e-commerce apps we've migrated, the shared design system also includes semantic tokens: AppColors.actionable, AppColors.success, AppColors.warning. These map to the underlying palette but let you reason in intent rather than arbitrary hex codes. Both frameworks respect those tokens identically.
Common mistakes
- Rewriting working screens early. The temptation to "modernize" a solid UIViewController in SwiftUI burns time with minimal user impact. New features first. Rewrite only the high-pain, high-value screens.
- Ignoring memory management in UIViewRepresentable. Forgetting to clean up delegates and observers in removeFromSuperview or deinit causes leaks. Test with Instruments when wrapping UIViews.
- Inconsistent design tokens. Hardcoding colors in UIKit while using design system in SwiftUI creates visible seams. Enforce the shared system from day one.
- State duplication across frameworks. If a UIViewController and a SwiftUI view both track the same piece of domain state independently, they will diverge. Use shared ViewModels or shared ObservableObjects that both can access.
- Skipping defensive testing at boundaries. UIHostingController can behave unexpectedly with certain navigation configurations or appearance settings. Test your mixed-framework flows early, not after shipping.
Key takeaways
- Use UIHostingController to push SwiftUI screens into existing UIKit navigation stacks without refactoring the navigation layer.
- Use UIViewRepresentable to wrap polished UIKit components rather than rewriting them for SwiftUI—your investment in UIKit optimization carries forward.
- Commit to new SwiftUI screens going forward; don't retrofit existing UIKit screens unless you're already refactoring them for other reasons.
- Build a lightweight, shared design system that both UIKit and SwiftUI reference for colors, typography, and spacing—single source of truth prevents visual divergence as the app evolves.
- Gradual migration reduces risk, preserves team momentum on features, and lets you learn what SwiftUI patterns work best for your specific constraints before betting the app on them.






Comments
Loading comments…