axiom-uikit-bridging

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

UIKit-SwiftUI Bridging

UIKit与SwiftUI桥接

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.
这是一份关于UIKit与SwiftUI桥接的系统性指南。大多数生产环境中的iOS应用都会同时用到这两种框架——本技能专注于讲解桥接模式本身,而非特定领域的视图桥接。

Decision Framework

决策框架

dot
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:
  • Wrapping a
    UIView
    UIViewRepresentable
    (Part 1)
  • Wrapping a
    UIViewController
    UIViewControllerRepresentable
    (Part 2)
  • Wrapping a
    UIGestureRecognizer
    subclass →
    UIGestureRecognizerRepresentable
    (Part 2b, iOS 18+)
  • Embedding SwiftUI in UIKit navigation →
    UIHostingController
    (Part 3)
  • SwiftUI in UICollectionView/UITableView cells →
    UIHostingConfiguration
    (Part 3)
  • Sharing state between UIKit and SwiftUI →
    @Observable
    shared model (Part 4)

dot
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];
}
快速规则:
  • 封装
    UIView
    → 使用
    UIViewRepresentable
    (第1部分)
  • 封装
    UIViewController
    → 使用
    UIViewControllerRepresentable
    (第2部分)
  • 封装
    UIGestureRecognizer
    子类 → 使用
    UIGestureRecognizerRepresentable
    (第2b部分,iOS 18+)
  • 在UIKit导航中嵌入SwiftUI → 使用
    UIHostingController
    (第3部分)
  • 在UICollectionView/UITableView单元格中使用SwiftUI → 使用
    UIHostingConfiguration
    (第3部分)
  • 在UIKit与SwiftUI之间共享状态 → 使用
    @Observable
    共享模型(第4部分)

Part 1: UIViewRepresentable — Wrapping UIViews

第1部分:UIViewRepresentable — 封装UIViews

Use when you have a
UIView
subclass (MKMapView, WKWebView, custom drawing views) and need it in SwiftUI.
For comprehensive MapKit patterns and the SwiftUI Map vs MKMapView decision, see
axiom-mapkit
.
当你拥有
UIView
子类(如MKMapView、WKWebView、自定义绘图视图)并需要在SwiftUI中使用时,可采用此方案。
如需了解完整的MapKit模式,以及SwiftUI Map与MKMapView的选择决策,请查看
axiom-mapkit

Lifecycle

生命周期

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:
swift
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)
    }
}
makeUIView(context:)         → 仅调用一次。用于创建和配置视图。
updateUIView(_:context:)     → 每次SwiftUI状态变更时调用。仅做增量更新,不要重新创建视图。
dismantleUIView(_:coordinator:) → 当视图从层级中移除时调用。用于清理观察者/计时器。
关键注意点
updateUIView
会被频繁调用,需避免不必要的操作:
swift
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) {
        // ✅ 仅在区域实际变化时才更新
        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 Synchronization

状态同步

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.
swift
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 → UIKit:通过
updateUIView
方法。SwiftUI状态变更会触发该方法。
UIKit → SwiftUI:通过Coordinator(协调器),使用父结构体的
@Binding
属性。
swift
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
        }
    }
}

Layout Property Warning

布局属性警告

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(_:)
.
SwiftUI拥有可表示视图的布局控制权。绝对不要修改封装后UIView的
center
bounds
frame
transform
属性
——根据Apple文档,这属于未定义行为。SwiftUI会在布局过程中设置这些属性。如果需要自定义尺寸,请在UIView上重写
intrinsicContentSize
,或使用
sizeThatFits(_:)
方法。

Coordinator Pattern

协调器模式

The Coordinator is a reference type (
class
) that:
  1. Acts as the delegate/data source for the UIKit view
  2. Holds a reference to the parent
    UIViewRepresentable
    struct
  3. Bridges UIKit callbacks back to SwiftUI
    @Binding
    properties
makeCoordinator()
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.
swift
// ❌ 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 ?? ""
    }
}
Coordinator(协调器)是引用类型(
class
),主要作用如下:
  1. 作为UIKit视图的代理/数据源
  2. 持有父
    UIViewRepresentable
    结构体的引用
  3. 将UIKit回调桥接到SwiftUI的
    @Binding
    属性
