UIKit to SwiftUI skill for Codex
Migrate screen by screen while still shipping. Codex migrates a UIKit app leaf screens first, keeping the existing navigation coordinators until most screens are SwiftUI. It hosts SwiftUI with proper child containment and intrinsic sizing, wraps UIKit components in thin representables, and shares one @Observable model between both sides. Each screen is checked for parity on keyboard, nav bar, accessibility and iPad before the old code is deleted.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- uikit-to-swiftui Installs to ~/.agents/skills/uikit-to-swiftui/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install uikit-to-swiftui The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project uikit-to-swiftui Installs to .agents/skills/uikit-to-swiftui/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/uikit-to-swiftui.zipunzip -o uikit-to-swiftui.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) uikit-to-swiftui PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $uikit-to-swiftui, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Migrates UIKit iOS apps to SwiftUI incrementally - embedding SwiftUI with UIHostingController and UIHostingConfiguration cells, wrapping UIKit views and controllers with UIViewRepresentable and UIViewControllerRepresentable coordinators, sharing @Observable models between both worlds, keeping UIKit coordinators for navigation during the transition, and sizing, safe-area and keyboard interop. Use when adding a first SwiftUI screen to a UIKit app, converting table or collection view cells, reusing a UIKit component inside SwiftUI, or planning a screen-by-screen migration.
Use it when
- Adding SwiftUI to an established UIKit codebase
- Converting table or collection view cells to SwiftUI
- Reusing a UIKit view or controller inside a SwiftUI screen
- Planning the order of a multi-release migration
Use something else when
- The app is already fully SwiftUI and you are building new screens: use swiftui-views
- The migration is about routing and deep links in pure SwiftUI: use swiftui-navigation
How to check the result
UI tests pass with unchanged accessibility identifiers, side-by-side snapshots match the old screen, and the memory graph shows no leaked hosting controllers after repeated push and pop.
The mistake it prevents
Adding a hosting controller's view without addChild and didMove, which breaks trait changes, appearance callbacks and safe-area handling.
Done well means: the app ships at every step (no big-bang rewrite), each migrated screen behaves identically to the UIKit one it replaced (navigation bar, keyboard, rotation, accessibility), models are shared rather than duplicated, and interop wrappers are thin.
Plan before converting
cat AGENTS.md 2>/dev/null
grep -rlE 'UIViewController|UITableView|UICollectionView' --include='*.swift' . | wc -l
grep -rlE 'UIHostingController|UIViewRepresentable' --include='*.swift' .
grep -rnE 'storyboard|\.xib' -l . --include='*.swift' | head
Rank screens and pick the order:
| Order | Candidate | Why |
|---|---|---|
| 1 | New features | Zero regression risk; build them in SwiftUI from day one |
| 2 | Leaf screens (settings, detail, about, forms) | Few inbound dependencies, no child navigation |
| 3 | Cells in existing lists (UIHostingConfiguration) | Big visual win, list stays UIKit |
| 4 | Flows with their own navigation | After shared models and routing are in place |
| 5 | Root containers (tab bar, split view, app delegate flow) | Last; most coupling |
Keep the UIKit navigation layer (coordinators or UINavigationController) until most
leaves are SwiftUI. Migrating navigation first forces every screen to change at once.
Embed SwiftUI in UIKit
Pushing a SwiftUI screen from UIKit:
let model = ProfileModel(api: api)
let host = UIHostingController(rootView: ProfileView(model: model, onDone: { [weak self] in
self?.navigationController?.popViewController(animated: true)
}))
host.title = "Profile"
host.navigationItem.largeTitleDisplayMode = .never
navigationController?.pushViewController(host, animated: true)
Embedding as a child in part of a UIKit screen:
let host = UIHostingController(rootView: SummaryCard(model: model))
host.sizingOptions = .intrinsicContentSize // iOS 16+: report SwiftUI size to Auto Layout
addChild(host)
host.view.translatesAutoresizingMaskIntoConstraints = false
host.view.backgroundColor = .clear
container.addSubview(host.view)
NSLayoutConstraint.activate([
host.view.topAnchor.constraint(equalTo: container.topAnchor),
host.view.leadingAnchor.constraint(equalTo: container.leadingAnchor),
host.view.trailingAnchor.constraint(equalTo: container.trailingAnchor),
host.view.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
host.didMove(toParent: self)
Cells without rewriting the list:
cell.contentConfiguration = UIHostingConfiguration {
OrderRow(order: order)
}
.margins(.horizontal, 16)
- Always use child view controller containment (
addChild/didMove) for hosting controllers; adding onlyhost.viewbreaks appearance callbacks and trait updates. - SwiftUI screens call back into UIKit through closures (
onDone,onSelect) or a coordinator protocol, not by reaching forUIApplicationwindows. UIHostingControllerreadsnavigationItemfrom the hosting controller. SwiftUI’s.navigationTitleworks only inside a SwiftUINavigationStack; when pushed onto a UIKit navigation controller, sethost.title/navigationItemin UIKit (or check behaviour on your minimum iOS version before relying on SwiftUI modifiers).
Embed UIKit in SwiftUI
struct MapPreview: UIViewRepresentable {
var coordinate: CLLocationCoordinate2D
var onTap: () -> Void
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.isScrollEnabled = false
map.delegate = context.coordinator
map.addGestureRecognizer(UITapGestureRecognizer(
target: context.coordinator, action: #selector(Coordinator.tapped)))
return map
}
func updateUIView(_ map: MKMapView, context: Context) {
context.coordinator.parent = self // keep closures current
if map.centerCoordinate.latitude != coordinate.latitude
|| map.centerCoordinate.longitude != coordinate.longitude {
map.setCenter(coordinate, animated: false) // only when changed
}
}
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
final class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapPreview
init(parent: MapPreview) { self.parent = parent }
@objc func tapped() { parent.onTap() }
}
}
makeUIViewruns once;updateUIViewruns on every SwiftUI update. Guard against redundant work there, and never create views in it.- The coordinator holds delegates and target-actions. Refresh
parentinupdateUIViewor closures capture stale values. - Implement
sizeThatFits(_:uiView:context:)(iOS 16+) when the UIKit view has an intrinsic size SwiftUI should respect; otherwise it fills the proposed space. dismantleUIView(_:coordinator:)is where observers and timers are removed.- Wrap whole controllers (
PHPickerViewController,SFSafariViewController, legacy screens) withUIViewControllerRepresentablethe same way.
Shared models
Use one @Observable model for both sides during the transition:
- SwiftUI views observe it automatically.
- UIKit: on iOS 26, UIKit tracks Observable reads in
layoutSubviews,viewWillLayoutSubviewsand the newupdateProperties()and re-runs them on change (back-deployable to iOS 18 with theUIObservationTrackingEnabledInfo.plist key). On older targets, usewithObservationTrackingand re-register after each change, or keep a Combine publisher on the model for UIKit consumers.
func observe() {
withObservationTracking {
titleLabel.text = model.title
badge.isHidden = model.unreadCount == 0
} onChange: { [weak self] in
Task { @MainActor in self?.observe() } // onChange fires once, before the change
}
}
Never duplicate state (a UIKit copy and a SwiftUI copy kept in sync by notifications): that is where migration bugs come from.
Checklist per migrated screen
- [ ] Inventory UIKit behaviour: nav bar items, title style, keyboard handling, rotation,
pull to refresh, swipe actions, context menus, analytics events, accessibility ids
- [ ] Build the SwiftUI view with the shared model; previews for each state
- [ ] Host it from the existing coordinator; delete the old view controller only after parity
- [ ] Re-point UI tests (keep accessibility identifiers identical)
- [ ] Compare side by side: Dynamic Type, dark mode, VoiceOver order, iPad, landscape
- [ ] Remove dead UIKit code, storyboard scenes and xibs in the same PR
Gotchas
- Double keyboard avoidance: SwiftUI inside a UIKit screen that already adjusts insets
for the keyboard moves content twice. Disable one (
.ignoresSafeArea(.keyboard)on the SwiftUI side, or remove the UIKit observer). - Hosting controllers inside cells or child containers may apply safe-area insets twice;
host.safeAreaRegions = [](iOS 16.4+) removes them. - Retain cycles: SwiftUI closures capturing the presenting view controller strongly keep
it alive; capture
[weak self]. UIHostingControllercreated per cell incellForRowAtinstead ofUIHostingConfigurationleaks child controllers and scrolls poorly.- Environment values do not flow from UIKit; inject
.environment(model)and.environment(\.locale, ...)explicitly at each hosting boundary. - Accessibility identifiers move: SwiftUI
.accessibilityIdentifiermust match the old UIKit ones or UI tests break silently (they just fail to find elements).
Verification
- Build and run UI tests:
xcodebuild test -scheme App -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath build/DerivedData. - Snapshot the old and new screen at the same state and compare.
- Memory graph debugger after pushing and popping the screen five times: no leaked hosting controllers or models.
- If parity fails on a detail you cannot match in SwiftUI on the minimum OS, keep a small representable wrapper for that piece rather than blocking the migration.
Report template
**Migrated:** SettingsViewController -> SettingsView (hosted from AccountCoordinator)
**Interop:** MapPreview (UIViewRepresentable), OrderRow via UIHostingConfiguration
**Shared model:** SettingsModel (@Observable), UIKit header observes via withObservationTracking
**Parity checked:** nav bar, keyboard, Dynamic Type XXXL, VoiceOver order, iPad landscape
**Removed:** Settings.storyboard, SettingsCell.xib (412 lines)
**Remaining UIKit screens:** 14 of 31 What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "UIKit to SwiftUI"
short_description: "Migrate screen by screen while still shipping"
default_prompt: "$uikit-to-swiftui Rewrite the settings screen of this UIKit app in SwiftUI and push it from the existing coordinator" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.