UIKit and AppKit Essentials, and Mixing Them with SwiftUI
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
SwiftUI is the default way to build an interface on every Apple platform, but it sits on top of two older,
imperative frameworks that are still very much alive: UIKit on iOS, iPadOS, visionOS and Mac Catalyst, and
AppKit on macOS. Every SwiftUI window is ultimately a UIWindow or an NSWindow; every List is backed by a
platform collection or table view; and a long tail of capabilities (a find-and-replace text view, a custom
menu validated through the responder chain, a document picker with a specific configuration) is still easiest,
or only possible, through the older frameworks. This page is written against iOS, iPadOS, macOS, watchOS and
visionOS 27, built with Xcode 27 and Swift 6.4, with FieldNotes keeping a deployment minimum of
26; anything that exists only on the 27 releases (and, here, on iOS 27.1) is labelled as new and guarded with
if #available.
The page has two halves. The first covers the UIKit and AppKit essentials that a SwiftUI developer needs in
order to read, maintain or extend existing code: view controllers, navigation controllers, diffable collection
views, the trait system, windows, window controllers and menus. The second covers mixing the frameworks in both
directions: hosting SwiftUI views inside UIKit and AppKit, wrapping UIKit and AppKit views inside SwiftUI with
representables, bridging traits into the SwiftUI environment, sharing one animation system across all three, and
the new UIArrangementViewController for iPhone Duo. The Objective-C side of these frameworks is not covered
again; see Objective-C: Swift Interoperability
and Mixing Swift, Objective-C and C++ in an App for how Swift
calls into Objective-C APIs, @objc selectors and bridging headers.
Where UIKit and AppKit Fit
| Framework | Platforms | Role in a SwiftUI-first app such as FieldNotes |
|---|---|---|
SwiftUI |
All five platforms |
Declares the interface as a function of state. FieldNotes is a SwiftUI app: |
UIKit |
iOS, iPadOS, visionOS, Mac Catalyst (and tvOS) |
The imperative layer under SwiftUI on those platforms: scenes, windows, view controllers, views, gesture recognizers, text system. An app reaches for it to wrap a component SwiftUI does not offer, or to embed SwiftUI in an existing UIKit codebase. |
AppKit |
macOS |
The equivalent layer on the Mac: |
WatchKit |
watchOS |
Only the app and extension plumbing remains; watchOS interfaces are written in SwiftUI (see watchOS: App Structure, Connectivity, Workouts and the Smart Stack). |
Two platform rules frame everything below. First, from the 27 SDK onward a UIKit app must adopt the
scene-based life cycle (a UIWindowSceneDelegate), or it fails to launch; the life-cycle details are on
App Structure and Life Cycle (SwiftUI, UIKit, AppKit).
Second, UIKit and AppKit types are @MainActor-isolated, so every snippet on this page runs on the main actor;
Swift Concurrency in Apps and
Actors, Isolation and Sendable explain what
that means for code that calls into them.
UIKit Essentials
View Controllers
A UIViewController owns one screen’s worth of views (its root view and everything below it) and receives
life-cycle callbacks as that view is loaded, laid out, shown and hidden. Where SwiftUI recomputes body when
state changes, a view controller mutates its views imperatively in response to events. Since iOS 26, UIKit also
supports automatic observation tracking: if a view controller reads an @Observable object such as
FieldNotes's NoteStore inside updateProperties() (or viewWillLayoutSubviews()), UIKit records the
dependency and calls the method again when that property changes, which removes most hand-written
"reload when the model changes" plumbing.
import UIKit
import FieldNotesModel
import FieldNotesServices
final class NotebookSummaryViewController: UIViewController {
private let store: NoteStore
private let countLabel = UILabel()
init(store: NoteStore) {
self.store = store
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
countLabel.font = .preferredFont(forTextStyle: .title2)
countLabel.adjustsFontForContentSizeCategory = true
countLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(countLabel)
NSLayoutConstraint.activate([
countLabel.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
countLabel.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor),
])
}
// Called again automatically whenever store.notes changes (iOS 26 observation tracking).
override func updateProperties() {
super.updateProperties()
countLabel.text = "\(store.notes.count) notes"
}
}
The life-cycle methods to know by heart are viewDidLoad() (build the view hierarchy once),
viewWillAppear(_:)/viewDidAppear(_:) (react to becoming visible), viewWillLayoutSubviews() (react to size
changes) and, new in iOS 26, updateProperties() (set content and styling that depends on observable state).
The general overview of the framework is
About App Development with UIKit.
Auto Layout, Storyboards and Interface Builder
A SwiftUI developer maintaining existing UIKit code (rather than writing new UIKit code from scratch) still meets
two older mechanisms constantly: Auto Layout, the constraint-based system that positions and sizes every
UIView that is not managed by SwiftUI, and storyboards, Interface Builder’s .storyboard files that lay out
a screen’s view hierarchy and its segues to other screens visually instead of in code. NotebookSummaryViewController
above already uses Auto Layout programmatically: translatesAutoresizingMaskIntoConstraints = false opts a view
out of the legacy autoresizing-mask system, NSLayoutAnchor properties (centerXAnchor, topAnchor,
widthAnchor, and so on) build individual NSLayoutConstraint objects with type safety, and
NSLayoutConstraint.activate(_:) turns a whole batch on at once instead of setting isActive on each one:
final class TagChipRowLayout {
@MainActor
static func constraints(for chip: UIView, in container: UIView, leading: NSLayoutXAxisAnchor) -> [NSLayoutConstraint] {
chip.translatesAutoresizingMaskIntoConstraints = false
return [
chip.leadingAnchor.constraint(equalTo: leading, constant: 8),
chip.centerYAnchor.constraint(equalTo: container.centerYAnchor),
chip.heightAnchor.constraint(equalToConstant: 28),
]
}
}
// Later, once every chip's constraints are collected:
NSLayoutConstraint.activate(allChipConstraints)
See Apple Developer Documentation — NSLayoutAnchor, Apple Developer Documentation — NSLayoutConstraint and Apple Developer Documentation -- NSLayoutConstraint.activate(_:).
FieldNotes itself has no .storyboard file — its FieldNotesApp scene graph and every screen are SwiftUI — but a codebase acquired or extended from an older UIKit app very often still has one. A UIStoryboard loads that
file’s view-controller graph at runtime, instantiateViewController(identifier:creator:) creates one of its scenes by
its Interface Builder identifier, and each connection made in Interface Builder’s canvas becomes an @IBOutlet
property (a reference to a view) or an @IBAction method (a target for a control event) on the corresponding
view controller subclass:
final class LegacyNoteDetailViewController: UIViewController {
@IBOutlet private var titleField: UITextField!
@IBOutlet private var bodyTextView: UITextView!
@IBAction private func pinButtonTapped(_ sender: UIButton) {
// Wired from a button in Main.storyboard to this action.
}
}
// Elsewhere, instantiating that scene by its storyboard identifier:
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let detail = storyboard.instantiateViewController(
identifier: "LegacyNoteDetailViewController"
) as! LegacyNoteDetailViewController
See Apple Developer Documentation — UIStoryboard
and Apple
Developer Documentation -- instantiateViewController(identifier:creator:). New screens in a SwiftUI-first app such as
FieldNotes are written in SwiftUI, not added to a storyboard; Auto Layout and storyboards matter here purely as
the maintenance path for the UIKit code that predates that decision, and UIHostingController (covered below)
is the usual way to fold a SwiftUI replacement into a screen a storyboard still owns.
UINavigationController
UINavigationController is the UIKit ancestor of NavigationStack: a container view controller that manages a
stack of child view controllers, draws a navigation bar, and gives each pushed screen a Back button and an edge
swipe to pop. The root view controller is set from the scene delegate, which is now mandatory for UIKit apps:
import UIKit
import FieldNotesServices
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let store = NoteStore()
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
let navigation = UINavigationController(rootViewController: NoteListViewController(store: store))
navigation.navigationBar.prefersLargeTitles = true
window.rootViewController = navigation
window.makeKeyAndVisible()
self.window = window
}
}
Pushing is navigationController?.pushViewController(_:animated:), popping is popViewController(animated:),
and each child configures its own bar through its navigationItem (title, bar button items, search
controller). The SwiftUI side of the same concepts is on
Navigation and Presentation.
Collection Views with Diffable Data Sources
UICollectionView is UIKit’s general-purpose scrolling container: a list, a grid or any custom layout, driven by
a compositional layout. Modern UIKit code feeds it through a diffable data source: instead of answering
"how many items in section 2?" callbacks, the code builds an NSDiffableDataSourceSnapshot of identifiers and
applies it, and UIKit computes and animates the difference. Cells are configured through a
UICollectionView.CellRegistration, so there are no reuse-identifier strings.
import UIKit
import FieldNotesModel
import FieldNotesServices
final class NoteListViewController: UICollectionViewController {
enum Section { case pinned, all }
let store: NoteStore
var dataSource: UICollectionViewDiffableDataSource<Section, Note.ID>!
init(store: NoteStore) {
self.store = store
let layout = UICollectionViewCompositionalLayout.list(
using: UICollectionLayoutListConfiguration(appearance: .insetGrouped))
super.init(collectionViewLayout: layout)
}
required init?(coder: NSCoder) { fatalError("init(coder:) is not used") }
override func viewDidLoad() {
super.viewDidLoad()
title = "Notes"
let registration = UICollectionView.CellRegistration<UICollectionViewListCell, Note.ID> {
[unowned self] cell, _, id in
guard let note = store.notes.first(where: { $0.id == id }) else { return }
var content = cell.defaultContentConfiguration()
content.text = note.title
content.secondaryText = note.createdAt.formatted(date: .abbreviated, time: .omitted)
content.image = note.isPinned ? UIImage(systemName: "pin.fill") : nil
cell.contentConfiguration = content
}
dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) {
collectionView, indexPath, id in
collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: id)
}
}
// The notes last applied, so edits to an existing note can be detected.
private var appliedNotes: [Note.ID: Note] = [:]
// Rebuilt automatically when store.notes changes (observation tracking). Reading the whole
// array here also tracks edits to titles and other fields, not just inserts and deletes.
override func updateProperties() {
super.updateProperties()
let notes = store.notes
var snapshot = NSDiffableDataSourceSnapshot<Section, Note.ID>()
snapshot.appendSections([.pinned, .all])
snapshot.appendItems(notes.filter(\.isPinned).map(\.id), toSection: .pinned)
snapshot.appendItems(notes.filter { !$0.isPinned }.map(\.id), toSection: .all)
// An edited note keeps its ID, so the diff sees no change; reconfigure those items explicitly.
let edited = notes.filter { note in
guard let previous = appliedNotes[note.id] else { return false }
return previous != note
}
snapshot.reconfigureItems(edited.map(\.id))
appliedNotes = Dictionary(uniqueKeysWithValues: notes.map { ($0.id, $0) })
dataSource.apply(snapshot, animatingDifferences: true)
}
}
See Apple Developer Documentation — UICollectionViewDiffableDataSource and Apple Developer Documentation — UICollectionView.CellRegistration.
The snapshot holds Note.ID values, not whole Note values. That is Apple’s recommended shape: identifiers stay
stable when a note’s title changes, so a content-only edit is expressed with snapshot.reconfigureItems(_:)
rather than a delete-and-insert, and the cell keeps its position and selection. Because the diff compares only
IDs, the controller has to find the edited notes itself (the appliedNotes comparison above); otherwise a
renamed note would keep showing its old title. The SwiftUI equivalents (List,
Table, LazyVGrid) are on Lists, Tables and
Collections.
The Trait System
A trait collection (UITraitCollection) describes the environment a view lives in: horizontal and vertical
size class, user interface style (light or dark), Dynamic Type size, display scale, layout direction, and more.
Traits flow down the hierarchy from the window scene through view controllers to views, exactly like SwiftUI’s
environment. Since iOS 17 an app can:
-
register a closure for changes to specific traits with
registerForTraitChanges(_:handler:); -
override a trait for a subtree with
traitOverrides; -
define its own traits by conforming a type to
UITraitDefinition.
FieldNotes defines a custom NoteDensity trait so a compact list layout can be switched on for an entire
subtree, the same way Dark Mode is. Its value is FieldNotesUI’s `NoteDensity enum, the one the SwiftUI
environment already uses (see SwiftUI Views and Modifiers):
import UIKit
import FieldNotesUI // NoteDensity
struct NoteDensityTrait: UITraitDefinition {
static let defaultValue = NoteDensity.comfortable
static let name = "NoteDensity"
}
extension UITraitCollection {
var noteDensity: NoteDensity { self[NoteDensityTrait.self] }
}
extension UIMutableTraits {
var noteDensity: NoteDensity {
get { self[NoteDensityTrait.self] }
set { self[NoteDensityTrait.self] = newValue }
}
}
extension NoteListViewController {
func observeTraits() {
// Override the trait for this view controller's subtree.
traitOverrides.noteDensity = .compact
// React when the size class or the custom trait changes.
registerForTraitChanges([UITraitHorizontalSizeClass.self, NoteDensityTrait.self]) {
(self: Self, previousTraitCollection: UITraitCollection) in
self.collectionView.collectionViewLayout.invalidateLayout()
}
}
}
See Apple Developer Documentation — UIMutableTraits and Apple Developer Documentation — UITraitChangeObservable.
Many views no longer need explicit registration at all: since iOS 18, UIKit performs automatic trait
tracking inside layoutSubviews(), updateProperties(), draw(_:) and the update handlers of cells, so
reading traitCollection.noteDensity there is enough to be called again when it changes. See
Apple Developer Documentation — Automatic trait tracking.
The same custom trait becomes visible to SwiftUI views later on this page, in
Bridging Traits and the Environment.
AppKit Essentials
AppKit predates UIKit and has a different shape: an app has many windows at once, a menu bar shared by the
whole app, and a responder chain that routes menu commands to whichever window is key. On the Mac, FieldNotes
is a SwiftUI app whose WindowGroup, Settings and MenuBarExtra scenes create the windows (see
macOS: Windows, Menus, Commands, Settings and Menu Bar Extras); the AppKit types below appear when a window needs behavior SwiftUI does not
expose, or when SwiftUI is being introduced into an existing AppKit app.
NSWindow and NSWindowController
NSWindow is a window on screen: its frame, title, style mask (titled, resizable, closable, full-size content
view), toolbar and content view controller. NSWindowController owns a window’s life cycle: loading it, showing
it, restoring its frame, and participating in the document architecture when there is one. FieldNotes uses a
window controller for a floating "note inspector" that the user can keep open beside the main window:
import AppKit
import SwiftUI
import FieldNotesModel
import FieldNotesServices
final class NoteInspectorWindowController: NSWindowController {
convenience init(note: Note, store: NoteStore) {
let hosting = NSHostingController(rootView: NoteDetail(note: note).environment(store))
let window = NSWindow(contentViewController: hosting)
window.title = note.title
window.styleMask.insert(.resizable)
window.setContentSize(NSSize(width: 420, height: 560))
window.setFrameAutosaveName("FieldNotes.NoteInspector")
self.init(window: window)
}
}
// Showing it from an action:
// let controller = NoteInspectorWindowController(note: note, store: store)
// controller.showWindow(nil)
See Apple Developer Documentation — NSWindowController and Apple Developer Documentation — NSWindow.
setFrameAutosaveName(_:) is the one-liner that makes the window reopen at the size and position the user left
it in, which Mac users expect. The content is a SwiftUI view hosted with NSHostingController, covered in
SwiftUI Inside AppKit.
NSMenu
The Mac menu bar is an NSMenu (NSApplication.shared.mainMenu) whose items each have a submenu. A menu item
names an action selector and, usually, no target: AppKit sends the action up the responder chain from the
key window, so the same "Pin Note" item acts on whichever note window is frontmost. Items are enabled or disabled
by validateMenuItem(_:) on the object that would receive the action.
import AppKit
@objc protocol NoteCommands {
func newNote(_ sender: Any?)
func togglePin(_ sender: Any?)
}
@MainActor
func installNotesMenu() {
let notesMenu = NSMenu(title: "Notes")
notesMenu.addItem(withTitle: "New Note",
action: #selector(NoteCommands.newNote(_:)),
keyEquivalent: "n")
let pinItem = NSMenuItem(title: "Pin Note",
action: #selector(NoteCommands.togglePin(_:)),
keyEquivalent: "p")
pinItem.keyEquivalentModifierMask = [.command, .shift]
notesMenu.addItem(pinItem)
let notesItem = NSMenuItem(title: "Notes", action: nil, keyEquivalent: "")
notesItem.submenu = notesMenu
NSApplication.shared.mainMenu?.addItem(notesItem)
}
See Apple Developer Documentation — NSMenu and Apple Developer Documentation — NSMenuItemValidation.
In the SwiftUI app itself, FieldNotes declares the same commands with .commands { CommandMenu("Notes") { … } }
on its WindowGroup (see macOS: Windows, Menus, Commands, Settings and Menu Bar Extras); the AppKit form above is what that declaration
produces, and what an AppKit app writes by hand.
SwiftUI Inside UIKit
UIHostingController
UIHostingController is a UIViewController whose view is a SwiftUI hierarchy. It is the main door from UIKit
into SwiftUI: push it on a navigation controller, present it modally, add it as a child view controller, or set
it as a window’s root. In the UIKit version of the note list, selecting a row pushes the SwiftUI NoteDetail:
import UIKit
import SwiftUI
extension NoteListViewController {
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let id = dataSource.itemIdentifier(for: indexPath),
let note = store.notes.first(where: { $0.id == id }) else { return }
let detail = UIHostingController(rootView: NoteDetail(note: note).environment(store))
detail.title = note.title
navigationController?.pushViewController(detail, animated: true)
}
func presentTagPicker(for note: Note, from sourceItem: UIBarButtonItem) {
let picker = UIHostingController(rootView: TagPicker(note: note).environment(store))
// Keep preferredContentSize in sync with the SwiftUI content, so the popover fits it.
picker.sizingOptions = [.preferredContentSize]
picker.modalPresentationStyle = .popover
picker.popoverPresentationController?.sourceItem = sourceItem
present(picker, animated: true)
}
}
See Apple Developer Documentation — UIHostingController and Apple Developer Documentation — Using SwiftUI with UIKit.
TagPicker is a small SwiftUI view of FieldNotes that edits a note’s tags. The environment does not cross the boundary by itself:
.environment(store) must be applied to the hosted root view, exactly as FieldNotesApp does for ContentView.
Traits do cross automatically: the hosting controller turns size class, color scheme and Dynamic Type into the
matching SwiftUI environment values.
UIHostingConfiguration Cells
For collection and table view cells, a full view controller per cell would be far too heavy.
UIHostingConfiguration is a content configuration (the same slot defaultContentConfiguration() filled
above) that renders a SwiftUI view inside a cell, so FieldNotes's canonical NoteRow can be reused verbatim
in the UIKit list:
import UIKit
import SwiftUI
@MainActor
let rowRegistration = UICollectionView.CellRegistration<UICollectionViewListCell, Note> { cell, _, note in
cell.contentConfiguration = UIHostingConfiguration {
NoteRow(note: note)
}
.margins(.horizontal, 16)
}
In a list layout, SwiftUI’s .swipeActions inside the hosted view are bridged to the cell’s swipe actions, and
separators align to the hosted text automatically. A hosting configuration is the cheapest way to migrate a UIKit
list to SwiftUI one row at a time.
SwiftUI Inside AppKit
The AppKit family has three hosting types, one per AppKit concept that SwiftUI content can fill.
NSHostingController
NSHostingController is an NSViewController hosting a SwiftUI view; it is what NoteInspectorWindowController
above uses as the window’s content view controller. Its sizingOptions decide whether the SwiftUI content’s
minimum, ideal and maximum sizes drive the window’s content size limits:
import AppKit
import SwiftUI
// `store` is the app's shared NoteStore; SettingsView reads it from the environment.
let hosting = NSHostingController(rootView: SettingsView().environment(store))
hosting.sizingOptions = [.minSize, .intrinsicContentSize]
let settingsWindow = NSWindow(contentViewController: hosting)
settingsWindow.title = "FieldNotes Settings"
NSHostingView
NSHostingView is an NSView, for embedding a SwiftUI view inside an existing AppKit view hierarchy: a table
cell, a toolbar item, a split view pane. Here an AppKit NSTableView reuses NoteRow for its cells:
import AppKit
import SwiftUI
final class NoteTableController: NSObject, NSTableViewDelegate {
let store: NoteStore
init(store: NoteStore) {
self.store = store
super.init()
}
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let note = store.notes[row]
let rowView = NSHostingView(rootView: NoteRow(note: note))
// Only publish the intrinsic size; the table decides the width.
rowView.sizingOptions = [.intrinsicContentSize]
return rowView
}
}
See Apple Developer Documentation — NSHostingView and Apple Developer Documentation — sizingOptions.
Trimming sizingOptions to what the container actually needs reduces the layout measurements SwiftUI performs,
which matters in a table with hundreds of rows.
NSHostingMenu
NSHostingMenu (macOS 14.4 and later) is an NSMenu subclass whose items are declared in SwiftUI: Button,
Toggle, Menu, Section and Divider become menu items with the same look and behavior as a SwiftUI
contextMenu. That lets FieldNotes write its note context menu once and attach it both to SwiftUI views and to
AppKit controls:
import AppKit
import SwiftUI
import FieldNotesModel
import FieldNotesServices
struct NoteContextMenu: View {
let note: Note
let store: NoteStore
var body: some View {
Section {
Button(note.isPinned ? "Unpin" : "Pin") { store.togglePin(note) }
}
Section {
Button("Delete", role: .destructive) { store.delete(note) }
}
}
}
// AppKit side: one menu for the table, updated for the clicked row before it opens.
@MainActor
final class NoteTableMenuController: NSObject, NSMenuDelegate {
let store: NoteStore
let tableView: NSTableView
let menu: NSHostingMenu<NoteContextMenu>
init(store: NoteStore, tableView: NSTableView, placeholder: Note) {
self.store = store
self.tableView = tableView
self.menu = NSHostingMenu(rootView: NoteContextMenu(note: placeholder, store: store))
super.init()
menu.delegate = self
tableView.menu = menu
}
func menuNeedsUpdate(_ menu: NSMenu) {
guard tableView.clickedRow >= 0 else { return }
self.menu.rootView = NoteContextMenu(note: store.notes[tableView.clickedRow], store: store)
}
}
The root view should be a Group, Section sequence or other multi-view container rather than an HStack,
because each top-level view becomes its own menu item.
UIKit and AppKit Inside SwiftUI
The opposite direction uses representables: small SwiftUI value types that create, update and tear down a UIKit or AppKit object on SwiftUI’s behalf.
| Protocol | Wraps | Required methods |
|---|---|---|
|
a |
|
|
a |
|
|
an |
|
|
an |
|
Each also has optional makeCoordinator(), a static dismantle...(_:coordinator:), and sizeThatFits(_:...).
The coordinator is a reference type created once per representable instance and kept for its whole life; it
is where delegate and target-action callbacks from the wrapped object land, and where they are turned back into
SwiftUI state writes.
UIViewRepresentable with a Coordinator
FieldNotes's NoteEditor wants UIKit’s built-in find-and-replace bar (isFindInteractionEnabled) on the note
body, which SwiftUI’s TextEditor does not expose. The editor therefore wraps a UITextView:
#if canImport(UIKit)
import SwiftUI
import UIKit
struct NoteBodyTextView: UIViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.font = .preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
textView.isFindInteractionEnabled = true
textView.isScrollEnabled = false
textView.delegate = context.coordinator
return textView
}
func updateUIView(_ textView: UITextView, context: Context) {
context.coordinator.text = $text // the binding may be a new value each update
if textView.text != text { // avoid resetting the cursor on every keystroke
textView.text = text
}
}
static func dismantleUIView(_ textView: UITextView, coordinator: Coordinator) {
textView.delegate = nil
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
guard let width = proposal.width else { return nil }
let fitting = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return CGSize(width: width, height: fitting.height)
}
final class Coordinator: NSObject, UITextViewDelegate {
var text: Binding<String>
init(text: Binding<String>) {
self.text = text
}
func textViewDidChange(_ textView: UITextView) {
text.wrappedValue = textView.text
}
}
}
#endif
See Apple Developer Documentation — UIViewRepresentable and Apple Developer Documentation -- sizeThatFits(_:uiView:context:).
Three habits keep representables correct:
-
Create once, update often.
makeUIViewruns once per identity;updateUIViewruns on every SwiftUI update that touches the representable’s inputs. Put configuration that never changes inmake, and makeupdateidempotent and cheap. -
Guard against feedback loops. Writing
textView.textunconditionally inupdatewould reset the cursor after every keystroke, because each keystroke writes the binding, which triggers an update. -
Clean up in
dismantle. Detach delegates, invalidate timers and remove observers there; SwiftUI calls it when the view leaves the hierarchy.
Apple’s step-by-step tutorial on the same pattern is SwiftUI Tutorials — Interfacing with UIKit.
UIViewControllerRepresentable
When the UIKit component is a whole view controller, wrap it with UIViewControllerRepresentable. The shape is
identical. Here FieldNotes wraps UIDocumentPickerViewController to attach files to a note (in a real app
SwiftUI’s fileImporter modifier covers this simple case; the wrapper illustrates the pattern with a familiar
controller):
#if canImport(UIKit)
import SwiftUI
import UIKit
import UniformTypeIdentifiers
struct AttachmentPicker: UIViewControllerRepresentable {
var onPick: ([URL]) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onPick: onPick)
}
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.image, .pdf], asCopy: true)
picker.allowsMultipleSelection = true
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ picker: UIDocumentPickerViewController, context: Context) {
context.coordinator.onPick = onPick
}
final class Coordinator: NSObject, UIDocumentPickerDelegate {
var onPick: ([URL]) -> Void
init(onPick: @escaping ([URL]) -> Void) {
self.onPick = onPick
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
onPick(urls)
}
}
}
#endif
NSViewRepresentable
On the Mac the same NoteBodyTextView name wraps an NSTextView, so NoteEditor compiles unchanged on every
platform; only the file guarded by #if os(macOS) differs. The Cocoa text view is normally used inside a scroll
view, so the representable wraps the scroll view that NSTextView.scrollableTextView() returns:
#if os(macOS)
import AppKit
import SwiftUI
struct NoteBodyTextView: NSViewRepresentable {
@Binding var text: String
func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSTextView.scrollableTextView()
let textView = scrollView.documentView as! NSTextView
textView.isRichText = false
textView.usesFindBar = true
textView.font = .preferredFont(forTextStyle: .body)
textView.delegate = context.coordinator
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
context.coordinator.text = $text
guard let textView = scrollView.documentView as? NSTextView else { return }
if textView.string != text {
textView.string = text
}
}
static func dismantleNSView(_ scrollView: NSScrollView, coordinator: Coordinator) {
(scrollView.documentView as? NSTextView)?.delegate = nil
}
final class Coordinator: NSObject, NSTextViewDelegate {
var text: Binding<String>
init(text: Binding<String>) {
self.text = text
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
text.wrappedValue = textView.string
}
}
}
#endif
See Apple Developer Documentation — NSViewRepresentable and Apple Developer Documentation — AppKit integration.
The #if os(…) guards are the multiplatform technique from
One Codebase for iOS, iPadOS, macOS, watchOS and visionOS; the
language mechanics are in
Attributes and Compiler Control.
Figure: The Representable Life Cycle
The diagram below follows one NoteBodyTextView from the moment NoteEditor first shows it until the editor is
dismissed. The coordinator exists before the UIKit view and outlives every update; it is the delegate that turns
UIKit callbacks into binding writes, which trigger the next updateUIView.
Bridging Traits and the Environment
When SwiftUI and UIKit are mixed, built-in traits and environment values already stay in sync: a hosting
controller maps UITraitCollection.userInterfaceStyle to \.colorScheme, horizontalSizeClass to
\.horizontalSizeClass, and so on. A custom trait such as NoteDensity needs an explicit bridge.
UITraitBridgedEnvironmentKey is an EnvironmentKey that also knows how to read its value from a trait
collection and write it into mutable traits; once declared, the value flows both ways:
-
a
traitOverrides.noteDensity = .compactset on a UIKit view controller is visible to every SwiftUI view it hosts, through@Environment(\.noteDensity); -
an
.environment(\.noteDensity, .compact)applied in SwiftUI is visible to every UIKit view wrapped by a representable below it, throughtraitCollection.noteDensity.
#if canImport(UIKit)
import SwiftUI
import UIKit
import FieldNotesModel
import FieldNotesUI // NoteDensity, NoteRow
struct NoteDensityKey: UITraitBridgedEnvironmentKey {
static let defaultValue = NoteDensity.comfortable
static func read(from traitCollection: UITraitCollection) -> NoteDensity {
traitCollection[NoteDensityTrait.self]
}
static func write(to mutableTraits: inout any UIMutableTraits, value: NoteDensity) {
mutableTraits[NoteDensityTrait.self] = value
}
}
extension EnvironmentValues {
var noteDensity: NoteDensity {
get { self[NoteDensityKey.self] }
set { self[NoteDensityKey.self] = newValue }
}
}
// NoteRow, hosted in a UIKit cell, adapts to the trait set by the UIKit list.
struct NoteRowDensityAdapter: View {
@Environment(\.noteDensity) private var density
let note: Note
var body: some View {
NoteRow(note: note)
.padding(.vertical, density == .compact ? 2 : 8)
}
}
#endif
The key is declared as a hand-written EnvironmentKey rather than with the @Entry macro used elsewhere in the
section, because the bridging protocol adds the two static requirements that @Entry cannot generate. The SwiftUI
side of environment values is covered on
State and Data Flow with Observation.
Unified Animations
UIKit, AppKit and SwiftUI historically had three animation engines with different timing curves, so a SwiftUI
spring next to a UIKit spring never quite matched. Since iOS 18 and macOS 15, UIKit and AppKit can animate with a
SwiftUI Animation value directly, which gives identical timing, velocity-preserving retargeting when an
animation is interrupted, and one vocabulary (.smooth, .snappy, .bouncy, .spring(duration:bounce:)) across
the app.
import SwiftUI
#if canImport(UIKit)
import UIKit
// UIKit: slide the capture badge up with the same spring SwiftUI uses elsewhere.
@MainActor
func revealCaptureBadge(_ badge: UIView) {
UIView.animate(.spring(duration: 0.4, bounce: 0.2)) {
badge.transform = .identity
badge.alpha = 1
} completion: {
badge.accessibilityLabel = "New note captured"
}
}
#endif
#if os(macOS)
import AppKit
// AppKit: widen the inspector pane smoothly.
@MainActor
func expandInspector(_ inspector: NSView) {
NSAnimationContext.animate(.smooth) {
inspector.setFrameSize(NSSize(width: 360, height: inspector.frame.height))
}
}
#endif
See Apple Developer Documentation -- UIView.animate(_:changes:completion:) and Apple Developer Documentation -- NSAnimationContext.animate(_:changes:completion:).
The third piece is inside representables: context.animate(changes:completion:) animates UIKit changes with
whatever animation the current SwiftUI transaction carries. When NoteRow pins a note inside
withAnimation(.bouncy), a wrapped UIKit badge animates with exactly that spring:
#if canImport(UIKit)
import SwiftUI
import UIKit
struct PinBadge: UIViewRepresentable {
var isPinned: Bool
func makeUIView(context: Context) -> UIImageView {
UIImageView(image: UIImage(systemName: "pin.fill"))
}
func updateUIView(_ badge: UIImageView, context: Context) {
context.animate {
badge.alpha = isPinned ? 1 : 0
badge.transform = isPinned ? .identity : CGAffineTransform(scaleX: 0.5, y: 0.5)
}
}
}
// In NoteRow:
// Button("Pin") { withAnimation(.bouncy) { store.togglePin(note) } }
// PinBadge(isPinned: note.isPinned)
#endif
See Apple Developer Documentation — UIViewRepresentableContext.animate(changes:completion:) and Apple Developer Documentation — Unifying your app’s animations.
The SwiftUI animation model itself (withAnimation, transactions, springs, phase and keyframe animators) is on
Drawing, Animation and Gestures.
UIArrangementViewController (New in iOS 27.1)
iPhone Duo, the foldable iPhone that ships with iOS 27.1, has a compact outer display and a large inner
display with a hinge between the halves. New in iOS 27.1, UIArrangementViewController is a container view
controller that holds a primary and a secondary view controller and lays them out adaptively from the
available size, size class and hardware features such as the fold. Its SwiftUI counterpart is ArrangementView,
covered on One Codebase for iOS, iPadOS, macOS, watchOS and visionOS
and, with the rest of iPhone Duo layout, on SwiftUI Layout. Both are marked beta in Apple’s documentation at the time of
writing.
Two built-in arrangements exist:
| Arrangement | Behavior |
|---|---|
|
Places the two view controllers side by side, splitting horizontally when the container is wider than tall and
vertically when it is taller than wide. |
|
Layers the primary view controller over the secondary one (a video surface with controls, a map with a panel).
When the device is partially folded, it can transition to a side-by-side layout; |
FieldNotes uses an arrangement for the detail screen of a note on iPhone Duo: the editor is primary and a map of where the note was taken is secondary. When the phone is closed the arrangement shows a single column; when it is open or folded like a book, the editor and the map sit on either side of the hinge. Following the HIG advice to keep navigation outside arrangement views, the arrangement is pushed onto the existing navigation controller rather than containing one:
#if os(iOS)
import UIKit
import SwiftUI
import MapKit
extension NoteListViewController {
func showDetail(for note: Note) {
if #available(iOS 27.1, *) {
let arrangement = UIArrangementViewController()
arrangement.setViewController(
UIHostingController(rootView: NoteEditor(noteID: note.id).environment(store)),
for: .primary)
arrangement.setViewController(
UIHostingController(rootView: Map()),
for: .secondary)
// Only ever split left/right, so the editor never ends up below the map.
arrangement.updateArrangement(UISplitArrangement().axes(.horizontal))
arrangement.title = note.title
navigationController?.pushViewController(arrangement, animated: true)
} else {
let editor = UIHostingController(rootView: NoteEditor(noteID: note.id).environment(store))
navigationController?.pushViewController(editor, animated: true)
}
}
}
#endif
See Apple Developer Documentation — UIArrangementViewController and Apple Developer Documentation — UISplitArrangement.
Child view controllers can find their container through the new arrangementViewController property, switch
arrangements at run time with updateArrangement(_:animated:), and ask where they are with state(for:), which
reports visibility, split axis and z-index. A custom arrangement conforms to
UIArrangementViewController.Arrangement. The map itself is covered on
Location and Maps, and the September 2026 UIKit changes that accompany
iPhone Duo (reserved regions, UIHingeInteraction, vertical bars) are listed on
UIKit updates.
Choosing a Direction
| Situation | Recommended approach |
|---|---|
New app, new screen |
SwiftUI throughout; FieldNotes is built this way. |
A single UIKit/AppKit control SwiftUI lacks |
Wrap it with |
A full UIKit/AppKit screen or system controller |
|
Existing UIKit app adopting SwiftUI |
Start with |
Existing AppKit app adopting SwiftUI |
|
Custom environment shared across the boundary |
A |
Consistent motion across frameworks |
SwiftUI |
WWDC’s overview of the whole topic is WWDC26 — Use SwiftUI with AppKit and UIKit.
References
-
Apple Developer Documentation — About App Development with UIKit
-
Apple Developer Documentation -- NSLayoutConstraint.activate(_:)
-
Apple Developer Documentation -- instantiateViewController(identifier:creator:)
-
Apple Developer Documentation — UICollectionViewDiffableDataSource
-
Apple Developer Documentation — UICollectionView.CellRegistration
-
Apple Developer Documentation -- sizeThatFits(_:uiView:context:)
-
Apple Developer Documentation — UIViewControllerRepresentable
-
Apple Developer Documentation — UITraitBridgedEnvironmentKey
-
Apple Developer Documentation -- UIView.animate(_:changes:completion:)
-
Apple Developer Documentation -- NSAnimationContext.animate(_:changes:completion:)
-
Apple Developer Documentation — UIViewRepresentableContext.animate(changes:completion:)
-
Apple Developer Documentation — Unifying your app’s animations
-
Apple Developer Documentation — Transitioning to the UIKit scene-based life cycle