makeCoordinator()
可选的——当UIKit视图不需要代理回调或UIKit→SwiftUI通信时(例如静态展示视图),可以省略该方法。
为什么不使用闭包? 闭包会捕获
self
并导致循环引用。协调器模式提供了一个由SwiftUI管理的稳定引用类型。
swift
// ❌ 闭包方式:存在循环引用风险,不支持代理协议
func makeUIView(context: Context) -> UITextField {
    let field = UITextField()
    field.addTarget(self, action: #selector(textChanged), for: .editingChanged) // 编译失败——self是结构体
    return field
}

// ✅ 协调器方式:生命周期清晰,支持代理
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 ?? ""
    }
}

Sizing

尺寸控制

UIViewRepresentable views participate in SwiftUI layout. Control sizing with:
swift
// 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:
swift
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
        )
    }
}
UIViewRepresentable视图会参与SwiftUI的布局流程。可通过以下方式控制尺寸:
swift
// 如果UIView有intrinsicContentSize,SwiftUI会自动遵循该尺寸
// 对于没有固有尺寸的视图(如MKMapView、WKWebView),设置frame:
MapView(region: region)
    .frame(height: 300)

// 对于需要自适应内容尺寸的视图:
WrappedLabel(text: "Hello")
    .fixedSize()  // 使用intrinsicContentSize
重写
sizeThatFits(_:)
以自定义尺寸适配:
swift
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
    }

    // 自定义尺寸适配——SwiftUI会在布局时调用此方法
    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
        )
    }
}

Scroll-Tracking Navigation Bars (iOS 15+)

导航栏滚动跟踪(iOS 15+)

When wrapping a UIScrollView subclass, tell the navigation bar which scroll view to track for large title collapse:
swift
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.
当封装UIScrollView子类时,需告知导航栏跟踪哪个滚动视图以实现大标题折叠效果:
swift
func makeUIView(context: Context) -> UITableView {
    let table = UITableView()
    return table
}

func updateUIView(_ table: UITableView, context: Context) {
    // 告知最近的导航控制器跟踪此滚动视图
    // 以实现内嵌/大标题的过渡效果
    if let navController = sequence(first: table as UIResponder, next: \.next)
        .compactMap({ $0 as? UINavigationController }).first {
        navController.navigationBar.setContentScrollView(table, forEdge: .top)
    }
}
如果不设置此属性,当滚动封装的UIScrollView时,导航栏的大标题将不会折叠。

Animation Bridging

动画桥接

Use
context.transaction.animation
to bridge SwiftUI animations into UIKit:
swift
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:
  • SwiftUI animations are NOT backed by CAAnimation — they use a different rendering path
  • Incompatible with
    UIViewPropertyAnimator
    and
    UIView
    keyframe animations
  • Velocity retargeting: Re-targeted SwiftUI animations carry forward velocity from interrupted animations, creating fluid transitions
For comprehensive animation bridging patterns, see
/skill axiom-swiftui-animation-ref
Part 10.

使用
context.transaction.animation
将SwiftUI动画桥接到UIKit:
swift
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+ 动画统一:SwiftUI动画可通过
UIView.animate(_:)
直接应用于UIKit视图。但需注意兼容性问题:
  • SwiftUI动画并非基于CAAnimation——它们使用不同的渲染路径
  • 不兼容
    UIViewPropertyAnimator
    UIView
    关键帧动画
  • 速度重定向:重定向后的SwiftUI动画会继承被中断动画的速度,实现流畅过渡
如需了解完整的动画桥接模式,请查看
/skill axiom-swiftui-animation-ref
第10部分。

Part 2: UIViewControllerRepresentable — Wrapping UIViewControllers

第2部分:UIViewControllerRepresentable — 封装UIViewControllers

Use when wrapping a full
UIViewController
— pickers, mail compose, Safari, camera, or any controller that manages its own view hierarchy.
当需要封装完整的
UIViewController
时使用此方案——例如选择器、邮件撰写、Safari、相机,或任何管理自身视图层级的控制器。

Lifecycle

生命周期

makeUIViewController(context:)         → Called ONCE. Create and configure.
updateUIViewController(_:context:)     → Called on SwiftUI state changes.
dismantleUIViewController(_:coordinator:) → Cleanup.
makeUIViewController(context:)         → 仅调用一次。用于创建和配置控制器。
updateUIViewController(_:context:)     → SwiftUI状态变更时调用。
dismantleUIViewController(_:coordinator:) → 清理资源。

Canonical Example: PHPickerViewController

典型示例:PHPickerViewController

swift
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()
        }
    }
}
swift
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创建后不支持更新
    }

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

When the Controller Presents Its Own UI

当控制器自行展示UI时

