axiom-uikit-bridging について
UIKit と SwiftUI を連携させる iOS 開発者向けのリファレンススキルです。UIKit のビュー、コントローラー、ジェスチャー認識器を SwiftUI で使う方法と、UIKit で SwiftUI を表示する方法を扱い、ライフサイクルや状態同期、レイアウトも解説します。
主な機能
- 01UIView と UIViewController のラップ
- 02UIKit ジェスチャー認識器の連携
- 03UIKit への SwiftUI の埋め込み
- 04UIKit と SwiftUI の状態同期
- 05ライフサイクルとレイアウトの処理
活用シーン
- 01MKMapView や WKWebView を SwiftUI で使う
- 02写真ピッカーなどのコントローラーを SwiftUI に接続
- 03UIKit ナビゲーションに SwiftUI ビューを配置
- 04UIKit セルに SwiftUI コンテンツを表示
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-uikit-bridgingインストール後、Claude Code で/axiom-uikit-bridgingを入力して読み込まれたか確認します。
詳細
UIKit-SwiftUI ブリッジ
UIKit と 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をラップする →UIViewControllerRepresentable(パート 2)UIGestureRecognizerのサブクラスをラップする →UIGestureRecognizerRepresentable(パート 2b、iOS 18 以降)- SwiftUI を UIKit のナビゲーションに埋め込む →
UIHostingController(パート 3) - UICollectionView/UITableView のセルで SwiftUI を使う →
UIHostingConfiguration(パート 3) - UIKit と SwiftUI 間で状態を共有する → 共有モデル
@Observable(パート 4)
パート 1:UIViewRepresentable — UIView のラップ
UIView のサブクラス(MKMapView、WKWebView、カスタム描画ビュー)があり、それを SwiftUI で使う必要がある場合に使用します。
MapKit の包括的なパターンと、SwiftUI Map と MKMapView の選択については、
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.
重要: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) {
// ✅ 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)
}
}
状態の同期
状態はブリッジを介して双方向に流れます。
SwiftUI → UIKit:updateUIView を介します。SwiftUI の状態が変わると、このメソッドが呼び出されます。
UIKit → SwiftUI:親構造体の @Binding を使い、Coordinator を介します。
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
}
}
}
レイアウトプロパティに関する警告
Representable ビューのレイアウトは SwiftUI が管理します。ラップした UIView の center、bounds、frame、transform は決して変更しないでください。Apple のドキュメントによると、これは未定義の動作です。SwiftUI はレイアウトパス中にこれらのプロパティを設定します。カスタムサイズが必要な場合は、UIView の intrinsicContentSize をオーバーライドするか、sizeThatFits(_:) を使用してください。
Coordinator パターン
Coordinator は参照型(class)で、次の役割を担います。
- UIKit ビューのデリゲート/データソースとして機能します
- 親の
UIViewRepresentable構造体への参照を保持します - UIKit のコールバックを SwiftUI の
@Bindingプロパティに橋渡しします
makeCoordinator() は省略可能です。UIKit ビューにデリゲートコールバックや UIKit から SwiftUI への通信が不要な場合(例:静的な表示専用ビュー)は、省略してください。
クロージャーを使わない理由:クロージャーは self をキャプチャして、循環参照を作成します。Coordinator パターンでは、SwiftUI が管理する安定した参照型を利用できます。
// ❌ 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 ビューは SwiftUI のレイアウトに参加します。次の方法でサイズを制御します。
// 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
カスタムサイズの提案には、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
}
// 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
)
}
}
スクロール追跡ナビゲーションバー(iOS 15 以降)
UIScrollView のサブクラスをラップする場合、大きなタイトルの折りたたみに使用するスクロールビューをナビゲーションバーに指定します。
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)
}
}
これを設定しないと、ラップした 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 以降のアニメーション統合:UIView.animate(_:) を介して、SwiftUI のアニメーションを UIKit ビューに直接適用できます。ただし、互換性に関する次の点に注意してください。
- SwiftUI のアニメーションは CAAnimation によって処理されるわけではありません。異なる描画経路を使用します
UIViewPropertyAnimatorおよびUIViewのキーフレームアニメーションとは互換性がありません- 速度の引き継ぎ:再ターゲットされた SwiftUI アニメーションは、中断されたアニメーションの速度を引き継ぎ、滑らかなトランジションを実現します
アニメーションのブリッジに関する包括的なパターンについては、/skill axiom-swiftui-animation-ref のパート 10 を参照してください。
パート 2:UIViewControllerRepresentable — UIViewController のラップ
ピッカー、メール作成、Safari、カメラなど、ビュー階層を独自に管理するコントローラーを含む、UIViewController 全体をラップする場合に使用します。
ライフサイクル
makeUIViewController(context:) → Called ONCE. Create and configure.
updateUIViewController(_:context:) → Called on SwiftUI state changes.
dismantleUIViewController(_:coordinator:) → Cleanup.
標準例:PHPickerViewController
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()
}
}
}
コントローラーが独自の UI を表示する場合
一部のコントローラー(UIImagePickerController、MFMailComposeViewController、SFSafariViewController)は、独自のフルスクリーン UI を表示します。終了処理はコーディネーターを通じて行います。
class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
var parent: MailComposer
func mailComposeController(_ controller: MFMailComposeViewController,
didFinishWith result: MFMailComposeResult, error: Error?) {
parent.dismiss() // Let SwiftUI handle the dismissal
}
}
コーディネーターから controller.dismiss(animated:) を直接呼び出さないでください。SwiftUI の @Environment(\.dismiss) または表示を制御するバインディングに任せます。
表示コンテキスト
ラップされたコントローラーは、SwiftUI のナビゲーションコンテキストを自動的には継承しません。コントローラーをナビゲーションスタックにプッシュする必要がある場合は、NavigationStack 内で UIViewControllerRepresentable を使い、コントローラーからナビゲーションコントローラーにアクセスできるようにする必要があります。
// ❌ 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)
}
パート 2b:UIGestureRecognizerRepresentable(iOS 18 以降)
SwiftUI に UIKit のジェスチャー認識器が必要な場合に使用します。SwiftUI のネイティブジェスチャー API がサポートしていないジェスチャー(カスタムサブクラス、UIKit のジェスチャー状態マシンの精密な制御、ヒットテストの制御など)に適しています。
iOS 18 より前の代替方法:UIViewRepresentable でラップした透明な UIView にジェスチャー認識器をアタッチし、Coordinator をターゲット/アクションの受信先として使用します(パート 1 の Coordinator パターンを参照)。CoordinateSpaceConverter は使えませんが、認識器の location(in:) を直接使用できます。
ライフサイクル
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.
ターゲット/アクションを手動で設定する必要はありません。システムがアクションターゲットの設定を管理します。代わりに 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 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))
}
}
CoordinateSpaceConverter
context.converter は、UIKit のジェスチャー座標を SwiftUI の座標空間に変換します。
| プロパティ/メソッド | 説明 |
|---|---|
localLocation | アタッチされた SwiftUI ビューの座標空間におけるジェスチャー位置 |
localTranslation | ローカル座標空間におけるジェスチャーの移動量 |
localVelocity | ローカル座標空間におけるジェスチャーの速度 |
location(in:) | 位置を祖先の座標空間に変換 |
translation(in:) | 移動量を祖先の座標空間に変換 |
velocity(in:) | 速度を祖先の座標空間に変換 |
SwiftUI のジェスチャーとどちらを使うべきか
| 用途 | 使用するもの |
|---|---|
| 標準的なタップ、ドラッグ、長押し、回転、拡大縮小 | SwiftUI のネイティブジェスチャー |
カスタムの UIGestureRecognizer サブクラス | UIGestureRecognizerRepresentable |
ジェスチャー状態マシン(.possible、.began、.changed など)の精密な制御 | UIGestureRecognizerRepresentable |
失敗要件や同時認識のために delegate メソッドが必要なジェスチャー | Coordinator を使用した UIGestureRecognizerRepresentable |
| UIKit と SwiftUI 間の座標空間変換 | UIGestureRecognizerRepresentable(コンバーターが組み込まれています) |
パート 3:UIHostingController — UIKit 内の SwiftUI
既存の UIKit ナビゲーション階層に SwiftUI ビューを埋め込む場合に使用します。
基本的な埋め込み
// 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)
子ビューコントローラーとしての埋め込み
子 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 以降)を設定すると、ホスティングコントローラーが SwiftUI コンテンツのサイズを Auto Layout に通知します。これを設定しない場合、ホスティングコントローラーのビューには固有サイズがなく、制約に完全に依存します。
sizingOptions のケース(iOS 16 以降、OptionSet):
.intrinsicContentSize— SwiftUI コンテンツが変更されると、固有サイズを自動的に無効化します.preferredContentSize— コンテンツの理想的なサイズをコントローラーのpreferredContentSizeで追跡します
明示的なサイズの取得
Auto Layout との統合用に、sizeThatFits(in:) を使って SwiftUI コンテンツの推奨サイズを計算します。
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
これは、ビュー階層に追加する前にホスティングコントローラーのサイズが必要な場合や、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. 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
}
}
これで @Environment(\.featureOne) は双方向に自動で同期されます。UIKit の traitOverrides を更新すると SwiftUI ビューが更新され、SwiftUI の .environment(\.featureOne, true) を更新すると UIKit ビューが更新されます。
UIKit からホストされた SwiftUI コンテンツに値を渡すには:
// In any UIKit view controller — flows down to UIHostingController children
viewController.traitOverrides.featureOne = true
UIHostingConfiguration(iOS 16 以降)
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) // Minimum tap target height
.background(.quaternarySystemFill) // ShapeStyle background
セルがクリッピングされる場合:UIHostingConfiguration のセルは自身でサイズを調整します。セルがクリッピングされる場合は、コレクションビューのレイアウトで固定の itemSize が使われている可能性があります。SwiftUI コンテンツに合わせてセルを拡大できるよう、コンポジショナルレイアウトで寸法を estimated に変更してください。
UIHostingController 全体を使う場合と比べた利点
- 子ビューコントローラーの管理が不要
- セルのサイズを自動調整
- 状態変更時にサイズの変更を自動的に無効化
- 差分可能なデータソースと互換性あり
UIHostingConfiguration と UIHostingController の使い分け
| シナリオ | 使用するもの |
|---|---|
| UICollectionView/UITableView のセルコンテンツ | UIHostingConfiguration |
| 全画面表示またはナビゲーション先 | UIHostingController |
| レイアウト内の子 VC | UIHostingController |
| オーバーレイまたは装飾 | 補助ビュー内の UIHostingConfiguration |
ナビゲーションバーのスクロール追跡
UIHostingController にスクロールビューが含まれていて、それを UINavigationController にプッシュした場合、大きなタイトルが折りたたまれないことがあります。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)
}
これは、UIKit のナビゲーションに SwiftUI の List や ScrollView を埋め込むときによく発生する問題です。
ハイブリッドレイアウトでのキーボード処理
UIKit と SwiftUI を組み合わせる場合、キーボード回避が自動的に機能しないことがあります。SwiftUI コンテンツを含む UIKit レイアウトで、制約ベースのキーボード追跡を行うには UIKeyboardLayoutGuide(iOS 15 以降)を使用してください。
// Constrain the hosting controller's view above the keyboard
hostingController.view.bottomAnchor.constraint(
equalTo: view.keyboardLayoutGuide.topAnchor
).isActive = true
パート 4:@Observable による共有状態
UIKit と SwiftUI が同じアプリ内に共存する場合、共有モデルレイヤーが必要です。@Observable(iOS 17 以降)は Combine を使わずに、どちらのフレームワークでも自然に機能します。
共有モデルレイヤーとしての @Observable
@Observable
class AppState {
var userName: String = ""
var isLoggedIn: Bool = false
var itemCount: Int = 0
}
SwiftUI 側 — 標準のプロパティラッパー:
struct ProfileView: View {
@State var appState: AppState // or @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 {
// 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 側(iOS 26 以降) — 自動監視追跡:
UIKit は、指定されたライフサイクルメソッド内での @Observable プロパティへのアクセスを自動的に追跡します。これらのメソッドで読み取られたプロパティが変更されると、UI が自動的に更新されます。
| メソッド | クラス | 更新内容 |
|---|---|---|
updateProperties() | UIView, UIViewController | コンテンツとスタイル |
layoutSubviews() | UIView | ジオメトリと配置 |
viewWillLayoutSubviews() | UIViewController | レイアウト前 |
draw(_:) | UIView | カスタム描画 |
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 の要件:iOS 18 では、自動監視追跡を有効にするため、Info.plist に UIObservationTrackingEnabled = true を追加してください。iOS 26 以降では、デフォルトで有効です。
iOS 16 での代替手段:ObservableObject + Combine
iOS 16(@Observable より前)を対象とする場合は、ObservableObject と @Published を使用し、UIKit 側では Combine を介して監視してください。
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 は Combine を必要とせず、ObservableObject と @Published に置き換わります。ハイブリッドアプリの場合:
ObservableObjectクラスを@Observableに置き換えます@Publishedプロパティラッパーを削除します(監視は自動です)- SwiftUI ビューは引き続き動作します —
@Stateと@Environmentは@Observableを直接サポートします - UIKit ビューでは、
withObservationTracking()(iOS 17+)または自動トラッキング(iOS 26+)によって監視が可能になります
パート 5:よくある落とし穴
| 落とし穴 | 症状 | 対処法 |
|---|---|---|
| Coordinator が親を保持する | メモリーリークが発生し、ビューが解放されない | Coordinator では let ではなく var parent: X を使用します。SwiftUI は updateUIView の呼び出しごとに親への参照を更新します。余分な強参照を追加しないでください。 |
| updateUIView が過剰に呼び出される | UIKit ビューがちらつく、スクロール位置がリセットされる、ユーザー入力が失われる | 等価性チェックでガードします。変更を適用する前に、古い値と新しい値を比較します。 |
| Environment がブリッジを越えて伝わらない | カスタム環境値が nil/デフォルト値になる | 双方向ブリッジには UITraitBridgedEnvironmentKey(iOS 17+)を使用するか、イニシャライザー経由で依存関係を注入します。システムの Trait(カラースキーム、サイズカテゴリ)は自動的にブリッジされます。 |
| 大きなタイトルが折りたたまれない | ラップされた UIScrollView をスクロールしてもナビゲーションバーが展開されたままになる | ナビゲーションバーで setContentScrollView(_:forEdge:) を呼び出します。 |
| UIHostingController のサイズが正しくない | ビューのサイズがゼロになる、またはレイアウト後に跳ねる | sizingOptions: .intrinsicContentSize(iOS 16+)を使用します。それ以前のバージョンでは、ルートビューの変更後に hostingController.view.invalidateIntrinsicContentSize() を呼び出します。 |
| ナビゲーションスタックが混在している | 戻るボタンの動作が予測不能になる、状態が失われる | 同じフローで UINavigationController と NavigationStack を混在させないでください。ナビゲーションのサブツリー全体を移行します。 |
| makeUIView が複数回呼び出される | ビューが予期せず再作成される | UIViewRepresentable 構造体の ID が安定していることを確認します。ID が変わる条件分岐の中に配置しないでください。 |
| Coordinator がコールバックを受け取らない | Delegate メソッドが呼び出されない | updateUIView ではなく makeUIView で delegate = context.coordinator を設定します。プロトコルへの準拠も確認してください。 |
| 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() 内で自動的にトラッキングされます。 |
パート 6:アンチパターン
| パターン | 問題 | 修正方法 |
|---|---|---|
| 「画面全体にUIViewRepresentableを使います」 | 独自のビュー階層を管理し、回転を処理し、レスポンダーチェーンに参加するコントローラー向けにUIViewControllerRepresentableが用意されています | UIViewControllerにはUIViewControllerRepresentableを使います。UIViewRepresentableは、単体のUIView向けです。 |
| 「コーディネーターは不要です。クロージャを使います」 | クロージャは構造体の値(参照ではありません)をキャプチャするため、更新後に古くなり、デリゲートプロトコルにも準拠できません | Coordinatorを使います。Coordinatorは、SwiftUIが保持して更新する安定した参照型です。 |
| 「更新のたびにUIKitビューを再構築します」 | makeUIViewは一度だけ実行されます。updateUIViewでビューを再作成すると、ちらつきや状態の消失、パフォーマンスの問題が発生します。 | makeUIViewで作成し、updateUIViewでプロパティを更新します。 |
| 「SwiftUIの環境値はブリッジを越えてそのまま使えます」 | カスタムの@Environment値はUIKitとの境界を越えません | ブリッジには(iOS 17以降)UITraitBridgedEnvironmentKeyを使うか、イニシャライザーを通じて明示的に注入します。システムのトレイトに基づく値は自動的にブリッジされます。 |
| 「UIKitコントローラーを直接閉じます」 | コーディネーターからdismiss(animated:)を呼び出すと、SwiftUIのプレゼンテーション状態を迂回するため、バインディングの状態が一致しなくなります | @Environment(\.dismiss)または@Binding var isPresentedを使い、SwiftUIに閉じる処理を任せます。 |
| 「dismantleUIViewは省略します。自動的にクリーンアップされます」 | UIView上のタイマー、オブザーバー、KVO登録がリークします | deinitだけでは処理できないクリーンアップには、dismantleUIView(staticメソッド)を実装します。 |
リソース
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