Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.
90
90%
Does it follow best practices?
Impact
—
Average score across 248 eval scenarios
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
var body: some View {
VStack {
Text(item.title)
Button("Delete", role: .destructive) {
onDelete()
}
}
}
}
// 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.UIHostingController pushed by UIKit is not in a SwiftUI NavigationStack. Pass explicit callbacks for popViewController, dismissal, or parent navigation instead of relying on @Environment(\.dismiss).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)
}
}
}
}For iOS 26+, use UIKit's automatic observation tracking hooks. UIKit tracks @Observable properties read inside supported update methods and reruns those methods when the properties change. On iOS 18, add UIObservationTrackingEnabled to Info.plist and set it to YES; on iOS 26 and later, this key is not required.
import Observation
@Observable
@MainActor
final class AppState {
var unreadCount: Int = 0
}
final class DashboardViewController: UIViewController {
let state: AppState
private let badgeLabel = UILabel()
init(state: AppState) {
self.state = state
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("Use init(state:)") }
override func updateProperties() {
super.updateProperties()
badgeLabel.text = "\(state.unreadCount)"
badgeLabel.isHidden = state.unreadCount == 0
}
}Use updateProperties() for labels, colors, visibility, and other non-layout properties. Use layoutSubviews() for geometry work, and use configurationUpdateHandler for table or collection view cells.
For iOS 17 back-deployment with @Observable, do not describe this as UIKit automatic observation tracking. @Observable is available, but UIKit's automatic tracking hooks are not. Manual withObservationTracking registrations are one-shot; if you use them, re-register from an explicit invalidation point and avoid polling loops. For iOS 15-16 or existing ObservableObject models, subscribe to objectWillChange:
import Combine
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()
}
}
}updateProperties(), layoutSubviews(), or cell configuration update handlers. Arbitrary methods are not tracked automatically.UIObservationTrackingEnabled key and no key requirement on iOS 26+.@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..swipeActions can bridge through UIHostingConfiguration. Use UIKit swipe configuration only when the table or collection view integration needs UIKit to own those actions.UIHostingConfiguration closure; content created from UIKit does not inherit a surrounding SwiftUI app environment by default.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 for SwiftUI presentations and navigation contexts. For a UIHostingController pushed by UIKit, pass an explicit callback that calls popViewController(animated:); the pushed controller is not inside a SwiftUI NavigationStack.@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..tessl-plugin
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
references
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