Some controllers (UIImagePickerController, MFMailComposeViewController, SFSafariViewController) present their own full-screen UI. Handle dismissal through the coordinator:
swift
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.
部分控制器(如UIImagePickerController、MFMailComposeViewController、SFSafariViewController)会自行展示全屏UI。需通过协调器处理关闭逻辑:
swift
class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
    var parent: MailComposer

    func mailComposeController(_ controller: MFMailComposeViewController,
                                didFinishWith result: MFMailComposeResult, error: Error?) {
        parent.dismiss()  // 让SwiftUI处理关闭逻辑
    }
}
不要直接在协调器中调用
controller.dismiss(animated:)
——让SwiftUI的
@Environment(\.dismiss)
或控制展示状态的
@Binding
来处理。

Presentation Context

展示上下文

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:
swift
// ❌ 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)
}

封装后的控制器不会自动继承SwiftUI的导航上下文。如果需要将控制器推入导航栈,需将UIViewControllerRepresentable放在NavigationStack中,且控制器需能访问导航控制器:
swift
// ❌ 无法推入——控制器没有navigationController
struct WrappedVC: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> MyViewController {
        let vc = MyViewController()
        vc.navigationController?.pushViewController(otherVC, animated: true) // nil
        return vc
    }
}

// ✅ 改为模态展示,或在UIKit导航流程中使用UIHostingController
.sheet(isPresented: $showPicker) {
    PhotoPicker(selectedImages: $images)
}

Part 2b: UIGestureRecognizerRepresentable (iOS 18+)

第2b部分:UIGestureRecognizerRepresentable(iOS 18+)

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.
当需要在SwiftUI中使用UIKit手势识别器时使用此方案——适用于SwiftUI原生手势API不支持的场景(如自定义子类、精确的UIKit手势状态机、点击测试控制)。
iOS 18之前的兼容方案:将手势识别器附加到一个用
UIViewRepresentable
封装的透明
UIView
上,使用协调器作为目标/动作接收器(参见第1部分的协调器模式)。这种方式无法使用
CoordinateSpaceConverter
,但可以直接使用识别器的
location(in:)
方法。

Lifecycle

生命周期

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.
makeUIGestureRecognizer(context:)              → 仅调用一次。用于创建识别器。
handleUIGestureRecognizerAction(_:context:)    → 手势被识别时调用。
updateUIGestureRecognizer(_:context:)          → SwiftUI状态变更时调用。
makeCoordinator(converter:)                    → 可选。用于创建状态协调器。
无需手动设置目标/动作——系统会管理动作目标的安装。只需实现
handleUIGestureRecognizerAction
方法即可。

Canonical Example: Long Press with Location

典型示例:带位置信息的长按手势

swift
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))
    }
}
swift
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将UIKit坐标转换为SwiftUI坐标空间
            pressLocation = context.converter.localLocation
        case .ended, .cancelled:
            pressLocation = nil
        default:
            break
        }
    }
}

// 使用示例
struct ContentView: View {
    @State private var pressLocation: CGPoint?

    var body: some View {
        Rectangle()
            .gesture(LongPressGesture(pressLocation: $pressLocation))
    }
}

CoordinateSpaceConverter

CoordinateSpaceConverter

The
context.converter
bridges UIKit gesture coordinates into SwiftUI coordinate spaces:
Property/MethodDescription
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
context.converter
用于将UIKit手势坐标桥接到SwiftUI坐标空间:
属性/方法描述
localLocation
手势在附加的SwiftUI视图空间中的位置
localTranslation
手势在本地空间中的移动距离
localVelocity
手势在本地空间中的速度
location(in:)
将位置转换为祖先视图的坐标空间
translation(in:)
将移动距离转换为祖先视图的坐标空间
velocity(in:)
将速度转换为祖先视图的坐标空间

When to Use This vs SwiftUI Gestures

何时使用此方案 vs SwiftUI原生手势

NeedUse
Standard tap, drag, long press, rotation, magnificationSwiftUI 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)

需求方案选择
标准点击、拖拽、长按、旋转、缩放SwiftUI原生手势
自定义
UIGestureRecognizer
子类
UIGestureRecognizerRepresentable
对手势状态机(
.possible
.began
.changed
等)的精确控制
UIGestureRecognizerRepresentable
需要
delegate
方法处理手势失败条件或同时识别
带Coordinator的
UIGestureRecognizerRepresentable
UIKit与SwiftUI之间的坐标空间转换
UIGestureRecognizerRepresentable
(内置转换器)

