Use when wrapping UIKit views/controllers in SwiftUI, embedding SwiftUI in UIKit, or debugging UIKit-SwiftUI interop issues. Covers UIViewRepresentable, UIViewControllerRepresentable, UIHostingController, UIHostingConfiguration, coordinators, lifecycle, state binding, memory management.
Systematic guidance for bridging UIKit and SwiftUI. Most production iOS apps need both — this skill teaches the bridging patterns themselves, not the domain-specific views being bridged.
digraph bridge {
start [label="What are you bridging?" shape=diamond];
start -> "UIViewRepresentable" [label="UIView subclass → SwiftUI"];
start -> "UIViewControllerRepresentable" [label="UIViewController → SwiftUI"];
start -> "UIGestureRecognizerRepresentable" [label="UIGestureRecognizer → SwiftUI\n(iOS 18+)"];
start -> "UIHostingController" [label="SwiftUI view → UIKit"];
start -> "UIHostingConfiguration" [label="SwiftUI in UIKit cell\n(iOS 16+)"];
"UIViewRepresentable" [shape=box];
"UIViewControllerRepresentable" [shape=box];
"UIGestureRecognizerRepresentable" [shape=box];
"UIHostingController" [shape=box];
"UIHostingConfiguration" [shape=box];
}
Quick rules:
UIView → UIViewRepresentable (Part 1)UIViewController → UIViewControllerRepresentable (Part 2)UIGestureRecognizer subclass → UIGestureRecognizerRepresentable (Part 2b, iOS 18+)UIHostingController (Part 3)UIHostingConfiguration (Part 3)@Observable shared model (Part 4)Use when you have a UIView subclass (MKMapView, WKWebView, custom drawing views) and need it in SwiftUI.
makeUIView(context:) → Called ONCE. Create and configure the view.
updateUIView(_:context:) → Called on EVERY SwiftUI state change. Patch, don't recreate.
dismantleUIView(_:coordinator:) → Called when removed from hierarchy. Clean up observers/timers.
Critical: updateUIView is called frequently. Guard against unnecessary work:
struct MapView: UIViewRepresentable {
let region: MKCoordinateRegion
func makeUIView(context: Context) -> MKMapView {
let map = MKMapView()
map.delegate = context.coordinator
return map
}
func updateUIView(_ map: MKMapView, context: Context) {
// ✅ Guard: only update if region actually changed
if map.region.center.latitude != region.center.latitude
|| map.region.center.longitude != region.center.longitude {
map.setRegion(region, animated: true)
}
}
static func dismantleUIView(_ map: MKMapView, coordinator: Coordinator) {
map.removeAnnotations(map.annotations)
}
}
State flows in two directions across the bridge:
SwiftUI → UIKit: Via updateUIView. SwiftUI state changes trigger this method.
UIKit → SwiftUI: Via the Coordinator, using @Binding on the parent struct.
struct SearchField: UIViewRepresentable {
@Binding var text: String
@Binding var isEditing: Bool
func makeUIView(context: Context) -> UISearchBar {
let bar = UISearchBar()
bar.delegate = context.coordinator
return bar
}
func updateUIView(_ bar: UISearchBar, context: Context) {
bar.text = text // SwiftUI → UIKit
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchField
init(_ parent: SearchField) { self.parent = parent }
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
parent.text = searchText // UIKit → SwiftUI
}
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
parent.isEditing = true // UIKit → SwiftUI
}
func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
parent.isEditing = false
}
}
}
SwiftUI owns the layout of representable views. Never modify center, bounds, frame, or transform on the wrapped UIView — this is undefined behavior per Apple documentation. SwiftUI sets these properties during its layout pass. If you need custom sizing, override intrinsicContentSize on the UIView or use sizeThatFits(_:).
The Coordinator is a reference type (class) that:
UIViewRepresentable struct@Binding propertiesmakeCoordinator() is optional — omit it when the UIKit view needs no delegate callbacks or UIKit→SwiftUI communication (e.g., a static display-only view).
Why not closures? Closures capture self and create retain cycles. The Coordinator pattern gives you a stable reference type that SwiftUI manages.
// ❌ Closure-based: retain cycle risk, no delegate protocol support
func makeUIView(context: Context) -> UITextField {
let field = UITextField()
field.addTarget(self, action: #selector(textChanged), for: .editingChanged) // Won't compile — self is a struct
return field
}
// ✅ Coordinator: clean lifecycle, delegate support
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, UITextFieldDelegate {
var parent: SearchField
init(_ parent: SearchField) { self.parent = parent }
func textFieldDidChangeSelection(_ textField: UITextField) {
parent.text = textField.text ?? ""
}
}
UIViewRepresentable views participate in SwiftUI layout. Control sizing with:
// If the UIView has intrinsicContentSize, SwiftUI respects it
// For views without intrinsic size (MKMapView, WKWebView), set a frame:
MapView(region: region)
.frame(height: 300)
// For views that should size to fit their content:
WrappedLabel(text: "Hello")
.fixedSize() // Uses intrinsicContentSize
Override sizeThatFits(_:) for custom size proposals:
struct WrappedLabel: UIViewRepresentable {
let text: String
func makeUIView(context: Context) -> UILabel {
let label = UILabel()
label.numberOfLines = 0
return label
}
func updateUIView(_ label: UILabel, context: Context) {
label.text = text
}
// Custom size proposal — SwiftUI calls this during layout
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UILabel, context: Context) -> CGSize? {
let width = proposal.width ?? UIView.layoutFittingCompressedSize.width
return uiView.systemLayoutSizeFitting(
CGSize(width: width, height: UIView.layoutFittingCompressedSize.height),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
}
}
When wrapping a UIScrollView subclass, tell the navigation bar which scroll view to track for large title collapse:
func makeUIView(context: Context) -> UITableView {
let table = UITableView()
return table
}
func updateUIView(_ table: UITableView, context: Context) {
// Tell the nearest navigation controller to track this scroll view
// for inline/large title transitions
if let navController = sequence(first: table as UIResponder, next: \.next)
.compactMap({ $0 as? UINavigationController }).first {
navController.navigationBar.setContentScrollView(table, forEdge: .top)
}
}
Without this, navigation bar large titles won't collapse when scrolling a wrapped UIScrollView.
Use context.transaction.animation to bridge SwiftUI animations into UIKit:
func updateUIView(_ uiView: UIView, context: Context) {
if context.transaction.animation != nil {
UIView.animate(withDuration: 0.3) {
uiView.alpha = isVisible ? 1 : 0
}
} else {
uiView.alpha = isVisible ? 1 : 0
}
}
iOS 18+ animation unification: SwiftUI animations can be applied directly to UIKit views via UIView.animate(_:). However, be aware of incompatibilities:
UIViewPropertyAnimator and UIView keyframe animationsFor comprehensive animation bridging patterns, see $axiom-swiftui-animation-ref Part 10.
Use when wrapping a full UIViewController — pickers, mail compose, Safari, camera, or any controller that manages its own view hierarchy.
makeUIViewController(context:) → Called ONCE. Create and configure.
updateUIViewController(_:context:) → Called on SwiftUI state changes.
dismantleUIViewController(_:coordinator:) → Cleanup.
struct PhotoPicker: UIViewControllerRepresentable {
@Binding var selectedImages: [UIImage]
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.selectionLimit = 5
config.filter = .images
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ picker: PHPickerViewController, context: Context) {
// PHPicker doesn't support updates after creation
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
class Coordinator: NSObject, PHPickerViewControllerDelegate {
var parent: PhotoPicker
init(_ parent: PhotoPicker) { self.parent = parent }
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
parent.selectedImages = []
for result in results {
result.itemProvider.loadObject(ofClass: UIImage.self) { image, _ in
if let image = image as? UIImage {
DispatchQueue.main.async {
self.parent.selectedImages.append(image)
}
}
}
}
parent.dismiss()
}
}
}
Some controllers (UIImagePickerController, MFMailComposeViewController, SFSafariViewController) present their own full-screen UI. Handle dismissal through the coordinator:
class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
var parent: MailComposer
func mailComposeController(_ controller: MFMailComposeViewController,
didFinishWith result: MFMailComposeResult, error: Error?) {
parent.dismiss() // Let SwiftUI handle the dismissal
}
}
Don't call controller.dismiss(animated:) directly from the coordinator — let SwiftUI's @Environment(\.dismiss) or the binding that controls presentation handle it.
The wrapped controller doesn't automatically inherit SwiftUI's navigation context. If you need the controller to push onto a navigation stack, you need UIViewControllerRepresentable inside a NavigationStack, and the controller needs access to the navigation controller:
// ❌ This won't push — the controller has no navigationController
struct WrappedVC: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> MyViewController {
let vc = MyViewController()
vc.navigationController?.pushViewController(otherVC, animated: true) // nil
return vc
}
}
// ✅ Present modally instead, or use UIHostingController in a UIKit navigation flow
.sheet(isPresented: $showPicker) {
PhotoPicker(selectedImages: $images)
}
Use when you need a UIKit gesture recognizer in SwiftUI — for gestures that SwiftUI's native gesture API doesn't support (custom subclasses, precise UIKit gesture state machine, hit testing control).
Pre-iOS 18 fallback: Attach the gesture recognizer to a transparent UIView wrapped with UIViewRepresentable, using the Coordinator as the target/action receiver (see Part 1 Coordinator Pattern). You lose CoordinateSpaceConverter but can use the recognizer's location(in:) directly.
makeUIGestureRecognizer(context:) → Called ONCE. Create the recognizer.
handleUIGestureRecognizerAction(_:context:) → Called when the gesture is recognized.
updateUIGestureRecognizer(_:context:) → Called on SwiftUI state changes.
makeCoordinator(converter:) → Optional. Create coordinator for state.
No manual target/action — the system manages action target installation. Implement handleUIGestureRecognizerAction instead.
struct LongPressGesture: UIGestureRecognizerRepresentable {
@Binding var pressLocation: CGPoint?
func makeUIGestureRecognizer(context: Context) -> UILongPressGestureRecognizer {
let recognizer = UILongPressGestureRecognizer()
recognizer.minimumPressDuration = 0.5
return recognizer
}
func handleUIGestureRecognizerAction(
_ recognizer: UILongPressGestureRecognizer, context: Context
) {
switch recognizer.state {
case .began:
// localLocation converts UIKit coordinates to SwiftUI coordinate space
pressLocation = context.converter.localLocation
case .ended, .cancelled:
pressLocation = nil
default:
break
}
}
}
// Usage
struct ContentView: View {
@State private var pressLocation: CGPoint?
var body: some View {
Rectangle()
.gesture(LongPressGesture(pressLocation: $pressLocation))
}
}
The context.converter bridges UIKit gesture coordinates into SwiftUI coordinate spaces:
| Property/Method | Description |
|-----------------|-------------|
| localLocation | Gesture position in the attached SwiftUI view's space |
| localTranslation | Gesture movement in local space |
| localVelocity | Gesture velocity in local space |
| location(in:) | Transform location to an ancestor coordinate space |
| translation(in:) | Transform translation to an ancestor space |
| velocity(in:) | Transform velocity to an ancestor space |
| Need | Use |
|------|-----|
| Standard tap, drag, long press, rotation, magnification | SwiftUI native gestures |
| Custom UIGestureRecognizer subclass | UIGestureRecognizerRepresentable |
| Precise control over gesture state machine (.possible, .began, .changed, etc.) | UIGestureRecognizerRepresentable |
| Gesture that requires delegate methods for failure requirements or simultaneous recognition | UIGestureRecognizerRepresentable with a Coordinator |
| Coordinate space conversion between UIKit and SwiftUI | UIGestureRecognizerRepresentable (converter is built-in) |
Use when embedding SwiftUI views in an existing UIKit navigation hierarchy.
// Push onto UIKit navigation stack
let profileView = ProfileView(user: user)
let hostingController = UIHostingController(rootView: profileView)
navigationController?.pushViewController(hostingController, animated: true)
// Present modally
let settingsView = SettingsView()
let hostingController = UIHostingController(rootView: settingsView)
hostingController.modalPresentationStyle = .pageSheet
present(hostingController, animated: true)
When embedding as a child VC (e.g., a SwiftUI card inside a UIKit layout):
let swiftUIView = StatusCard(status: currentStatus)
let hostingController = UIHostingController(rootView: swiftUIView)
hostingController.sizingOptions = .intrinsicContentSize // iOS 16+
addChild(hostingController)
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: headerView.bottomAnchor)
])
hostingController.didMove(toParent: self)
sizingOptions: .intrinsicContentSize (iOS 16+) makes the hosting controller report its SwiftUI content size to Auto Layout. Without this, the hosting controller's view has no intrinsic size and relies entirely on constraints.
sizingOptions cases (iOS 16+, OptionSet):
.intrinsicContentSize — auto-invalidates intrinsic content size when SwiftUI content changes.preferredContentSize — tracks content's ideal size in the controller's preferredContentSizeUse sizeThatFits(in:) to calculate the SwiftUI content's preferred size for Auto Layout integration:
let hostingController = UIHostingController(rootView: CompactCard(item: item))
// Query preferred size for a given width constraint
let fittingSize = hostingController.sizeThatFits(in: CGSize(width: 320, height: .infinity))
// Returns the optimal CGSize for the SwiftUI content
This is useful when you need the hosting controller's size before adding it to the view hierarchy, or when embedding in contexts where sizingOptions alone isn't sufficient (e.g., manually sizing popover content).
Standard system environment values (colorScheme, sizeCategory, locale) bridge automatically through the UIKit trait system. Custom @Environment keys from a parent SwiftUI view do NOT — unless you use UITraitBridgedEnvironmentKey.
Option 1: Inject explicitly (simplest, works on all versions):
let view = DetailView(store: appStore, theme: currentTheme)
let hostingController = UIHostingController(rootView: view)
Option 2: UITraitBridgedEnvironmentKey (iOS 17+, bidirectional bridging):
Bridge custom environment values between UIKit traits and SwiftUI environment:
// 1. Define a UIKit trait
struct FeatureOneTrait: UITraitDefinition {
static let defaultValue = false
}
extension UIMutableTraits {
var featureOne: Bool {
get { self[FeatureOneTrait.self] }
set { self[FeatureOneTrait.self] = newValue }
}
}
// 2. Define a SwiftUI EnvironmentKey
struct FeatureOneKey: EnvironmentKey {
static let defaultValue = false
}
extension EnvironmentValues {
var featureOne: Bool {
get { self[FeatureOneKey.self] }
set { self[FeatureOneKey.self] = newValue }
}
}
// 3. Bridge them
extension FeatureOneKey: UITraitBridgedEnvironmentKey {
static func read(from traitCollection: UITraitCollection) -> Bool {
traitCollection[FeatureOneTrait.self]
}
static func write(to mutableTraits: inout UIMutableTraits, value: Bool) {
mutableTraits.featureOne = value
}
}
Now @Environment(\.featureOne) automatically syncs in both directions — UIKit traitOverrides update SwiftUI views, and SwiftUI .environment(\.featureOne, true) updates UIKit views.
To push values from UIKit into hosted SwiftUI content:
// In any UIKit view controller — flows down to UIHostingController children
viewController.traitOverrides.featureOne = true
Use SwiftUI views as UICollectionView or UITableView cells:
cell.contentConfiguration = UIHostingConfiguration {
HStack {
Image(systemName: item.icon)
.foregroundStyle(.tint)
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
.margins(.all, EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.minSize(width: nil, height: 44) // Minimum tap target height
.background(.quaternarySystemFill) // ShapeStyle background
Cell clipping? UIHostingConfiguration cells self-size. If cells are clipped, the collection view layout likely uses fixed itemSize — switch to estimated dimensions in your compositional layout so cells can grow to fit the SwiftUI content.
| Scenario | Use | |----------|-----| | Cell content in UICollectionView/UITableView | UIHostingConfiguration | | Full screen or navigation destination | UIHostingController | | Child VC in a layout | UIHostingController | | Overlay or decoration | UIHostingConfiguration in a supplementary view |
When a UIHostingController contains a scroll view and is pushed onto a UINavigationController, large title collapse may not work. Use setContentScrollView:
let hostingController = UIHostingController(rootView: ScrollableListView())
// After pushing, tell the nav bar to track the scroll view
if let scrollView = hostingController.view.subviews.compactMap({ $0 as? UIScrollView }).first {
navigationController?.navigationBar.setContentScrollView(scrollView, forEdge: .top)
}
This is a common issue when embedding SwiftUI List or ScrollView in UIKit navigation.
When mixing UIKit and SwiftUI, keyboard avoidance may not work automatically. Use UIKeyboardLayoutGuide (iOS 15+) for constraint-based keyboard tracking in UIKit layouts that contain SwiftUI content:
// Constrain the hosting controller's view above the keyboard
hostingController.view.bottomAnchor.constraint(
equalTo: view.keyboardLayoutGuide.topAnchor
).isActive = true
When UIKit and SwiftUI coexist in the same app, you need a shared model layer. @Observable (iOS 17+) works naturally in both frameworks without Combine.
@Observable
class AppState {
var userName: String = ""
var isLoggedIn: Bool = false
var itemCount: Int = 0
}
SwiftUI side — standard property wrappers:
struct ProfileView: View {
@State var appState: AppState // or @Environment, @Bindable
var body: some View {
Text("Welcome, \(appState.userName)")
Text("\(appState.itemCount) items")
}
}
Why UIKit needs explicit observation: SwiftUI's rendering engine automatically participates in the Observation framework — when a view's body accesses an @Observable property, SwiftUI registers that access and re-renders when it changes. UIKit is imperative and has no equivalent re-evaluation mechanism, so you must opt in explicitly.
UIKit side (pre-iOS 26) — manual observation with withObservationTracking():
class DashboardViewController: UIViewController {
let appState: AppState
override func viewDidLoad() {
super.viewDidLoad()
observeState()
}
private func observeState() {
withObservationTracking {
// Properties accessed here are tracked
titleLabel
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
npx skills add megastep/axiom-uikit-bridging下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer