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: FieldNotesApp, ContentView, NoteRow, NoteEditor and every other view in the canonical code are SwiftUI.

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: NSApplication, NSWindow, NSViewController, NSView, NSMenu, the Cocoa text system. Mac-specific behavior such as fine-grained menu validation or custom window chrome still lives here.

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)

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)
    }
}

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()
        }
    }
}

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)

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)
}

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)
    }
}

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
    }
}

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

UIViewRepresentable

a UIView

makeUIView(context:), updateUIView(_:context:)

UIViewControllerRepresentable

a UIViewController

makeUIViewController(context:), updateUIViewController(_:context:)

NSViewRepresentable

an NSView

makeNSView(context:), updateNSView(_:context:)

NSViewControllerRepresentable

an NSViewController

makeNSViewController(context:), updateNSViewController(_:context:)

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

Three habits keep representables correct:

  • Create once, update often. makeUIView runs once per identity; updateUIView runs on every SwiftUI update that touches the representable’s inputs. Put configuration that never changes in make, and make update idempotent and cheap.

  • Guard against feedback loops. Writing textView.text unconditionally in update would 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

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.

sequenceDiagram participant SUI as SwiftUI (NoteEditor) participant Rep as NoteBodyTextView participant Coord as Coordinator participant View as UITextView SUI->>Rep: First appearance Rep->>Coord: makeCoordinator() Rep->>View: makeUIView(context:) Rep->>View: textView.delegate = context.coordinator SUI->>Rep: updateUIView(_:context:) with current text Rep->>View: Set text if different loop Every keystroke View->>Coord: textViewDidChange(_:) Coord->>SUI: text.wrappedValue = textView.text SUI->>Rep: updateUIView(_:context:) Rep->>View: Text already equal, no write end SUI->>Rep: sizeThatFits(_:uiView:context:) on layout SUI->>Rep: View removed from hierarchy Rep->>View: dismantleUIView(_:coordinator:) View--xCoord: delegate = nil, coordinator released

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 = .compact set 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, through traitCollection.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

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

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

UISplitArrangement (the default)

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. axes(_:) restricts which axes the split may use.

UIOverlayArrangement

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; axes(_:) restricts the axes on which that transition may happen.

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

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 UIViewRepresentable/NSViewRepresentable and a coordinator, as NoteBodyTextView does.

A full UIKit/AppKit screen or system controller

UIViewControllerRepresentable/NSViewControllerRepresentable.

Existing UIKit app adopting SwiftUI

Start with UIHostingConfiguration cells and UIHostingController screens, one screen at a time.

Existing AppKit app adopting SwiftUI

NSHostingController for windows and panes, NSHostingView for embedded pieces, NSHostingMenu for menus.

Custom environment shared across the boundary

A UITraitDefinition plus a UITraitBridgedEnvironmentKey.

Consistent motion across frameworks

SwiftUI Animation values in UIView.animate, NSAnimationContext.animate and context.animate.

WWDC’s overview of the whole topic is WWDC26 — Use SwiftUI with AppKit and UIKit.

References