Part 3: UIHostingController — SwiftUI Inside UIKit

第3部分:UIHostingController — 在UIKit中嵌入SwiftUI

Use when embedding SwiftUI views in an existing UIKit navigation hierarchy.
当需要在现有UIKit导航层级中嵌入SwiftUI视图时使用此方案。

Basic Embedding

基础嵌入方式

swift
// 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)
swift
// 推入UIKit导航栈
let profileView = ProfileView(user: user)
let hostingController = UIHostingController(rootView: profileView)
navigationController?.pushViewController(hostingController, animated: true)

// 模态展示
let settingsView = SettingsView()
let hostingController = UIHostingController(rootView: settingsView)
hostingController.modalPresentationStyle = .pageSheet
present(hostingController, animated: true)

Child View Controller Embedding

子视图控制器嵌入

When embedding as a child VC (e.g., a SwiftUI card inside a UIKit layout):
swift
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
    preferredContentSize
当作为子视图控制器嵌入时(例如在UIKit布局中嵌入SwiftUI卡片):
swift
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+)让宿主控制器向Auto Layout报告其SwiftUI内容的尺寸。如果不设置此属性,宿主控制器的视图将没有固有尺寸,完全依赖约束。
sizingOptions
可选值
(iOS 16+,
OptionSet
类型):
  • .intrinsicContentSize
    —— 当SwiftUI内容变化时自动失效固有内容尺寸
  • .preferredContentSize
    —— 在控制器的
    preferredContentSize
    中跟踪内容的理想尺寸

Explicit Size Queries

显式尺寸查询

Use
sizeThatFits(in:)
to calculate the SwiftUI content's preferred size for Auto Layout integration:
swift
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).
使用
sizeThatFits(in:)
计算SwiftUI内容的首选尺寸,以集成到Auto Layout中:
swift
let hostingController = UIHostingController(rootView: CompactCard(item: item))

// 查询给定宽度约束下的首选尺寸
let fittingSize = hostingController.sizeThatFits(in: CGSize(width: 320, height: .infinity))
// 返回SwiftUI内容的最优CGSize
当需要在将宿主控制器添加到视图层级之前获取其尺寸,或在
sizeThatFits
alone不足以满足的场景中嵌入时(例如手动调整弹出框内容尺寸),此方法非常有用。

Environment Bridging

环境桥接

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):
swift
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:
swift
// 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:
swift
// In any UIKit view controller — flows down to UIHostingController children
viewController.traitOverrides.featureOne = true
标准系统环境值(
colorScheme
sizeCategory
locale
)会通过UIKit trait系统自动桥接。但父SwiftUI视图的自定义
@Environment
键不会自动桥接——除非使用
UITraitBridgedEnvironmentKey
方案1:显式注入(最简单,支持所有版本):
swift
let view = DetailView(store: appStore, theme: currentTheme)
let hostingController = UIHostingController(rootView: view)
方案2:UITraitBridgedEnvironmentKey(iOS 17+,双向桥接):
将自定义环境值在UIKit trait与SwiftUI环境之间桥接:
swift
// 1. 定义UIKit trait
struct FeatureOneTrait: UITraitDefinition {
    static let defaultValue = false
}

extension UIMutableTraits {
    var featureOne: Bool {
        get { self[FeatureOneTrait.self] }
        set { self[FeatureOneTrait.self] = newValue }
    }
}

// 2. 定义SwiftUI EnvironmentKey
struct FeatureOneKey: EnvironmentKey {
    static let defaultValue = false
}

extension EnvironmentValues {
    var featureOne: Bool {
        get { self[FeatureOneKey.self] }
        set { self[FeatureOneKey.self] = newValue }
    }
}

// 3. 桥接两者
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
    }
}
现在
@Environment(\.featureOne)
会自动双向同步——UIKit的
traitOverrides
会更新SwiftUI视图,SwiftUI的
.environment(\.featureOne, true)
也会更新UIKit视图。
要将值从UIKit推送到宿主SwiftUI内容中:
swift
// 在任意UIKit视图控制器中——会传递给UIHostingController子视图
viewController.traitOverrides.featureOne = true

UIHostingConfiguration (iOS 16+)

UIHostingConfiguration(iOS 16+)

