
Swiftui Uikit Interop
- 2.8k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
swiftui-uikit-interop is a SwiftUI skill for UIViewRepresentable, UIHostingController, coordinator delegates, and two-way state sync across UIKit boundaries.
About
The swiftui-uikit-interop skill bridges UIKit and SwiftUI in both directions for iOS 26 plus with Swift 6.3 patterns and notes back to iOS 16 where stated. UIViewRepresentable wraps UIView subclasses with makeUIView once, updateUIView on state changes, optional dismantleUIView cleanup, and sizeThatFits sizing from iOS 16. UIViewControllerRepresentable covers modal system controllers like document scanners and mail compose with coordinator delegate result routing. The coordinator pattern explains why reference-type delegates are required, parent binding write-back, setting delegates in make not update, and weak coordinator captures to avoid cycles. UIHostingController embedding documents mandatory addChild, constraint pinning, and didMove sequence plus sizingOptions and rootView updates. State sync covers Binding two-way updates with redundancy guards, closure events, environment reads in updateUIView, and Sendable MainActor coordinator guidance. Common mistakes and a review checklist guard view creation in update, missing dismiss paths, and UIHostingConfiguration for collection cells on iOS 16 plus.
- UIViewRepresentable and UIViewControllerRepresentable lifecycle methods.
- Coordinator delegate pattern with parent binding write-back.
- UIHostingController three-step child embedding sequence.
- updateUIView redundancy guards to prevent binding loops.
- UIHostingConfiguration for collection and table view cells.
Swiftui Uikit Interop by the numbers
- 2,848 all-time installs (skills.sh)
- +120 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #59 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
swiftui-uikit-interop capabilities & compatibility
- Capabilities
- uiviewrepresentable lifecycle and sizing · uiviewcontrollerrepresentable modal result handl · coordinator delegate and binding synchronization · uihostingcontroller child containment embedding · environment value propagation into uikit views · uihostingconfiguration for collection and table
- Use cases
- frontend · api development
- Platforms
- macOS
- Pricing
- Free
What swiftui-uikit-interop says it does
Create the view once in `makeUIView`; only configure/update it in `updateUIView`.
The three-step sequence (addChild, add view, didMove) is mandatory.
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-uikit-interopAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.8k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
How do I embed UIKit-only views or controllers in SwiftUI without delegate loops, leaks, or broken containment?
Bridge UIKit views and view controllers into SwiftUI and embed SwiftUI in UIKit with representables and hosting controllers.
Who is it for?
SwiftUI apps integrating camera previews, maps, scanners, PDF views, or migrating UIKit screens incrementally.
Skip if: Skip for pure SwiftUI screens with no UIKit dependencies or server-side API work.
When should I use this skill?
User integrates UIKit views, hosting controllers, document scanners, text views, or migrates UIKit apps to SwiftUI.
What you get
Correct representable lifecycle, coordinator callbacks, hosting controller embedding, and guarded state synchronization patterns.
- embedded SwiftUI screens
- bridged navigation setup
- shared state between UIKit and SwiftUI
By the numbers
- Documents 6 UIKit-to-SwiftUI migration patterns including UIHostingController and UIHostingConfiguration
Files
SwiftUI-UIKit Interop
Bridge UIKit and SwiftUI in both directions. Wrap UIKit views and view controllers for use in SwiftUI, embed SwiftUI views inside UIKit screens, and synchronize state across the boundary. Targets iOS 26+ with Swift 6.3 patterns; notes backward-compatible to iOS 16 unless stated otherwise.
See references/representable-recipes.md for complete wrapping recipes and references/hosting-migration.md for UIKit-to-SwiftUI migration patterns.
Contents
- UIViewRepresentable Protocol
- UIViewControllerRepresentable Protocol
- The Coordinator Pattern
- UIHostingController
- Sizing and Layout
- State Synchronization Patterns
- Sendable Considerations
- Common Mistakes
- Review Checklist
- References
UIViewRepresentable Protocol
Use UIViewRepresentable to wrap any UIView subclass for use in SwiftUI.
Required Methods
struct WrappedTextView: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> UITextView {
// Called ONCE when SwiftUI inserts this view into the hierarchy.
// Create and return the UIKit view. One-time setup goes here.
let textView = UITextView()
textView.delegate = context.coordinator
textView.font = .preferredFont(forTextStyle: .body)
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
// Called on EVERY SwiftUI state change that affects this view.
// Synchronize SwiftUI state into the UIKit view.
// Guard against redundant updates to avoid loops.
if uiView.text != text {
uiView.text = text
}
}
}Lifecycle Timing
| Method | When Called | Purpose |
|---|---|---|
makeCoordinator() | Before makeUIView. Once per representable lifetime. | Create the delegate/datasource reference type. |
makeUIView(context:) | Once, when the representable enters the view tree. | Allocate and configure the UIKit view. |
updateUIView(_:context:) | Immediately after makeUIView, then on every relevant state change. | Push SwiftUI state into the UIKit view. |
dismantleUIView(_:coordinator:) | When the representable is removed from the view tree. | Clean up observers, timers, subscriptions. |
sizeThatFits(_:uiView:context:) | During layout, when SwiftUI needs the view's ideal size. iOS 16+. | Return a custom size proposal. |
Why `updateUIView` is the most important method: SwiftUI calls it every time any @Binding, @State, @Environment, or @Observable property read by the representable changes. All state synchronization from SwiftUI to UIKit happens here. If you skip a property, the UIKit view will fall out of sync.
Optional: dismantleUIView
static func dismantleUIView(_ uiView: UITextView, coordinator: Coordinator) {
// Remove observers, invalidate timers, cancel subscriptions.
// The coordinator is passed in so you can access state stored on it.
coordinator.cancellables.removeAll()
}Optional: sizeThatFits (iOS 16+)
@available(iOS 16.0, *)
func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: UITextView,
context: Context
) -> CGSize? {
// Return nil to fall back to UIKit's intrinsicContentSize.
// Return a CGSize to override SwiftUI's sizing for this view.
let width = proposal.width ?? UIView.layoutFittingExpandedSize.width
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return size
}UIViewControllerRepresentable Protocol
Use UIViewControllerRepresentable to wrap a UIViewController subclass -- typically for system pickers, document scanners, mail compose, or any controller that presents modally.
struct DocumentScannerView: UIViewControllerRepresentable {
@Binding var scannedImages: [UIImage]
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
let scanner = VNDocumentCameraViewController()
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {
// Usually empty for modal controllers -- nothing to push from SwiftUI.
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
}Handling Results from Presented Controllers
The coordinator captures delegate callbacks and routes results back to SwiftUI through the parent's @Binding or closures:
extension DocumentScannerView {
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
let parent: DocumentScannerView
init(_ parent: DocumentScannerView) { self.parent = parent }
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFinishWith scan: VNDocumentCameraScan
) {
parent.scannedImages = (0..<scan.pageCount).map { scan.imageOfPage(at: $0) }
parent.dismiss()
}
func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
parent.dismiss()
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFailWithError error: Error
) {
parent.dismiss()
}
}
}The Coordinator Pattern
Why Coordinators Exist
UIKit delegates, data sources, and target-action patterns require a reference type (class). SwiftUI representable structs are value types and cannot serve as delegates. The Coordinator is a class instance that SwiftUI creates and manages for you -- it lives as long as the representable view.
Structure
Always nest the Coordinator inside the representable or in an extension. Store a reference to parent (the representable struct) so the coordinator can write back to @Binding properties.
struct SearchBarView: UIViewRepresentable {
@Binding var text: String
var onSearch: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UISearchBar {
let bar = UISearchBar()
bar.delegate = context.coordinator // Set delegate HERE, not in updateUIView
return bar
}
func updateUIView(_ uiView: UISearchBar, context: Context) {
if uiView.text != text {
uiView.text = text
}
}
final class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchBarView
init(_ parent: SearchBarView) { self.parent = parent }
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
parent.text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
parent.onSearch(parent.text)
searchBar.resignFirstResponder()
}
}
}Key Rules
1. Set the delegate in `makeUIView`/`makeUIViewController`, never in `updateUIView`. The update method runs on every state change -- setting the delegate there causes redundant assignment and can trigger unexpected side effects.
2. The coordinator's `parent` property is updated automatically. SwiftUI updates the coordinator's reference to the latest representable struct value before each call to updateUIView. This means the coordinator always sees current @Binding values through parent.
3. Use `[weak coordinator]` in closures to avoid retain cycles between the coordinator and UIKit objects that capture it.
UIHostingController
Embed SwiftUI views inside UIKit view controllers using UIHostingController.
Basic Embedding
final class ProfileViewController: UIViewController {
private let hostingController = UIHostingController(rootView: ProfileView())
override func viewDidLoad() {
super.viewDidLoad()
// 1. Add as child
addChild(hostingController)
// 2. Add and constrain the view
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
// 3. Notify the child
hostingController.didMove(toParent: self)
}
}The three-step sequence (addChild, add view, didMove) is mandatory. Skipping any step causes containment callbacks to misfire, which breaks appearance transitions and trait propagation.
Sizing Options (iOS 16+)
@available(iOS 16.0, *)
hostingController.sizingOptions = [.intrinsicContentSize]| Option | Effect |
|---|---|
.intrinsicContentSize | The hosting controller's view reports its SwiftUI content size as intrinsicContentSize. Use in Auto Layout when the hosted view should size itself. |
.preferredContentSize | Updates preferredContentSize to match SwiftUI content. Use when presenting as a popover or form sheet. |
Updating the Root View
When data changes in UIKit, push new state into the hosted SwiftUI view:
func updateProfile(_ profile: Profile) {
hostingController.rootView = ProfileView(profile: profile)
}For observable models, pass an @Observable object and SwiftUI tracks changes automatically -- no need to reassign rootView.
UIHostingConfiguration (iOS 16+)
Render SwiftUI content directly inside UICollectionViewCell or UITableViewCell without managing a child hosting controller:
@available(iOS 16.0, *)
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.contentConfiguration = UIHostingConfiguration {
ItemRow(item: items[indexPath.item])
}
return cell
}Sizing and Layout
intrinsicContentSize Bridging
UIKit views wrapped in UIViewRepresentable communicate their natural size to SwiftUI through intrinsicContentSize. SwiftUI respects this during layout unless overridden by frame() or fixedSize().
fixedSize() and frame() Interactions
| SwiftUI Modifier | Effect on Representable |
|---|---|
| No modifier | SwiftUI uses intrinsicContentSize as ideal size; the view is flexible. |
.fixedSize() | Forces the representable to its ideal (intrinsic) size in both axes. |
.fixedSize(horizontal: true, vertical: false) | Fixes width to intrinsic; height remains flexible. |
.frame(width:height:) | Overrides the proposed size; UIKit view receives this size. |
Auto Layout with UIHostingController
When embedding UIHostingController as a child, pin its view with constraints. Use .sizingOptions = [.intrinsicContentSize] so Auto Layout can query the SwiftUI content's natural size for self-sizing cells or variable-height sections.
State Synchronization Patterns
@Binding: Two-Way Sync (SwiftUI <-> UIKit)
Use @Binding when both sides read and write the same value. The coordinator writes to parent.bindingProperty in delegate callbacks; updateUIView reads the binding and pushes it into the UIKit view.
// SwiftUI -> UIKit: in updateUIView
if uiView.text != text { uiView.text = text }
// UIKit -> SwiftUI: in Coordinator delegate method
func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
}Closures: One-Way Events (UIKit -> SwiftUI)
For fire-and-forget events (button tapped, search submitted, scan completed), pass a closure instead of a binding:
struct WebViewWrapper: UIViewRepresentable {
let url: URL
var onNavigationFinished: ((URL) -> Void)?
}Environment Values
Access SwiftUI environment values inside representable methods via context.environment:
func updateUIView(_ uiView: UITextView, context: Context) {
let isEnabled = context.environment.isEnabled
uiView.isEditable = isEnabled
// Respond to color scheme changes
let colorScheme = context.environment.colorScheme
uiView.backgroundColor = colorScheme == .dark ? .systemGray6 : .white
}Avoiding Update Loops
updateUIView is called whenever SwiftUI state changes -- including changes triggered by the coordinator writing to a @Binding. Guard against redundant updates to prevent infinite loops:
func updateUIView(_ uiView: UITextView, context: Context) {
// GUARD: Only update if values actually differ
if uiView.text != text {
uiView.text = text
}
}Without the guard, setting uiView.text may trigger the delegate's textViewDidChange, which writes to parent.text, which triggers updateUIView again.
Sendable Considerations
UIKit delegate protocols are not Sendable. When the coordinator conforms to a UIKit delegate, it inherits main-actor isolation from UIKit. Mark coordinators @MainActor or use nonisolated only for methods that truly do not touch UIKit state. In Swift 6 strict concurrency:
@MainActor
final class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchBarView
init(_ parent: SearchBarView) { self.parent = parent }
// Delegate methods are main-actor-isolated -- safe to access UIKit and @Binding.
}If passing closures across isolation boundaries, ensure they are @Sendable or captured on the correct actor.
Common Mistakes
DO / DON'T
DON'T: Create the UIKit view in updateUIView. DO: Create the view once in makeUIView; only configure/update it in updateUIView. Why: updateUIView runs on every state change. Creating a new view each time destroys all UIKit state (selection, scroll position, first responder) and leaks memory.
DON'T: Set delegates in updateUIView. DO: Set delegates in makeUIView/makeUIViewController only. Why: Redundant delegate assignment on every update can reset internal delegate state in UIKit views like WKWebView or MKMapView.
DON'T: Hold strong references to the Coordinator from closures. DO: Use [weak coordinator] in closures. Why: UIKit objects often store closures (completion handlers, action blocks). A strong reference to the coordinator that holds a reference to the UIKit view creates a retain cycle.
DON'T: Forget to call parent.dismiss() or completion handlers. DO: Use the coordinator to track dismissal and invoke parent.dismiss() in all delegate exit paths. Why: Modal controllers presented by SwiftUI (via .sheet) need their dismiss binding toggled, or the sheet state becomes inconsistent.
DON'T: Ignore dismantleUIView for views that hold observers or timers. DO: Clean up NotificationCenter observers, Combine subscriptions, and Timer instances in dismantleUIView. Why: Without cleanup, observers and timers continue firing after the view is removed, causing crashes or stale state updates.
DON'T: Force UIHostingController's view to fill the parent without proper constraints. DO: Use Auto Layout constraints or sizingOptions for proper embedding. Why: Setting frame manually breaks adaptive layout, trait propagation, and safe area handling.
DON'T: Try to use @State in the Coordinator -- it is not a View. DO: Use regular stored properties on the Coordinator and communicate to SwiftUI via parent's @Binding properties. Why: @State only works inside View conformances. Using it on a class has no effect.
DON'T: Skip the addChild/didMove(toParent:) dance when embedding UIHostingController. DO: Always call addChild(_:), add the view to the hierarchy, then call didMove(toParent:). Why: Skipping containment causes viewWillAppear/viewDidAppear to never fire, breaks trait collection propagation, and causes visual glitches.
Review Checklist
- [ ] View/controller created in
make*, notupdate* - [ ] Coordinator set as delegate in
make*, notupdate* - [ ]
@Bindingused for two-way state sync - [ ]
updateUIViewhandles all SwiftUI state changes with redundancy guards - [ ]
dismantleUIViewcleans up observers/timers if needed - [ ] No retain cycles between coordinator and closures (
[weak coordinator]) - [ ]
UIHostingControllerproperly added as child (addChild+didMove(toParent:)) - [ ] Sizing strategy chosen (
intrinsicContentSizevs fixedframevssizeThatFits) - [ ] Environment values read in
updateUIViewviacontext.environmentwhere needed - [ ] Coordinator marked
@MainActorfor strict concurrency - [ ] Modal controllers dismiss in all delegate exit paths (success, cancel, error)
- [ ]
UIHostingConfigurationused for collection/table view cells instead of manual hosting (iOS 16+)
References
- Wrapping recipes: references/representable-recipes.md
- Migration patterns: references/hosting-migration.md
- Apple docs: UIViewRepresentable
- Apple docs: UIViewControllerRepresentable
- Apple docs: UIHostingController
UIKit-to-SwiftUI Migration Patterns
Patterns for incrementally migrating a UIKit app to SwiftUI. Each pattern is self-contained with rationale, implementation, and gotchas.
---
Contents
- 1. Screen-by-Screen Migration
- 2. UIHostingController as Child
- 3. Navigation Bridging
- 4. Data Sharing Between UIKit and SwiftUI
- 5. UIHostingConfiguration (iOS 16+)
- 6. Environment Bridging
1. Screen-by-Screen Migration
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.
Strategy
1. Pick a leaf screen (one that does not contain child view controllers). 2. Rewrite its UI in SwiftUI. 3. Replace the UIKit view controller with UIHostingController wherever it was instantiated. 4. Wire navigation from the parent UIKit code into the hosting controller.
Implementation
// 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)Passing Dismiss/Navigation Callbacks
When the SwiftUI screen needs to pop itself or trigger navigation in the UIKit stack:
struct ItemDetailView: View {
let item: Item
var onDelete: (() -> Void)?
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
Text(item.title)
Button("Delete", role: .destructive) {
onDelete?()
dismiss()
}
}
}
}
// In UIKit:
let detailView = ItemDetailView(item: item) {
self.dataSource.delete(item)
self.navigationController?.popViewController(animated: true)
}
let hostingVC = UIHostingController(rootView: detailView)Gotchas
- Navigation bar.
UIHostingControllerinherits navigation bar visibility from its parentUINavigationController. Use.navigationTitle()and.toolbar()in the SwiftUI view -- they propagate to the UIKit navigation bar automatically. - Large titles. Set
hostingVC.navigationItem.largeTitleDisplayModein UIKit code if the SwiftUI.navigationBarTitleDisplayMode()modifier does not apply correctly. - Tab bar insets.
UIHostingControllerrespectsadditionalSafeAreaInsets. If the content overlaps the tab bar, verify safe area propagation.
---
2. UIHostingController as Child
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.
Implementation
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)
}
}With @Observable Model
Pass 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 = newStatsGotchas
- Background color.
UIHostingController's view has an opaque system background by default. SethostingVC.view.backgroundColor = .clearif embedding over existing content. - sizingOptions on iOS 16+. Without
.intrinsicContentSize, the hosted view may report zero size in Auto Layout, causing the container to collapse. - Memory. Store the hosting controller in a property. If it is only held as a child, removing it from the parent deallocates it and the SwiftUI view disappears.
---
3. Navigation Bridging
Mix UIKit and SwiftUI screens in the same UINavigationController stack.
UIKit Pushing SwiftUI
// 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)
}SwiftUI Pushing UIKit
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) {}
}Passing NavigationController Reference
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 }
}
}Gotchas
- Back button. When pushing
UIHostingControlleronto aUINavigationController, the back button works automatically. Do not add a manual back button in the SwiftUI view. - Double navigation bars. If the SwiftUI view uses
NavigationStack, it creates its own navigation bar inside the UIKit one. RemoveNavigationStackfrom SwiftUI views presented insideUINavigationController. - Toolbar items. SwiftUI
.toolbaritems propagate to the UIKit navigation bar when hosted inUIHostingController. This works reliably on iOS 16+.
---
4. Data Sharing Between UIKit and SwiftUI
Using @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)
}
}
}
}Reactive Updates in UIKit with Combine
If UIKit code needs to react to @Observable changes, bridge with a withObservationTracking loop or use Combine:
import Combine
import Observation
final class DashboardViewController: UIViewController {
let state: AppState
private var observationTask: Task<Void, Never>?
override func viewDidLoad() {
super.viewDidLoad()
startObserving()
}
private func startObserving() {
observationTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard let self else { return }
withObservationTracking {
self.updateUI(unreadCount: self.state.unreadCount)
} onChange: {
// Triggers next iteration
}
try? await Task.sleep(for: .zero) // Yield to allow onChange to fire
}
}
}
private func updateUI(unreadCount: Int) {
badgeLabel.text = "\(unreadCount)"
}
deinit { observationTask?.cancel() }
}Legacy: ObservableObject with Combine
For iOS 15-16 or existing ObservableObject models, subscribe to objectWillChange:
final class SettingsViewController: UIViewController {
let settings: SettingsModel // ObservableObject
private var cancellable: AnyCancellable?
override func viewDidLoad() {
super.viewDidLoad()
cancellable = settings.objectWillChange
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.updateUI()
}
}
}Gotchas
- `@Observable` does not trigger UIKit updates automatically. Unlike SwiftUI views, UIKit code must manually observe changes via
withObservationTrackingorCombine. - Thread safety. Mutate
@Observableproperties on@MainActorwhen they drive UI in both UIKit and SwiftUI. - Retain cycles. Use
[weak self]in Combine sinks and task closures. Store cancellables and tasks, then cancel indeinit.
---
5. UIHostingConfiguration (iOS 16+)
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.
UICollectionView with SwiftUI Cells
@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
}UITableView with SwiftUI Cells
@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
}Self-Sizing
UIHostingConfiguration cells self-size automatically. Ensure:
- The table/collection view uses
UICollectionViewCompositionalLayoutwith estimated dimensions, ortableView.rowHeight = UITableView.automaticDimension. - The SwiftUI content has defined height (via content or explicit
.frame).
Background Customization
cell.contentConfiguration = UIHostingConfiguration {
ItemRowView(item: item)
}
.background {
RoundedRectangle(cornerRadius: 12)
.fill(.background)
}
.margins(.horizontal, 16)
.minSize(height: 60)Gotchas
- Performance. Each
UIHostingConfigurationcreates a lightweight hosting controller. For very large lists (10,000+ items), profile with Instruments to ensure smooth scrolling. - State management. The SwiftUI content inside
UIHostingConfigurationis recreated on each cell reuse. Do not store@Statethat needs to persist across reuse -- use the data model instead. - Swipe actions. Configure swipe actions in UIKit (
leadingSwipeActionsConfigurationForRowAt), not inside the SwiftUI content. - No `@Environment` propagation by default. Environment values from the UIKit context are not automatically available. Inject them explicitly in the
UIHostingConfigurationclosure.
---
6. Environment Bridging
Pass SwiftUI environment values into hosted SwiftUI views from UIKit, and access UIKit traits from SwiftUI.
Injecting Environment into UIHostingController
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).
Trait Collection to SwiftUI Environment
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).
Custom Environment Values Across the Bridge
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()
}
}
}Updating Environment After Creation
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 = .adminGotchas
- `@Environment(\.dismiss)` in hosted views. This works when the
UIHostingControlleris presented modally (viapresent(_:animated:)). It does NOT work when the hosting controller is pushed onto aUINavigationController-- use the navigation controller'spopViewControllerinstead. - Missing environment. If a SwiftUI view expects an
@Environmentobject and it is not provided, the app crashes at runtime. Always set required environment values before creating the hosting controller. - Overriding traits. Use
hostingVC.overrideUserInterfaceStyleto force light/dark mode for a hosted SwiftUI view. This propagates to\.colorSchemeautomatically.
Representable Recipes
Complete working recipes for common UIKit wrapping scenarios. Each recipe includes the full UIViewRepresentable or UIViewControllerRepresentable struct, the Coordinator with delegate methods, a SwiftUI usage example, and gotchas specific to that wrapper.
---
Contents
- 1. MKMapView Wrapper
- 2. UITextView Wrapper (Attributed Text)
- 3. AVCaptureVideoPreviewLayer Wrapper
- 4. PHPickerViewController Wrapper
- 5. MFMailComposeViewController Wrapper
- 6. UIActivityViewController Wrapper (Share Sheet)
- 7. UISearchBar Wrapper
- 8. PDFView Wrapper (PDFKit)
- 9. MFMessageComposeViewController Wrapper
Native WebKit for SwiftUI now covers modern embedded web content on iOS 26+. See theswiftui-webkitskill forWebView,WebPage, navigation policies, JavaScript calls, and migration guidance. Keep this file focused on generic representable patterns.
1. MKMapView Wrapper
Display a map with annotations, track region changes, and toggle map type.
import SwiftUI
import MapKit
struct MapViewRepresentable: UIViewRepresentable {
@Binding var region: MKCoordinateRegion
@Binding var mapType: MKMapType
var annotations: [MKPointAnnotation]
var onRegionChanged: ((MKCoordinateRegion) -> Void)?
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
mapView.showsUserLocation = true
return mapView
}
func updateUIView(_ uiView: MKMapView, context: Context) {
// Update map type
if uiView.mapType != mapType {
uiView.mapType = mapType
}
// Update region -- guard against tiny differences to avoid feedback loops
let currentCenter = uiView.region.center
let threshold = 0.0001
if abs(currentCenter.latitude - region.center.latitude) > threshold ||
abs(currentCenter.longitude - region.center.longitude) > threshold {
uiView.setRegion(region, animated: true)
}
// Diff annotations
let existing = Set(uiView.annotations.compactMap { $0 as? MKPointAnnotation })
let incoming = Set(annotations)
let toRemove = existing.subtracting(incoming)
let toAdd = incoming.subtracting(existing)
uiView.removeAnnotations(Array(toRemove))
uiView.addAnnotations(Array(toAdd))
}
final class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapViewRepresentable
init(_ parent: MapViewRepresentable) { self.parent = parent }
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
parent.region = mapView.region
parent.onRegionChanged?(mapView.region)
}
func mapView(
_ mapView: MKMapView,
viewFor annotation: MKAnnotation
) -> MKAnnotationView? {
guard !(annotation is MKUserLocation) else { return nil }
let id = "pin"
let view = mapView.dequeueReusableAnnotationView(withIdentifier: id)
?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: id)
view.annotation = annotation
return view
}
}
}Usage
struct MapScreen: View {
@State private var region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
@State private var mapType: MKMapType = .standard
var body: some View {
MapViewRepresentable(
region: $region,
mapType: $mapType,
annotations: []
)
.ignoresSafeArea()
}
}Gotchas
- Region update loops. The delegate writes to
@Binding region, which triggersupdateUIView, which callssetRegion, which triggers the delegate again. The threshold guard is essential. - Annotation diffing. MKMapView does not handle duplicate annotations well. Always diff before adding/removing.
- Native SwiftUI Map. For iOS 17+, prefer the native
Mapview unless you need delegate-level control (custom overlays, clustering, etc.).
---
2. UITextView Wrapper (Attributed Text)
Wrap UITextView for rich text editing with NSAttributedString binding and placeholder support.
import SwiftUI
struct RichTextEditor: UIViewRepresentable {
@Binding var attributedText: NSAttributedString
var placeholder: String = ""
@Binding var isFirstResponder: Bool
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UITextView {
let textView = UITextView()
textView.delegate = context.coordinator
textView.font = .preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
textView.backgroundColor = .clear
textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
// Placeholder label
let label = UILabel()
label.text = placeholder
label.font = .preferredFont(forTextStyle: .body)
label.textColor = .placeholderText
label.tag = 999
label.translatesAutoresizingMaskIntoConstraints = false
textView.addSubview(label)
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: textView.topAnchor, constant: 8),
label.leadingAnchor.constraint(equalTo: textView.leadingAnchor, constant: 8),
])
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
if uiView.attributedText != attributedText {
uiView.attributedText = attributedText
}
// Update placeholder visibility
if let label = uiView.viewWithTag(999) as? UILabel {
label.isHidden = !uiView.text.isEmpty
}
// First responder management
if isFirstResponder && !uiView.isFirstResponder {
uiView.becomeFirstResponder()
} else if !isFirstResponder && uiView.isFirstResponder {
uiView.resignFirstResponder()
}
}
@available(iOS 16.0, *)
func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: UITextView,
context: Context
) -> CGSize? {
let width = proposal.width ?? UIView.layoutFittingExpandedSize.width
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return CGSize(width: width, height: max(size.height, 44))
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: RichTextEditor
init(_ parent: RichTextEditor) { self.parent = parent }
func textViewDidChange(_ textView: UITextView) {
parent.attributedText = textView.attributedText ?? NSAttributedString()
if let label = textView.viewWithTag(999) as? UILabel {
label.isHidden = !textView.text.isEmpty
}
}
func textViewDidBeginEditing(_ textView: UITextView) {
parent.isFirstResponder = true
}
func textViewDidEndEditing(_ textView: UITextView) {
parent.isFirstResponder = false
}
}
}Usage
struct NotesEditorView: View {
@State private var text = NSAttributedString()
@State private var isFocused = false
var body: some View {
RichTextEditor(
attributedText: $text,
placeholder: "Write something...",
isFirstResponder: $isFocused
)
.frame(minHeight: 100)
}
}Gotchas
- `NSAttributedString` comparison. The equality check in
updateUIViewis critical -- without it, every keystroke triggers a full re-render loop. - First responder management. Avoid calling
becomeFirstResponder()unconditionally inupdateUIView-- it steals focus from other fields. - iOS 26 alternative.
TextEditorin iOS 26 supportsAttributedStringnatively. Prefer it unless you needNSAttributedStringor delegate-level control.
---
3. AVCaptureVideoPreviewLayer Wrapper
Display a live camera preview. The preview layer requires a UIView host.
import SwiftUI
import AVFoundation
struct CameraPreview: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> CameraPreviewUIView {
let view = CameraPreviewUIView()
view.previewLayer.session = session
view.previewLayer.videoGravity = .resizeAspectFill
return view
}
func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {
// Session is reference type -- no update needed unless swapping sessions
if uiView.previewLayer.session !== session {
uiView.previewLayer.session = session
}
}
}
final class CameraPreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer {
layer as! AVCaptureVideoPreviewLayer
}
override func layoutSubviews() {
super.layoutSubviews()
previewLayer.frame = bounds
}
}Usage
struct CameraScreen: View {
@State private var cameraManager = CameraManager()
var body: some View {
CameraPreview(session: cameraManager.session)
.ignoresSafeArea()
.task { await cameraManager.start() }
}
}Gotchas
- Use a custom UIView subclass with `layerClass`. Overriding
layerClassavoids adding a sublayer and ensures the preview layer resizes automatically with the view. - Session management belongs outside the representable. Create and manage
AVCaptureSessionin a separate model. The representable only displays it. - Orientation. Set
previewLayer.connection?.videoRotationAngleif supporting device rotation.
---
4. PHPickerViewController Wrapper
Multi-select photo picker that loads selected images asynchronously.
import SwiftUI
import PhotosUI
struct PhotoPicker: UIViewControllerRepresentable {
@Binding var selectedImages: [UIImage]
var selectionLimit: Int = 0 // 0 = unlimited
@Environment(\.dismiss) private var dismiss
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration(photoLibrary: .shared())
config.filter = .images
config.selectionLimit = selectionLimit
config.preferredAssetRepresentationMode = .current
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
// Nothing to update -- configuration is immutable after creation
}
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: PhotoPicker
init(_ parent: PhotoPicker) { self.parent = parent }
func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]
) {
parent.dismiss()
guard !results.isEmpty else { return }
Task { @MainActor in
var images: [UIImage] = []
for result in results {
if let image = await loadImage(from: result.itemProvider) {
images.append(image)
}
}
parent.selectedImages = images
}
}
private func loadImage(from provider: NSItemProvider) async -> UIImage? {
await withCheckedContinuation { continuation in
if provider.canLoadObject(ofClass: UIImage.self) {
provider.loadObject(ofClass: UIImage.self) { image, _ in
continuation.resume(returning: image as? UIImage)
}
} else {
continuation.resume(returning: nil)
}
}
}
}
}Usage
struct ImagePickerDemo: View {
@State private var images: [UIImage] = []
@State private var showPicker = false
var body: some View {
VStack {
ScrollView(.horizontal) {
HStack {
ForEach(images.indices, id: \.self) { i in
Image(uiImage: images[i])
.resizable()
.scaledToFill()
.frame(width: 100, height: 100)
.clipShape(.rect(cornerRadius: 8))
}
}
}
Button("Pick Photos") { showPicker = true }
}
.sheet(isPresented: $showPicker) {
PhotoPicker(selectedImages: $images, selectionLimit: 5)
}
}
}Gotchas
- Always dismiss in the delegate.
picker(_:didFinishPicking:)is called for both selection and cancellation (with empty results). Dismiss in both cases. - Async image loading.
NSItemProvider.loadObjectis completion-based. Wrap inwithCheckedContinuationfor async/await usage. Load images after dismissal to avoid blocking the picker UI. - iOS 17 alternative.
PhotosUI.PhotosPickeris a native SwiftUI view. Prefer it unless you need custom picker UI or advanced filtering.
---
5. MFMailComposeViewController Wrapper
Present the system email composer with pre-filled fields and handle the result.
import SwiftUI
import MessageUI
struct MailComposer: UIViewControllerRepresentable {
let subject: String
let recipients: [String]
let body: String
var isHTML: Bool = false
var onResult: ((MFMailComposeResult) -> Void)?
@Environment(\.dismiss) private var dismiss
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIViewController(context: Context) -> MFMailComposeViewController {
let controller = MFMailComposeViewController()
controller.mailComposeDelegate = context.coordinator
controller.setSubject(subject)
controller.setToRecipients(recipients)
controller.setMessageBody(body, isHTML: isHTML)
return controller
}
func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {
// Cannot update mail compose after presentation
}
final class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
let parent: MailComposer
init(_ parent: MailComposer) { self.parent = parent }
func mailComposeController(
_ controller: MFMailComposeViewController,
didFinishWith result: MFMailComposeResult,
error: Error?
) {
parent.onResult?(result)
parent.dismiss()
}
}
}Usage
struct FeedbackView: View {
@State private var showMail = false
var body: some View {
Button("Send Feedback") {
guard MFMailComposeViewController.canSendMail() else { return }
showMail = true
}
.sheet(isPresented: $showMail) {
MailComposer(
subject: "App Feedback",
recipients: ["support@example.com"],
body: "I have feedback about..."
) { result in
print("Mail result: \(result.rawValue)")
}
}
}
}Gotchas
- Check `canSendMail()` before presenting. The app crashes if
MFMailComposeViewControlleris presented on a device with no mail account configured. - Cannot update after presentation.
updateUIViewControlleris intentionally empty -- the mail compose API does not support changing fields after the controller is shown. - The delegate protocol name is `MFMailComposeViewControllerDelegate`, not
MFMailComposeDelegate.
---
6. UIActivityViewController Wrapper (Share Sheet)
Present the system share sheet. This is a UIViewControllerRepresentable because UIActivityViewController is a controller, not a view.
import SwiftUI
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
var activities: [UIActivity]? = nil
var excludedTypes: [UIActivity.ActivityType]? = nil
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: items,
applicationActivities: activities
)
controller.excludedActivityTypes = excludedTypes
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
// Cannot update after presentation
}
}Usage
struct ContentView: View {
@State private var showShare = false
var body: some View {
Button("Share") { showShare = true }
.sheet(isPresented: $showShare) {
ShareSheet(items: ["Check out this app!", URL(string: "https://example.com")!])
.presentationDetents([.medium])
}
}
}Gotchas
- Present via `.sheet`. Do not try to use
UIActivityViewControlleras an inline view -- it is a modal controller. - iPad requires `popoverPresentationController`. When using on iPad outside of
.sheet, set the source view/rect on the popover controller. SwiftUI's.sheethandles this automatically. - iOS 16+ alternative.
ShareLinkis a native SwiftUI view for Transferable items. Prefer it for simple sharing.
---
7. UISearchBar Wrapper
Wrap UISearchBar with delegate-based callbacks, debounce support, and cancel button handling.
import SwiftUI
import Combine
struct SearchBar: UIViewRepresentable {
@Binding var text: String
var placeholder: String = "Search"
var onSearch: ((String) -> Void)?
var onCancel: (() -> Void)?
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UISearchBar {
let searchBar = UISearchBar()
searchBar.delegate = context.coordinator
searchBar.placeholder = placeholder
searchBar.searchBarStyle = .minimal
searchBar.autocapitalizationType = .none
return searchBar
}
func updateUIView(_ uiView: UISearchBar, context: Context) {
if uiView.text != text {
uiView.text = text
}
}
final class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchBar
private var debounceTask: Task<Void, Never>?
init(_ parent: SearchBar) { self.parent = parent }
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
parent.text = searchText
searchBar.showsCancelButton = !searchText.isEmpty
// Debounce search
debounceTask?.cancel()
debounceTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
parent.onSearch?(searchText)
}
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
debounceTask?.cancel()
parent.onSearch?(parent.text)
searchBar.resignFirstResponder()
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
parent.text = ""
parent.onCancel?()
searchBar.resignFirstResponder()
searchBar.showsCancelButton = false
}
}
}Usage
struct SearchableList: View {
@State private var query = ""
@State private var results: [String] = []
var body: some View {
VStack(spacing: 0) {
SearchBar(text: $query, placeholder: "Search items") { text in
results = performSearch(text)
}
List(results, id: \.self) { Text($0) }
}
}
}Gotchas
- Native `.searchable` modifier. Prefer SwiftUI's
.searchable(text:)modifier for standard search patterns. Use this wrapper only when you need precise control over search bar appearance or delegate timing. - Debounce with `Task.sleep`. Cancel the previous task before starting a new one to debounce.
Combineis not needed. - Cancel button state. Toggle
showsCancelButtonin the delegate, not inupdateUIView, to avoid layout jumps.
---
8. PDFView Wrapper (PDFKit)
Display PDF documents in SwiftUI using PDFView from PDFKit. Supports loading from URL, Data, or file path, with configurable display mode and auto-scaling.
import SwiftUI
import PDFKit
struct PDFViewer: UIViewRepresentable {
let document: PDFDocument?
var displayMode: PDFDisplayMode = .singlePageContinuous
var autoScales: Bool = true
var displayDirection: PDFDisplayDirection = .vertical
var pageShadowsEnabled: Bool = true
func makeUIView(context: Context) -> PDFView {
let pdfView = PDFView()
pdfView.displayMode = displayMode
pdfView.displayDirection = displayDirection
pdfView.autoScales = autoScales
pdfView.pageShadowsEnabled = pageShadowsEnabled
pdfView.document = document
return pdfView
}
func updateUIView(_ uiView: PDFView, context: Context) {
// Update document if it changed (reference comparison)
if uiView.document !== document {
uiView.document = document
}
if uiView.displayMode != displayMode {
uiView.displayMode = displayMode
}
if uiView.autoScales != autoScales {
uiView.autoScales = autoScales
}
}
}Convenience Initializers
extension PDFViewer {
/// Load a PDF from a URL (local file or remote).
init(url: URL, displayMode: PDFDisplayMode = .singlePageContinuous) {
self.document = PDFDocument(url: url)
self.displayMode = displayMode
}
/// Load a PDF from raw data.
init(data: Data, displayMode: PDFDisplayMode = .singlePageContinuous) {
self.document = PDFDocument(data: data)
self.displayMode = displayMode
}
}Usage
struct DocumentView: View {
let pdfURL: URL
var body: some View {
PDFViewer(url: pdfURL)
.ignoresSafeArea(edges: .bottom)
.navigationTitle("Document")
.navigationBarTitleDisplayMode(.inline)
}
}With Async Loading
struct RemotePDFView: View {
let url: URL
@State private var document: PDFDocument?
@State private var isLoading = true
@State private var errorMessage: String?
var body: some View {
Group {
if let document {
PDFViewer(document: document)
} else if isLoading {
ProgressView("Loading PDF...")
} else if let errorMessage {
ContentUnavailableView(
"Could Not Load PDF",
systemImage: "doc.text.fill",
description: Text(errorMessage)
)
}
}
.task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
document = PDFDocument(data: data)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}
}PDFView with Page Navigation
struct NavigablePDFView: UIViewRepresentable {
let document: PDFDocument?
@Binding var currentPageIndex: Int
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> PDFView {
let pdfView = PDFView()
pdfView.displayMode = .singlePageContinuous
pdfView.autoScales = true
pdfView.document = document
NotificationCenter.default.addObserver(
context.coordinator,
selector: #selector(Coordinator.pageChanged(_:)),
name: .PDFViewPageChanged,
object: pdfView
)
return pdfView
}
func updateUIView(_ uiView: PDFView, context: Context) {
if uiView.document !== document {
uiView.document = document
}
// Navigate to page if binding changed externally
if let doc = uiView.document,
let page = doc.page(at: currentPageIndex),
uiView.currentPage != page {
uiView.go(to: page)
}
}
static func dismantleUIView(_ uiView: PDFView, coordinator: Coordinator) {
NotificationCenter.default.removeObserver(coordinator)
}
final class Coordinator: NSObject {
var parent: NavigablePDFView
init(_ parent: NavigablePDFView) { self.parent = parent }
@objc func pageChanged(_ notification: Notification) {
guard let pdfView = notification.object as? PDFView,
let currentPage = pdfView.currentPage,
let document = pdfView.document else { return }
let index = document.index(for: currentPage)
if parent.currentPageIndex != index {
parent.currentPageIndex = index
}
}
}
}Gotchas
- `PDFView` inherits from `UIView`. Use
UIViewRepresentable, notUIViewControllerRepresentable. - Document is a reference type. Use
!==for identity comparison inupdateUIViewto avoid unnecessary reloads. - Page change notifications. Use
NotificationCenterwith.PDFViewPageChanged--PDFViewdoes not use a delegate pattern for page changes. - Remove observers in `dismantleUIView`. Failing to remove
NotificationCenterobservers causes crashes after the view is removed. - `autoScales` fits the PDF to the view width. Disable it if you want the user to start at a specific zoom level.
- Thread safety.
PDFDocumentloading can be expensive. Load asynchronously and assign on the main thread.
Docs: PDFView | PDFKit
---
9. MFMessageComposeViewController Wrapper
Present the system SMS/MMS composer with pre-filled recipients, body, and optional attachments. Companion to Recipe 6 (MFMailComposeViewController).
import SwiftUI
import MessageUI
struct MessageComposer: UIViewControllerRepresentable {
let recipients: [String]
let body: String
var attachments: [MessageAttachment] = []
var onResult: ((MessageComposeResult) -> Void)?
@Environment(\.dismiss) private var dismiss
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIViewController(context: Context) -> MFMessageComposeViewController {
let controller = MFMessageComposeViewController()
controller.messageComposeDelegate = context.coordinator
controller.recipients = recipients
controller.body = body
for attachment in attachments {
controller.addAttachmentData(
attachment.data,
typeIdentifier: attachment.typeIdentifier,
filename: attachment.filename
)
}
return controller
}
func updateUIViewController(
_ uiViewController: MFMessageComposeViewController,
context: Context
) {
// Cannot update message compose after presentation
}
final class Coordinator: NSObject, MFMessageComposeViewControllerDelegate {
let parent: MessageComposer
init(_ parent: MessageComposer) { self.parent = parent }
func messageComposeViewController(
_ controller: MFMessageComposeViewController,
didFinishWith result: MessageComposeResult
) {
parent.onResult?(result)
parent.dismiss()
}
}
}
struct MessageAttachment {
let data: Data
let typeIdentifier: String // UTI, e.g., "public.jpeg"
let filename: String
}Usage
struct InviteView: View {
@State private var showMessage = false
var body: some View {
Button("Send Invite via SMS") {
guard MFMessageComposeViewController.canSendText() else { return }
showMessage = true
}
.sheet(isPresented: $showMessage) {
MessageComposer(
recipients: ["+1234567890"],
body: "Join me on this app!"
) { result in
switch result {
case .sent:
print("Message sent")
case .cancelled:
print("User cancelled")
case .failed:
print("Message failed")
@unknown default:
break
}
}
}
}
}With Image Attachment
struct SharePhotoView: View {
@State private var showMessage = false
let image: UIImage
var body: some View {
Button("Send Photo") {
guard MFMessageComposeViewController.canSendText(),
MFMessageComposeViewController.canSendAttachments() else {
return
}
showMessage = true
}
.sheet(isPresented: $showMessage) {
MessageComposer(
recipients: [],
body: "Check out this photo!",
attachments: [
MessageAttachment(
data: image.jpegData(compressionQuality: 0.8) ?? Data(),
typeIdentifier: "public.jpeg",
filename: "photo.jpg"
)
]
)
}
}
}Gotchas
- Check `canSendText()` before presenting. The app crashes if
MFMessageComposeViewControlleris presented on a device that cannot send texts (e.g., iPod touch without iMessage). - Check `canSendAttachments()` before adding attachments. Not all devices or carriers support MMS attachments.
- The delegate protocol is `MFMessageComposeViewControllerDelegate`, not
MFMessageComposeDelegate. It has a single required method. - Cannot update after presentation. Like
MFMailComposeViewController, the message composer API does not support changing fields after the controller is shown. - iMessage vs. SMS. The controller automatically uses iMessage when available. You cannot force one protocol over the other.
- Simulator limitation.
canSendText()returnsfalseon the simulator. Test on a physical device.
Docs: MFMessageComposeViewController | MFMessageComposeViewControllerDelegate
Related skills
FAQ
Where should UIKit delegates be assigned?
Set delegates in makeUIView or makeUIViewController, never in updateUIView on every state change.
How prevent updateUIView infinite loops?
Guard writes so UIKit properties update only when values differ from SwiftUI bindings.
What is the UIHostingController embedding sequence?
addChild, add and constrain the view, then didMove to parent for correct lifecycle and traits.
Is Swiftui Uikit Interop safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.