Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
71
89%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
Patterns for incrementally migrating a UIKit app to SwiftUI. Each pattern is self-contained with rationale, implementation, and gotchas.
Replace one UIViewController at a time with a UIHostingController wrapping a SwiftUI view. This is the safest migration path -- each screen is an isolated unit.
UIHostingController wherever it was instantiated.// BEFORE: UIKit screen pushed onto a navigation stack
let detailVC = ItemDetailViewController(item: item)
navigationController?.pushViewController(detailVC, animated: true)
// AFTER: SwiftUI screen wrapped in UIHostingController
let detailView = ItemDetailView(item: item)
let hostingVC = UIHostingController(rootView: detailView)
navigationController?.pushViewController(hostingVC, animated: true)When the SwiftUI screen needs to pop itself or trigger navigation in the UIKit stack:
struct ItemDetailView: View {
let item: Item
var onDelete: (() -> Void)?
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
Text(item.title)
Button("Delete", role: .destructive) {
onDelete?()
dismiss()
}
}
}
}
// In UIKit:
let detailView = ItemDetailView(item: item) {
self.dataSource.delete(item)
self.navigationController?.popViewController(animated: true)
}
let hostingVC = UIHostingController(rootView: detailView)UIHostingController inherits navigation bar visibility from its parent UINavigationController. Use .navigationTitle() and .toolbar() in the SwiftUI view -- they propagate to the UIKit navigation bar automatically.hostingVC.navigationItem.largeTitleDisplayMode in UIKit code if the SwiftUI .navigationBarTitleDisplayMode() modifier does not apply correctly.UIHostingController respects additionalSafeAreaInsets. If the content overlaps the tab bar, verify safe area propagation.Embed SwiftUI sections within an existing UIKit screen. Use when migrating part of a screen (a header, a card, a section) before rewriting the entire controller.
final class DashboardViewController: UIViewController {
private var statsHostingController: UIHostingController<StatsCardView>?
override func viewDidLoad() {
super.viewDidLoad()
let statsView = StatsCardView(stats: currentStats)
let hostingVC = UIHostingController(rootView: statsView)
// Enable intrinsic sizing so Auto Layout can size the hosted view
if #available(iOS 16.0, *) {
hostingVC.sizingOptions = [.intrinsicContentSize]
}
addChild(hostingVC)
hostingVC.view.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(hostingVC.view)
NSLayoutConstraint.activate([
hostingVC.view.topAnchor.constraint(equalTo: containerView.topAnchor),
hostingVC.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
hostingVC.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
hostingVC.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
hostingVC.didMove(toParent: self)
statsHostingController = hostingVC
}
func updateStats(_ stats: Stats) {
statsHostingController?.rootView = StatsCardView(stats: stats)
}
}@Observable ModelPass an @Observable model to avoid reassigning rootView manually. SwiftUI tracks changes automatically:
@Observable
final class DashboardModel {
var stats: Stats = .empty
var isLoading = false
}
struct StatsCardView: View {
let model: DashboardModel
var body: some View {
// Automatically re-renders when model.stats changes
if model.isLoading {
ProgressView()
} else {
StatsGrid(stats: model.stats)
}
}
}
// In UIKit:
let model = DashboardModel()
let hostingVC = UIHostingController(rootView: StatsCardView(model: model))
// Later -- just mutate the model, no rootView reassignment needed
model.stats = newStatsUIHostingController's view has an opaque system background by default. Set hostingVC.view.backgroundColor = .clear if embedding over existing content..intrinsicContentSize, the hosted view may report zero size in Auto Layout, causing the container to collapse.Mix UIKit and SwiftUI screens in the same UINavigationController stack.
// From a UIKit view controller, push a SwiftUI screen
func showProfile(for user: User) {
let profileView = ProfileView(user: user)
let hostingVC = UIHostingController(rootView: profileView)
hostingVC.title = user.name
navigationController?.pushViewController(hostingVC, animated: true)
}Use a coordinator or UIViewControllerRepresentable bridge:
struct ProfileView: View {
let user: User
@State private var showLegacyEditor = false
var body: some View {
List {
// ... profile content
Button("Edit (Legacy)") { showLegacyEditor = true }
}
.sheet(isPresented: $showLegacyEditor) {
LegacyEditorWrapper(user: user)
}
}
}
struct LegacyEditorWrapper: UIViewControllerRepresentable {
let user: User
func makeUIViewController(context: Context) -> UINavigationController {
let editor = ProfileEditorViewController(user: user)
return UINavigationController(rootViewController: editor)
}
func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {}
}For deep integration where SwiftUI needs to push onto the UIKit navigation stack:
struct NavigationBridge {
weak var navigationController: UINavigationController?
func push(_ viewController: UIViewController, animated: Bool = true) {
navigationController?.pushViewController(viewController, animated: animated)
}
func push<V: View>(_ view: V, title: String? = nil, animated: Bool = true) {
let hostingVC = UIHostingController(rootView: view)
hostingVC.title = title
navigationController?.pushViewController(hostingVC, animated: animated)
}
}
// Inject via environment
private struct NavigationBridgeKey: EnvironmentKey {
static let defaultValue = NavigationBridge()
}
extension EnvironmentValues {
var navigationBridge: NavigationBridge {
get { self[NavigationBridgeKey.self] }
set { self[NavigationBridgeKey.self] = newValue }
}
}UIHostingController onto a UINavigationController, the back button works automatically. Do not add a manual back button in the SwiftUI view.NavigationStack, it creates its own navigation bar inside the UIKit one. Remove NavigationStack from SwiftUI views presented inside UINavigationController..toolbar items propagate to the UIKit navigation bar when hosted in UIHostingController. This works reliably on iOS 16+.@Observable (iOS 17+)The cleanest approach. Create an @Observable model, pass it to both UIKit and SwiftUI code:
@Observable
final class AppState {
var currentUser: User?
var unreadCount: Int = 0
var theme: AppTheme = .system
}
// UIKit side -- read properties directly
let state = AppState()
func viewDidLoad() {
titleLabel.text = state.currentUser?.name
}
// SwiftUI side -- observation is automatic
struct HeaderView: View {
let state: AppState
var body: some View {
HStack {
Text(state.currentUser?.name ?? "Guest")
if state.unreadCount > 0 {
Badge(count: state.unreadCount)
}
}
}
}If UIKit code needs to react to @Observable changes, bridge with a withObservationTracking loop or use Combine:
import Combine
import Observation
final class DashboardViewController: UIViewController {
let state: AppState
private var observationTask: Task<Void, Never>?
override func viewDidLoad() {
super.viewDidLoad()
startObserving()
}
private func startObserving() {
observationTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard let self else { return }
withObservationTracking {
self.updateUI(unreadCount: self.state.unreadCount)
} onChange: {
// Triggers next iteration
}
try? await Task.sleep(for: .zero) // Yield to allow onChange to fire
}
}
}
private func updateUI(unreadCount: Int) {
badgeLabel.text = "\(unreadCount)"
}
deinit { observationTask?.cancel() }
}For iOS 15-16 or existing ObservableObject models, subscribe to objectWillChange:
final class SettingsViewController: UIViewController {
let settings: SettingsModel // ObservableObject
private var cancellable: AnyCancellable?
override func viewDidLoad() {
super.viewDidLoad()
cancellable = settings.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.updateUI()
}
}
}@Observable does not trigger UIKit updates automatically. Unlike SwiftUI views, UIKit code must manually observe changes via withObservationTracking or Combine.@Observable properties on @MainActor when they drive UI in both UIKit and SwiftUI.[weak self] in Combine sinks and task closures. Store cancellables and tasks, then cancel in deinit.Render SwiftUI content inside UICollectionViewCell and UITableViewCell without managing a child UIHostingController. This is the preferred approach for cells in a UIKit collection or table view.
@available(iOS 16.0, *)
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "cell",
for: indexPath
)
let item = dataSource[indexPath.item]
cell.contentConfiguration = UIHostingConfiguration {
HStack {
AsyncImage(url: item.imageURL) { image in
image.resizable().scaledToFill()
} placeholder: {
ProgressView()
}
.frame(width: 60, height: 60)
.clipShape(.rect(cornerRadius: 8))
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text(item.subtitle).font(.subheadline).foregroundStyle(.secondary)
}
}
}
.margins(.all, 12)
return cell
}@available(iOS 16.0, *)
func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
let item = items[indexPath.row]
cell.contentConfiguration = UIHostingConfiguration {
ItemRowView(item: item)
}
return cell
}UIHostingConfiguration cells self-size automatically. Ensure:
UICollectionViewCompositionalLayout with estimated dimensions, or tableView.rowHeight = UITableView.automaticDimension..frame).cell.contentConfiguration = UIHostingConfiguration {
ItemRowView(item: item)
}
.background {
RoundedRectangle(cornerRadius: 12)
.fill(.background)
}
.margins(.horizontal, 16)
.minSize(height: 60)UIHostingConfiguration creates a lightweight hosting controller. For very large lists (10,000+ items), profile with Instruments to ensure smooth scrolling.UIHostingConfiguration is recreated on each cell reuse. Do not store @State that needs to persist across reuse -- use the data model instead.leadingSwipeActionsConfigurationForRowAt), not inside the SwiftUI content.@Environment propagation by default. Environment values from the UIKit context are not automatically available. Inject them explicitly in the UIHostingConfiguration closure.Pass SwiftUI environment values into hosted SwiftUI views from UIKit, and access UIKit traits from SwiftUI.
let model = AppState()
let settingsView = SettingsView()
.environment(model)
.environment(\.locale, Locale(identifier: "en_US"))
let hostingVC = UIHostingController(rootView: settingsView)Apply environment modifiers to the root view before passing it to the hosting controller. The hosting controller does not support adding environment values after creation (you would need to reassign rootView).
UIHostingController automatically bridges these UIKit trait collections to SwiftUI environment values:
| UIKit Trait | SwiftUI Environment |
|---|---|
userInterfaceStyle | \.colorScheme |
horizontalSizeClass | \.horizontalSizeClass |
verticalSizeClass | \.verticalSizeClass |
preferredContentSizeCategory | \.dynamicTypeSize |
layoutDirection | \.layoutDirection |
legibilityWeight | \.legibilityWeight |
These update automatically when the UIKit trait environment changes (device rotation, split view resize, accessibility settings change).
Define a custom environment key and set it from UIKit:
private struct UserRoleKey: EnvironmentKey {
static let defaultValue: UserRole = .guest
}
extension EnvironmentValues {
var userRole: UserRole {
get { self[UserRoleKey.self] }
set { self[UserRoleKey.self] = newValue }
}
}
// UIKit side:
let role = authManager.currentRole
let profileView = ProfileView().environment(\.userRole, role)
let hostingVC = UIHostingController(rootView: profileView)
// SwiftUI side:
struct ProfileView: View {
@Environment(\.userRole) private var role
var body: some View {
if role == .admin {
AdminDashboard()
} else {
UserDashboard()
}
}
}To change environment values after the hosting controller is created, wrap the root view in a container that takes a binding or observable:
struct EnvironmentBridge<Content: View>: View {
let state: AppState // @Observable
let content: Content
var body: some View {
content
.environment(state)
.environment(\.userRole, state.currentRole)
}
}
// UIKit:
let state = AppState()
let bridge = EnvironmentBridge(state: state, content: SettingsView())
let hostingVC = UIHostingController(rootView: bridge)
// Later: mutating state.currentRole updates the environment automatically
state.currentRole = .admin@Environment(\.dismiss) in hosted views. This works when the UIHostingController is presented modally (via present(_:animated:)). It does NOT work when the hosting controller is pushed onto a UINavigationController -- use the navigation controller's popViewController instead.@Environment object and it is not provided, the app crashes at runtime. Always set required environment values before creating the hosting controller.hostingVC.overrideUserInterfaceStyle to force light/dark mode for a hosted SwiftUI view. This propagates to \.colorScheme automatically.skills
accessorysetupkit
references
activitykit
references
adattributionkit
references
alarmkit
references
app-clips
app-intents
references
app-store-optimization
app-store-review
apple-on-device-ai
appmigrationkit
references
audioaccessorykit
references
authentication
references
avkit
references
background-processing
references
browserenginekit
references
callkit
references
carplay
references
cloudkit
references
contacts-framework
references
core-bluetooth
references
core-data
core-motion
references
core-nfc
references
coreml
references
cryptokit
references
cryptotokenkit
references
debugging-instruments
device-integrity
references
dockkit
references
energykit
references
eventkit
references
financekit
references
focus-engine
gamekit
references
healthkit
references
homekit
references
ios-accessibility
ios-localization
ios-networking
ios-simulator
references
mapkit
metrickit
references
musickit
references
natural-language
references
paperkit
references
passkit
references
pdfkit
references
pencilkit
references
permissionkit
references
photokit
push-notifications
realitykit
references
relevancekit
references
scenekit
references
sensorkit
references
speech-recognition
spritekit
references
storekit
swift-api-design-guidelines
swift-architecture
swift-charts
references
swift-codable
swift-concurrency
swift-formatstyle
swift-language
swift-security
references
swift-testing
swiftdata
swiftlint
swiftui-animation
swiftui-gestures
references
swiftui-layout-components
swiftui-liquid-glass
references
swiftui-patterns
swiftui-performance
swiftui-uikit-interop
swiftui-webkit
tabletopkit
references
tipkit
references
vision-framework
weatherkit
references
widgetkit
references