Use SwiftUI views as UICollectionView or UITableView cells:
swift
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.
将SwiftUI视图用作UICollectionView或UITableView的单元格:
swift
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)  // 最小点击目标高度
.background(.quaternarySystemFill)  // 背景样式
单元格被裁剪? UIHostingConfiguration单元格会自动适配尺寸。如果单元格被裁剪,可能是因为集合视图布局使用了固定的
itemSize
——在组合布局中切换为
estimated
尺寸,让单元格可以根据SwiftUI内容自动调整大小。

Advantages over full UIHostingController

相较于完整UIHostingController的优势

  • No child view controller management
  • Automatic cell sizing
  • Self-sizing invalidation on state change
  • Compatible with diffable data sources
  • 无需管理子视图控制器
  • 自动单元格尺寸适配
  • 状态变化时自动失效尺寸
  • 兼容可差分数据源

When to use UIHostingConfiguration vs UIHostingController

何时使用UIHostingConfiguration vs UIHostingController

ScenarioUse
Cell content in UICollectionView/UITableViewUIHostingConfiguration
Full screen or navigation destinationUIHostingController
Child VC in a layoutUIHostingController
Overlay or decorationUIHostingConfiguration in a supplementary view
场景方案选择
UICollectionView/UITableView中的单元格内容UIHostingConfiguration
全屏或导航目标页UIHostingController
布局中的子视图控制器UIHostingController
覆盖层或装饰视图补充视图中的UIHostingConfiguration

Scroll-Tracking for Navigation Bars

导航栏滚动跟踪

When a UIHostingController contains a scroll view and is pushed onto a UINavigationController, large title collapse may not work. Use
setContentScrollView
:
swift
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.
当UIHostingController包含滚动视图并被推入UINavigationController时,大标题折叠可能无法正常工作。需使用
setContentScrollView
swift
let hostingController = UIHostingController(rootView: ScrollableListView())

// 推入后,告知导航栏跟踪该滚动视图
if let scrollView = hostingController.view.subviews.compactMap({ $0 as? UIScrollView }).first {
    navigationController?.navigationBar.setContentScrollView(scrollView, forEdge: .top)
}
这是在UIKit导航中嵌入SwiftUI
List
ScrollView
时的常见问题。

Keyboard Handling in Hybrid Layouts

混合布局中的键盘处理

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:
swift
// Constrain the hosting controller's view above the keyboard
hostingController.view.bottomAnchor.constraint(
    equalTo: view.keyboardLayoutGuide.topAnchor
).isActive = true

当混合使用UIKit和SwiftUI时,键盘自动避让可能无法正常工作。在包含SwiftUI内容的UIKit布局中,使用
UIKeyboardLayoutGuide
(iOS 15+)实现基于约束的键盘跟踪:
swift
// 将宿主控制器的视图约束在键盘上方
hostingController.view.bottomAnchor.constraint(
    equalTo: view.keyboardLayoutGuide.topAnchor
).isActive = true

Part 4: Shared State with @Observable

第4部分:使用@Observable共享状态

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.
当UIKit和SwiftUI在同一应用中共存时,需要一个共享模型层。
@Observable
(iOS 17+)可在两个框架中自然工作,无需Combine。

@Observable as the Shared Model Layer

@Observable作为共享模型层

swift
@Observable
class AppState {
    var userName: String = ""
    var isLoggedIn: Bool = false
    var itemCount: Int = 0
}
SwiftUI side — standard property wrappers:
swift
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()
:
swift
class DashboardViewController: UIViewController {
    let appState: AppState

    override func viewDidLoad() {
        super.viewDidLoad()
        observeState()
    }

    private func observeState() {
        withObservationTracking {
            // Properties accessed here are tracked
            titleLabel.text = appState.userName
            countLabel.text = "\(appState.itemCount) items"
        } onChange: {
            // Fires ONCE on the thread that mutated the property — must re-register
            // Always dispatch to main: onChange can fire on ANY thread
            DispatchQueue.main.async { [weak self] in
                self?.observeState()
            }
        }
    }
}
UIKit side (iOS 26+) — automatic observation tracking:
UIKit automatically tracks
@Observable
property access in designated lifecycle methods. Properties read in these methods trigger automatic UI updates when they change:
MethodClassWhat it updates
updateProperties()
UIView, UIViewControllerContent and styling
layoutSubviews()
UIViewGeometry and positioning
viewWillLayoutSubviews()
UIViewControllerPre-layout
draw(_:)
UIViewCustom drawing
swift
class DashboardViewController: UIViewController {
    let appState: AppState

