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.

  • Mobile & Desktop
  • $uikit-to-swiftui
  • 200 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- uikit-to-swiftui

Installs to ~/.agents/skills/uikit-to-swiftui/ for every project on this machine.

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.

~/.agents/skills/uikit-to-swiftui/SKILL.md

--- name: uikit-to-swiftui description: Migrates UIKit iOS apps to SwiftUI incrementally - embedding SwiftUI with UIHostingController and UIHostingConfiguration cells, wrapping UIKit views… license: MIT ---

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:

OrderCandidateWhy
1New featuresZero regression risk; build them in SwiftUI from day one
2Leaf screens (settings, detail, about, forms)Few inbound dependencies, no child navigation
3Cells in existing lists (UIHostingConfiguration)Big visual win, list stays UIKit
4Flows with their own navigationAfter shared models and routing are in place
5Root 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 only host.view breaks appearance callbacks and trait updates.
  • SwiftUI screens call back into UIKit through closures (onDone, onSelect) or a coordinator protocol, not by reaching for UIApplication windows.
  • UIHostingController reads navigationItem from the hosting controller. SwiftUI’s .navigationTitle works only inside a SwiftUI NavigationStack; when pushed onto a UIKit navigation controller, set host.title / navigationItem in 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() }
    }
}
  • makeUIView runs once; updateUIView runs on every SwiftUI update. Guard against redundant work there, and never create views in it.
  • The coordinator holds delegates and target-actions. Refresh parent in updateUIView or 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) with UIViewControllerRepresentable the 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, viewWillLayoutSubviews and the new updateProperties() and re-runs them on change (back-deployable to iOS 18 with the UIObservationTrackingEnabled Info.plist key). On older targets, use withObservationTracking and 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].
  • UIHostingController created per cell in cellForRowAt instead of UIHostingConfiguration leaks 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 .accessibilityIdentifier must 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.yamlgenerated from this page

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.

esc