重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
axiom-uikit-bridging by charleswiltgen/axiom
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-uikit-bridgingUIKit 与 SwiftUI 的系统化桥接指南。大多数生产级 iOS 应用都需要两者结合——本技能教授的是桥接模式本身,而非被桥接的特定领域视图。
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 → (第 2 部分)广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
UIViewControllerRepresentableUIGestureRecognizer 子类 → UIGestureRecognizerRepresentable (第 2b 部分,iOS 18+)UIHostingController (第 3 部分)UIHostingConfiguration (第 3 部分)@Observable 共享模型 (第 4 部分)当你有一个 UIView 子类(MKMapView、WKWebView、自定义绘图视图)并且需要在 SwiftUI 中使用它时使用。
有关全面的 MapKit 模式以及 SwiftUI Map 与 MKMapView 的决策,请参阅
axiom-mapkit。
makeUIView(context:) → 调用一次。创建并配置视图。
updateUIView(_:context:) → 在每次 SwiftUI 状态变化时调用。进行修补,不要重新创建。
dismantleUIView(_:coordinator:) → 从视图层次结构中移除时调用。清理观察者/计时器。
关键:updateUIView 会被频繁调用。防止不必要的工作:
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)
}
}
状态在桥接的两个方向上流动:
SwiftUI → UIKit:通过 updateUIView。SwiftUI 状态变化触发此方法。
UIKit → SwiftUI:通过 Coordinator,使用父结构体上的 @Binding。
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 拥有 representable 视图的布局。切勿修改被包装 UIView 的 center、bounds、frame 或 transform —— 根据 Apple 文档,这是未定义行为。SwiftUI 在其布局过程中设置这些属性。如果你需要自定义尺寸,请在 UIView 上重写 intrinsicContentSize 或使用 sizeThatFits(_:)。
Coordinator 是一个引用类型(class),它:
UIViewRepresentable 结构体的引用@Binding 属性makeCoordinator() 是可选的 —— 当 UIKit 视图不需要委托回调或 UIKit→SwiftUI 通信时(例如,静态的仅显示视图),可以省略它。
为什么不使用闭包? 闭包会捕获 self 并创建循环引用。Coordinator 模式为你提供了一个 SwiftUI 管理的稳定引用类型。
// ❌ 基于闭包:有循环引用风险,不支持委托协议
func makeUIView(context: Context) -> UITextField {
let field = UITextField()
field.addTarget(self, action: #selector(textChanged), for: .editingChanged) // 无法编译 —— self 是结构体
return field
}
// ✅ Coordinator:清晰的生命周期,支持委托
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 视图参与 SwiftUI 布局。通过以下方式控制尺寸:
// 如果 UIView 有 intrinsicContentSize,SwiftUI 会尊重它
// 对于没有固有尺寸的视图(MKMapView、WKWebView),设置 frame:
MapView(region: region)
.frame(height: 300)
// 对于应调整尺寸以适应其内容的视图:
WrappedLabel(text: "Hello")
.fixedSize() // 使用 intrinsicContentSize
重写 sizeThatFits(_:) 以实现自定义尺寸提议:
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
)
}
}
当包装 UIScrollView 子类时,告诉导航栏跟踪哪个滚动视图以实现大标题折叠:
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 时,导航栏的大标题将不会折叠。
使用 context.transaction.animation 将 SwiftUI 动画桥接到 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+ 动画统一:SwiftUI 动画可以通过 UIView.animate(_:) 直接应用于 UIKit 视图。但是,请注意不兼容性:
UIViewPropertyAnimator 和 UIView 关键帧动画不兼容有关全面的动画桥接模式,请参阅 /skill axiom-swiftui-animation-ref 第 10 部分。
当包装一个完整的 UIViewController 时使用 —— 选择器、邮件撰写、Safari、相机或任何管理自己视图层次结构的控制器。
makeUIViewController(context:) → 调用一次。创建并配置。
updateUIViewController(_:context:) → 在 SwiftUI 状态变化时调用。
dismantleUIViewController(_:coordinator:) → 清理。
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()
}
}
}
某些控制器(UIImagePickerController、MFMailComposeViewController、SFSafariViewController)会呈现自己的全屏 UI。通过 Coordinator 处理关闭:
class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
var parent: MailComposer
func mailComposeController(_ controller: MFMailComposeViewController,
didFinishWith result: MFMailComposeResult, error: Error?) {
parent.dismiss() // 让 SwiftUI 处理关闭
}
}
不要直接从 Coordinator 调用 controller.dismiss(animated:) —— 让 SwiftUI 的 @Environment(\.dismiss) 或控制呈现的绑定来处理。
被包装的控制器不会自动继承 SwiftUI 的导航上下文。如果你需要控制器推送到导航栈上,你需要在 NavigationStack 中使用 UIViewControllerRepresentable,并且控制器需要访问导航控制器:
// ❌ 这不会推送 —— 控制器没有 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)
}
当你在 SwiftUI 中需要 UIKit 手势识别器时使用 —— 用于 SwiftUI 原生手势 API 不支持的手势(自定义子类、精确的 UIKit 手势状态机、命中测试控制)。
iOS 18 之前的回退方案:将手势识别器附加到使用 UIViewRepresentable 包装的透明 UIView 上,使用 Coordinator 作为目标/动作接收器(参见第 1 部分 Coordinator 模式)。你会失去 CoordinateSpaceConverter,但可以直接使用识别器的 location(in:)。
makeUIGestureRecognizer(context:) → 调用一次。创建识别器。
handleUIGestureRecognizerAction(_:context:) → 当手势被识别时调用。
updateUIGestureRecognizer(_:context:) → 在 SwiftUI 状态变化时调用。
makeCoordinator(converter:) → 可选。为状态创建 Coordinator。
无需手动目标/动作 —— 系统管理动作目标的安装。改为实现 handleUIGestureRecognizerAction。
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))
}
}
context.converter 将 UIKit 手势坐标桥接到 SwiftUI 坐标空间:
| 属性/方法 | 描述 |
|---|---|
localLocation | 手势在附加的 SwiftUI 视图空间中的位置 |
localTranslation | 手势在局部空间中的移动 |
localVelocity | 手势在局部空间中的速度 |
location(in:) | 将位置转换到祖先坐标空间 |
translation(in:) | 将移动转换到祖先空间 |
velocity(in:) | 将速度转换到祖先空间 |
| 需求 | 使用 |
|---|---|
| 标准点击、拖拽、长按、旋转、缩放 | SwiftUI 原生手势 |
自定义 UIGestureRecognizer 子类 | UIGestureRecognizerRepresentable |
精确控制手势状态机(.possible、.began、.changed 等) | UIGestureRecognizerRepresentable |
需要 delegate 方法来实现失败要求或同时识别的手势 | 带有 Coordinator 的 UIGestureRecognizerRepresentable |
| UIKit 和 SwiftUI 之间的坐标空间转换 | UIGestureRecognizerRepresentable(converter 是内置的) |
在现有 UIKit 导航层次结构中嵌入 SwiftUI 视图时使用。
// 推送到 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)
当作为子 VC 嵌入时(例如,在 UIKit 布局中的 SwiftUI 卡片):
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 中跟踪内容的理想尺寸使用 sizeThatFits(in:) 计算 SwiftUI 内容的首选尺寸,以便与 Auto Layout 集成:
let hostingController = UIHostingController(rootView: CompactCard(item: item))
// 查询给定宽度约束下的首选尺寸
let fittingSize = hostingController.sizeThatFits(in: CGSize(width: 320, height: .infinity))
// 返回 SwiftUI 内容的最佳 CGSize
这在需要将托管控制器添加到视图层次结构之前获取其尺寸,或者在 sizingOptions 单独不足的情况下嵌入时(例如,手动调整弹出框内容尺寸)非常有用。
标准的系统环境值(colorScheme、sizeCategory、locale)通过 UIKit 特性系统自动桥接。来自父 SwiftUI 视图的自定义 @Environment 键不会 —— 除非你使用 UITraitBridgedEnvironmentKey。
选项 1:显式注入(最简单,适用于所有版本):
let view = DetailView(store: appStore, theme: currentTheme)
let hostingController = UIHostingController(rootView: view)
选项 2:UITraitBridgedEnvironmentKey (iOS 17+, 双向桥接):
在 UIKit 特性和 SwiftUI 环境之间桥接自定义环境值:
// 1. 定义 UIKit 特性
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 内容推送值:
// 在任何 UIKit 视图控制器中 —— 向下流向 UIHostingController 子级
viewController.traitOverrides.featureOne = true
使用 SwiftUI 视图作为 UICollectionView 或 UITableView 单元格:
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) // ShapeStyle 背景
单元格被裁剪? UIHostingConfiguration 单元格会自定尺寸。如果单元格被裁剪,集合视图布局可能使用了固定的 itemSize —— 切换到组合布局中的 estimated 尺寸,以便单元格可以增长以适应 SwiftUI 内容。
| 场景 | 使用 |
|---|---|
| UICollectionView/UITableView 中的单元格内容 | UIHostingConfiguration |
| 全屏或导航目的地 | UIHostingController |
| 布局中的子 VC | UIHostingController |
| 覆盖层或装饰 | 补充视图中的 UIHostingConfiguration |
当 UIHostingController 包含滚动视图并被推送到 UINavigationController 上时,大标题折叠可能不起作用。使用 setContentScrollView:
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 时的常见问题。
当混合使用 UIKit 和 SwiftUI 时,键盘避让可能无法自动工作。在包含 SwiftUI 内容的 UIKit 布局中,使用 UIKeyboardLayoutGuide (iOS 15+) 进行基于约束的键盘跟踪:
// 将托管控制器的视图约束在键盘上方
hostingController.view.bottomAnchor.constraint(
equalTo: view.keyboardLayoutGuide.topAnchor
).isActive = true
当 UIKit 和 SwiftUI 共存于同一应用中时,你需要一个共享的模型层。@Observable (iOS 17+) 在两个框架中都能自然工作,无需 Combine。
@Observable
class AppState {
var userName: String = ""
var isLoggedIn: Bool = false
var itemCount: Int = 0
}
SwiftUI 端 —— 标准属性包装器:
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() 进行手动观察:
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 | 自定义绘图 |
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 中,将 UIObservationTrackingEnabled = true 添加到你的 Info.plist 以启用自动观察跟踪。iOS 26+ 默认启用。
如果目标版本是 iOS 16(在 @Observable 之前),使用带有 @Published 的 ObservableObject,并通过 Combine 在 UIKit 端观察:
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)
}
}
@Observable 取代了 ObservableObject + @Published,无需 Combine。对于混合应用:
ObservableObject 类替换为 @Observable@Published 属性包装器(观察是自动的)@State 和 @Environment 直接支持 @ObservablewithObservationTracking() (iOS 17+) 或自动跟踪 (iOS 26+) 获得观察能力| 陷阱 | 症状 | 修复方法 |
|---|---|---|
| Coordinator 持有父对象 | 内存泄漏,视图永远不会释放 | Coordinator 存储 var parent: X(不是 let)。SwiftUI 在每次 updateUIView 调用时更新父引用。不要添加额外的强引用。 |
| updateUIView 被过度调用 | UIKit 视图闪烁、重置滚动位置、丢弃用户输入 | 使用相等性检查进行防护。在应用更改之前比较新旧值。 |
| 环境无法跨桥接 | 自定义环境值为 nil/默认值 | 使用 UITraitBridgedEnvironmentKey (iOS 17+) 进行双向桥接,或通过初始化器注入依赖项。系统特性(颜色方案、尺寸类别)自动桥接。 |
| 大标题不会折叠 | 滚动包装的 UIScrollView 时导航栏保持展开状态 | 在导航栏上调用 setContentScrollView(_:forEdge:)。 |
| UIHostingController 尺寸错误 | 视图尺寸为零或在布局后跳动 | 使用 sizingOptions: .intrinsicContentSize (iOS 16+)。对于早期版本,在根视图更改后调用 hostingController.view.invalidateIntrinsicContentSize()。 |
| 混合导航栈 | 不可预测的后退按钮行为,状态丢失 | 不要在同一流程中混合使用 UINavigationController 和 NavigationStack。迁移整个导航子树。 |
| makeUIView 被多次调用 | 视图意外重新创建 | 确保 UIViewRepresentable 结构体的身份是稳定的。避免将其放在改变身份的条件语句中。 |
| Coordinator 未接收到回调 | 委托方法从未触发 | 在 makeUIView 中设置 delegate = context.coordinator,而不是 updateUIView。验证协议一致性。 |
| 修改 representable 视图上的布局属性 | 视图跳动、消失或布局不一致 | 切勿修改被包装 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() 中自动跟踪。 |
| 模式 | 问题 | 修复方法 |
|---|---|---|
| “我将使用 UIViewRepresentable 处理整个屏幕” | UIViewControllerRepresentable 用于管理自己视图层次结构、处理旋转并参与响应链的控制器 | 对于 UIViewControllers 使用 UIViewControllerRepresentable。UIViewRepresentable 用于裸 UIViews。 |
| “我不需要 Coordinator,我会使用闭包” | 闭包捕获结构体值(而非引用),在更新时变得陈旧,并且无法符合委托协议 | 使用 Coordinator。它是一个 SwiftUI 保持存活并更新的稳定引用类型。 |
| “我将在每次更新时重建 UIKit 视图” | makeUIView 运行一次。在 updateUIView 中重新创建视图会导致闪烁、状态丢失和性能问题。 | 在 makeUIView 中创建。在 updateUIView 中修补属性。 |
| “SwiftUI 环境将直接跨桥接工作” | 自定义 @Environment 值不会跨越 UIKit 边界 | 使用 UITraitBridgedEnvironmentKey (iOS 17+) 进行桥接,或通过初始化器显式注入。基于系统特性的值自动桥接。 |
| “我将直接关闭 UIKit 控制器” | 从 Coordinator 调用 dismiss(animated:) 会绕过 SwiftUI 的呈现状态,使绑定不同步 | 使用 @Environment(\.dismiss) 或 @Binding var isPresented 让 SwiftUI 处理关闭。 |
| “我将跳过 dismantleUIView,它会自动清理” | UIView 上的计时器、观察者和 KVO 注册会泄漏 | 为任何 deinit 无法处理的清理工作实现 dismantleUIView(静态方法)。 |
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
每周安装次数
32
代码仓库
GitHub 星标数
590
首次出现
14 天前
安全审计
安装于
codex31
opencode30
gemini-cli30
amp30
github-copilot30
kimi-cli30
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.
For comprehensive MapKit patterns and the SwiftUI Map vs MKMapView decision, see
axiom-mapkit.
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 modifycenter, 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 /skill 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:) |
| 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 |
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.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:
| Method | Class | What it updates |
|---|---|---|
updateProperties() | UIView, UIViewController | Content and styling |
layoutSubviews() | UIView | Geometry and positioning |
viewWillLayoutSubviews() | UIViewController | Pre-layout |
draw(_:) | UIView | Custom drawing |
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.
If targeting iOS 16 (before @Observable), use ObservableObject with @Published and observe via Combine on the UIKit side:
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)
}
}
@Observable replaces ObservableObject + @Published without requiring Combine. For hybrid apps:
ObservableObject classes with @Observable@Published property wrappers (observation is automatic)@State and @Environment support @Observable directlywithObservationTracking() (iOS 17+) or automatic tracking (iOS 26+)| Gotcha | Symptom | Fix |
|---|---|---|
| Coordinator retains parent | Memory leak, views never deallocate | Coordinator stores var parent: X (not let). SwiftUI updates the parent reference on each updateUIView call. Don't add extra strong references. |
| updateUIView called excessively | UIKit view flickers, resets scroll position, drops user input | Guard with equality checks. Compare old vs new values before applying changes. |
| Environment doesn't cross bridge | Custom environment values are nil/default | Use UITraitBridgedEnvironmentKey (iOS 17+) for bidirectional bridging, or inject dependencies through initializer. System traits (color scheme, size category) bridge automatically. |
| Large title won't collapse | Navigation bar stays expanded when scrolling wrapped UIScrollView |
| Pattern | Problem | Fix |
|---|---|---|
| "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 chain | Use 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 protocols | Use 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" |
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
Weekly Installs
32
Repository
GitHub Stars
590
First Seen
14 days ago
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex31
opencode30
gemini-cli30
amp30
github-copilot30
kimi-cli30
| Transform velocity to an ancestor space |
UIGestureRecognizerRepresentable with a Coordinator |
| Coordinate space conversion between UIKit and SwiftUI | UIGestureRecognizerRepresentable (converter is built-in) |
Call setContentScrollView(_:forEdge:) on the navigation bar. |
| UIHostingController sizing wrong | View is zero-sized or jumps after layout | Use sizingOptions: .intrinsicContentSize (iOS 16+). For earlier versions, call hostingController.view.invalidateIntrinsicContentSize() after root view changes. |
| Mixed navigation stacks | Unpredictable back button behavior, lost state | Don't mix UINavigationController and NavigationStack in the same flow. Migrate entire navigation subtrees. |
| makeUIView called multiple times | View recreated unexpectedly | Ensure the UIViewRepresentable struct's identity is stable. Avoid putting it inside a conditional that changes identity. |
| Coordinator not receiving callbacks | Delegate methods never fire | Set delegate = context.coordinator in makeUIView, not updateUIView. Verify protocol conformance. |
| Layout properties modified on representable view | View jumps, disappears, or has inconsistent layout | Never modify center, bounds, frame, or transform on the wrapped UIView — SwiftUI owns these. |
| Keyboard hides content in hybrid layout | Text field or content hidden behind keyboard | Use UIKeyboardLayoutGuide (iOS 15+) constraints in UIKit, or ensure SwiftUI's keyboard avoidance isn't disabled. |
| @Observable not updating UIKit views | UIKit views show stale data after model changes | Use withObservationTracking() (iOS 17+) or enable UIObservationTrackingEnabled in Info.plist (iOS 18). iOS 26+ auto-tracks in updateProperties(). |
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 leak | Implement dismantleUIView (static method) for any cleanup that deinit alone won't handle. |