    // iOS 26+: Properties accessed here are auto-tracked
    override func updateProperties() {
        super.updateProperties()
        titleLabel.text = appState.userName
        countLabel.text = "\(appState.itemCount) items"
    }
}
Info.plist requirement: In iOS 18, add
UIObservationTrackingEnabled = true
to your Info.plist to enable automatic observation tracking. iOS 26+ enables it by default.
swift
@Observable
class AppState {
    var userName: String = ""
    var isLoggedIn: Bool = false
    var itemCount: Int = 0
}
SwiftUI端 —— 使用标准属性包装器:
swift
struct ProfileView: View {
    @State var appState: AppState  // 或@Environment、@Bindable

    var body: some View {
        Text("Welcome, \(appState.userName)")
        Text("\(appState.itemCount) items")
    }
}
为什么UIKit需要显式观察? SwiftUI的渲染引擎会自动参与Observation框架——当视图的
body
访问
@Observable
属性时,SwiftUI会注册该访问,并在属性变化时重新渲染视图。而UIKit是命令式的,没有类似的自动重评估机制,因此必须手动开启观察。
UIKit端(iOS 26之前) —— 使用
withObservationTracking()
手动观察:
swift
class DashboardViewController: UIViewController {
    let appState: AppState

    override func viewDidLoad() {
        super.viewDidLoad()
        observeState()
    }

    private func observeState() {
        withObservationTracking {
            // 此处访问的属性会被跟踪
            titleLabel.text = appState.userName
            countLabel.text = "\(appState.itemCount) items"
        } onChange: {
            // 属性变更时触发一次——必须重新注册
            // 始终调度到主线程:onChange可能在任意线程触发
            DispatchQueue.main.async { [weak self] in
                self?.observeState()
            }
        }
    }
}
UIKit端(iOS 26+) —— 自动观察跟踪:
UIKit会在指定的生命周期方法中自动跟踪
@Observable
属性的访问。在这些方法中读取的属性会在变化时自动触发UI更新:
方法更新内容
updateProperties()
UIView、UIViewController内容和样式
layoutSubviews()
UIView几何布局和位置
viewWillLayoutSubviews()
UIViewController布局前准备
draw(_:)
UIView自定义绘图
swift
class DashboardViewController: UIViewController {
    let appState: AppState

    // iOS 26+:此处访问的属性会被自动跟踪
    override func updateProperties() {
        super.updateProperties()
        titleLabel.text = appState.userName
        countLabel.text = "\(appState.itemCount) items"
    }
}
Info.plist要求:在iOS 18中,需在Info.plist中添加
UIObservationTrackingEnabled = true
以启用自动观察跟踪。iOS 26+默认启用该功能。

iOS 16 Fallback: ObservableObject + Combine

iOS 16兼容方案:ObservableObject + Combine

If targeting iOS 16 (before
@Observable
), use
ObservableObject
with
@Published
and observe via Combine on the UIKit side:
swift
class AppState: ObservableObject {
    @Published var userName: String = ""
    @Published var itemCount: Int = 0
}

// UIKit side — observe with Combine sink
class DashboardViewController: UIViewController {
    let appState: AppState
    private var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        appState.$userName
            .receive(on: DispatchQueue.main)
            .sink { [weak self] name in
                self?.titleLabel.text = name
            }
            .store(in: &cancellables)
    }
}
如果需要兼容iOS 16(
@Observable
之前的版本),可使用
ObservableObject
配合
@Published
,并在UIKit端通过Combine观察:
swift
class AppState: ObservableObject {
    @Published var userName: String = ""
    @Published var itemCount: Int = 0
}

// UIKit端——通过Combine sink观察
class DashboardViewController: UIViewController {
    let appState: AppState
    private var cancellables = Set<AnyCancellable>()

    override func viewDidLoad() {
        super.viewDidLoad()
        appState.$userName
            .receive(on: DispatchQueue.main)
            .sink { [weak self] name in
                self?.titleLabel.text = name
            }
            .store(in: &cancellables)
    }
}

Migration Note

迁移说明

@Observable
replaces
ObservableObject
+
@Published
without requiring Combine. For hybrid apps:
  • Replace
    ObservableObject
    classes with
    @Observable
  • Remove
    @Published
    property wrappers (observation is automatic)
  • SwiftUI views keep working —
    @State
    and
    @Environment
    support
    @Observable
    directly
  • UIKit views gain observation through
    withObservationTracking()
    (iOS 17+) or automatic tracking (iOS 26+)

@Observable
替代了
ObservableObject
+
@Published
,无需依赖Combine。对于混合应用:
  • ObservableObject
    类替换为
    @Observable
  • 移除
    @Published
    属性包装器(观察是自动的)
  • SwiftUI视图可直接使用——
    @State
    @Environment
    原生支持
    @Observable
  • UIKit视图可通过
    withObservationTracking()
    (iOS 17+)或自动跟踪(iOS 26+)实现观察

Part 5: Common Gotchas

第5部分:常见陷阱

GotchaSymptomFix
Coordinator retains parentMemory leak, views never deallocateCoordinator stores
var parent: X
(not
let
). SwiftUI updates the parent reference on each
updateUIView
call. Don't add extra strong references.
updateUIView called excessivelyUIKit view flickers, resets scroll position, drops user inputGuard with equality checks. Compare old vs new values before applying changes.
Environment doesn't cross bridgeCustom environment values are nil/defaultUse
UITraitBridgedEnvironmentKey
(iOS 17+) for bidirectional bridging, or inject dependencies through initializer. System traits (color scheme, size category) bridge automatically.
Large title won't collapseNavigation bar stays expanded when scrolling wrapped UIScrollViewCall
setContentScrollView(_:forEdge:)
on the navigation bar.
UIHostingController sizing wrongView is zero-sized or jumps after layoutUse
sizingOptions: .intrinsicContentSize
(iOS 16+). For earlier versions, call
hostingController.view.invalidateIntrinsicContentSize()
after root view changes.
Mixed navigation stacksUnpredictable back button behavior, lost stateDon't mix UINavigationController and NavigationStack in the same flow. Migrate entire navigation subtrees.
makeUIView called multiple timesView recreated unexpectedlyEnsure the
UIViewRepresentable
struct's identity is stable. Avoid putting it inside a conditional that changes identity.
Coordinator not receiving callbacksDelegate methods never fireSet
delegate = context.coordinator
in
makeUIView
, not
updateUIView
. Verify protocol conformance.
Layout properties modified on representable viewView jumps, disappears, or has inconsistent layoutNever modify
center
,
bounds
,
frame
, or
transform
on the wrapped UIView — SwiftUI owns these.
Keyboard hides content in hybrid layoutText field or content hidden behind keyboardUse
UIKeyboardLayoutGuide
(iOS 15+) constraints in UIKit, or ensure SwiftUI's keyboard avoidance isn't disabled.
@Observable not updating UIKit viewsUIKit views show stale data after model changesUse
withObservationTracking()
(iOS 17+) or enable
UIObservationTrackingEnabled
in Info.plist (iOS 18). iOS 26+ auto-tracks in
updateProperties()
.

陷阱症状修复方案
Coordinator持有父结构体的强引用memory泄漏,视图永远不会被释放Coordinator存储
var parent: X
(而非
let
)。SwiftUI会在每次
updateUIView
调用时更新父引用。不要添加额外的强引用。
updateUIView被过度调用UIKit视图闪烁,滚动位置重置,丢失用户输入使用相等性检查做防护。在应用变更前比较新旧值。
环境无法跨桥接层传递自定义环境值为nil/默认值使用
UITraitBridgedEnvironmentKey
(iOS 17+)实现双向桥接,或通过初始化器显式注入依赖。系统trait(如配色方案、尺寸类别)会自动桥接。
大标题无法折叠滚动封装的UIScrollView时,导航栏保持展开状态调用导航栏的
setContentScrollView(_:forEdge:)
方法。
UIHostingController尺寸异常视图尺寸为0或布局后跳动使用
sizingOptions: .intrinsicContentSize
(iOS 16+)。对于更早版本,在根视图变化后调用
hostingController.view.invalidateIntrinsicContentSize()
混合导航栈返回按钮行为不可预测,状态丢失不要在同一流程中混合UINavigationController和NavigationStack。迁移整个导航子树。
makeUIView被多次调用视图被意外重建确保
UIViewRepresentable
结构体的标识稳定。避免将其放在会改变标识的条件语句中。
Coordinator未收到回调代理方法永远不会触发
makeUIView
中设置
delegate = context.coordinator
,而非
updateUIView
。验证协议一致性。
修改可表示视图的布局属性视图跳动、消失或布局不一致永远不要修改封装后UIView的
center
bounds
frame
transform
属性——这些由SwiftUI管理。
混合布局中键盘遮挡内容文本框或内容被键盘遮挡在UIKit中使用
UIKeyboardLayoutGuide
(iOS 15+)约束,或确保SwiftUI的键盘避让未被禁用。
@Observable未更新UIKit视图模型变化后UIKit视图显示陈旧数据使用
withObservationTracking()
(iOS 17+),或在Info.plist中启用
UIObservationTrackingEnabled
(iOS 18)。iOS 26+会在
updateProperties()
中自动跟踪。

Part 6: Anti-Patterns

第6部分:反模式

PatternProblemFix
"I'll use UIViewRepresentable for the whole screen"UIViewControllerRepresentable exists for controllers that manage their own view hierarchy, handle rotation, and participate in the responder chainUse UIViewControllerRepresentable for UIViewControllers. UIViewRepresentable is for bare UIViews.
"I don't need a coordinator, I'll use closures"Closures capture the struct value (not reference), become stale on updates, and can't conform to delegate protocolsUse the Coordinator. It's a stable reference type that SwiftUI keeps alive and updates.
"I'll rebuild the UIKit view every update"
makeUIView
runs once. Recreating the view in
updateUIView
causes flickering, lost state, and performance issues.
Create in
makeUIView
. Patch properties in
updateUIView
.
"SwiftUI environment will just work across the bridge"Custom
@Environment
values don't cross UIKit boundaries
Use
UITraitBridgedEnvironmentKey
(iOS 17+) for bridging, or inject explicitly through initializers. System trait-based values bridge automatically.
"I'll dismiss the UIKit controller directly"Calling
dismiss(animated:)
from coordinator bypasses SwiftUI's presentation state, leaving bindings out of sync
Use
@Environment(\.dismiss)
or the
@Binding var isPresented
to let SwiftUI handle dismissal.
"I'll skip dismantleUIView, it'll clean up automatically"Timers, observers, and KVO registrations on the UIView leakImplement
dismantleUIView
(static method) for any cleanup that
deinit
alone won't handle.

模式问题修复方案
"我要给整个屏幕用UIViewRepresentable"UIViewControllerRepresentable专为管理自身视图层级、处理旋转、参与响应链的控制器设计对UIViewControllers使用UIViewControllerRepresentable。UIViewRepresentable仅适用于独立UIView。
"我不需要Coordinator,用闭包就行"闭包捕获结构体值(而非引用),更新后会失效,且不支持代理协议使用Coordinator。它是由SwiftUI管理的稳定引用类型。
"我要在每次update时重建UIKit视图"
makeUIView
仅运行一次。在
updateUIView
中重建视图会导致闪烁、状态丢失和性能问题
makeUIView
中创建视图。在
updateUIView
中仅更新属性。
"SwiftUI环境会自动跨桥接层生效"自定义
@Environment
值无法跨UIKit边界传递
使用
UITraitBridgedEnvironmentKey
(iOS 17+)做桥接,或通过初始化器显式注入。基于系统trait的值会自动桥接。
"我要直接关闭UIKit控制器"在Coordinator中调用
dismiss(animated:)
会绕过SwiftUI的展示状态,导致绑定不同步
使用
@Environment(\.dismiss)
@Binding var isPresented
让SwiftUI处理关闭逻辑。
"我跳过dismantleUIView,系统会自动清理"UIView上的计时器、观察者和KVO注册会泄漏实现
dismantleUIView
(静态方法)来处理
deinit
无法完成的清理工作。

Resources

资源

WWDC: 2019-231, 2022-10072, 2023-10149, 2024-10118, 2024-10145, 2025-243, 2025-256
Docs: /swiftui/uiviewrepresentable, /swiftui/uiviewcontrollerrepresentable, /swiftui/uigesturerecognizerrepresentable, /uikit/uihostingcontroller, /uikit/uihostingconfiguration, /swiftui/uitraitbridgedenvironmentkey, /observation, /uikit/updating-views-automatically-with-observation-tracking
Skills: app-composition, swiftui-animation-ref, camera-capture, transferable-ref, swift-concurrency
WWDC:2019-231, 2022-10072, 2023-10149, 2024-10118, 2024-10145, 2025-243, 2025-256
文档:/swiftui/uiviewrepresentable, /swiftui/uiviewcontrollerrepresentable, /swiftui/uigesturerecognizerrepresentable, /uikit/uihostingcontroller, /uikit/uihostingconfiguration, /swiftui/uitraitbridgedenvironmentkey, /observation, /uikit/updating-views-automatically-with-observation-tracking
相关技能:app-composition, swiftui-animation-ref, camera-capture, transferable-ref, swift-concurrency