swiftui-uikit-interop について
SwiftUIとUIKitを双方向に連携させるiOS開発者向けガイドです。ライフサイクル、レイアウト、状態同期、移行パターンを扱い、連携時の注意点も説明します。
主な機能
- 01UIViewやコントローラーのSwiftUIへのラップ
- 02UIKitへのSwiftUIの埋め込み
- 03Coordinatorによるデリゲートイベントの受け渡し
- 04バインディングや環境値、状態の同期
- 05サイズ調整とライフサイクルの後処理
活用シーン
- 01SwiftUIでUIKitのシステムピッカーを使用
- 02既存のUIKit画面へのSwiftUI追加
- 03テキストビューなどの双方向状態同期
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dpearson2699/ swift-ios-skills --skill swiftui-uikit-interopインストール後、Claude Code で/swiftui-uikit-interopを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
evals/evals.jsonreferences/hosting-migration.mdrepresentable-recipes.md詳細
SwiftUI-UIKit の相互運用
UIKit と SwiftUI を双方向に連携します。UIKit のビューをラップし、SwiftUI を UIKit の画面に埋め込み、ライフサイクルの所有権を重複させずに状態を同期します。
完全なラップのレシピについてはreferences/representable-recipes.mdを、UIKit から SwiftUI への移行パターンについてはreferences/hosting-migration.mdを参照してください。
目次
- UIViewRepresentable プロトコル
- UIViewControllerRepresentable プロトコル
- コーディネーターパターン
- UIHostingController
- サイズとレイアウト
- 状態同期パターン
- UIKit の自動オブザベーション追跡
- Sendable に関する考慮事項
- よくある間違い
- レビュー用チェックリスト
- 参考資料
UIViewRepresentable プロトコル
UIViewRepresentableを使って、任意のUIViewサブクラスを SwiftUI で使えるようにラップします。
必須メソッド
struct WrappedTextView: UIViewRepresentable {
@Binding var text: String
func makeUIView(context: Context) -> UITextView {
// Called ONCE when SwiftUI inserts this view into the hierarchy.
// Create and return the UIKit view. One-time setup goes here.
let textView = UITextView()
textView.delegate = context.coordinator
textView.font = .preferredFont(forTextStyle: .body)
return textView
}
func updateUIView(_ uiView: UITextView, context: Context) {
// Called on EVERY SwiftUI state change that affects this view.
// Synchronize SwiftUI state into the UIKit view.
// Guard against redundant updates to avoid loops.
if uiView.text != text {
uiView.text = text
}
}
}
ライフサイクルのタイミング
| メソッド | 呼び出されるタイミング | 目的 |
|---|---|---|
makeCoordinator() | makeUIViewの前。Representable の存続期間中に一度。 | デリゲート/データソースの参照型を作成します。 |
makeUIView(context:) | Representable がビュー階層に追加されたときに一度。 | UIKit ビューを割り当てて設定します。 |
updateUIView(_:context:) | makeUIViewの直後、その後は関連する状態が変化するたび。 | SwiftUI の状態を UIKit ビューに反映します。 |
dismantleUIView(_:coordinator:) | Representable がビュー階層から削除されたとき。 | オブザーバー、タイマー、サブスクリプションをクリーンアップします。 |
sizeThatFits(_:uiView:context:) | レイアウト中に、SwiftUI がビューの理想サイズを必要とするとき。iOS 16 以降。 | カスタムのサイズ提案を返します。 |
**updateUIViewが最も重要なメソッドである理由:**Representable が読み取る@Binding、@State、@Environment、または@Observableのプロパティが変化するたびに、SwiftUI はこのメソッドを呼び出します。SwiftUI から UIKit への状態同期はすべてここで行われます。プロパティの更新を省略すると、UIKit ビューの状態が同期されなくなります。
オプション:dismantleUIView
static func dismantleUIView(_ uiView: UITextView, coordinator: Coordinator) {
// Remove observers, invalidate timers, cancel subscriptions.
// The coordinator is passed in so you can access state stored on it.
coordinator.cancellables.removeAll()
}
オプション:sizeThatFits(iOS 16 以降)
@available(iOS 16.0, *)
func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: UITextView,
context: Context
) -> CGSize? {
// Return nil to fall back to UIKit's intrinsicContentSize.
// Return a CGSize to override SwiftUI's sizing for this view.
let width = proposal.width ?? UIView.layoutFittingExpandedSize.width
let size = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
return size
}
UIViewControllerRepresentable プロトコル
UIViewControllerRepresentableを使って、UIViewControllerサブクラスをラップします。通常は、システムのピッカー、書類スキャナー、メール作成画面、またはモーダル表示するコントローラーに使用します。
struct DocumentScannerView: UIViewControllerRepresentable {
@Binding var scannedImages: [UIImage]
@Environment(\.dismiss) private var dismiss
func makeUIViewController(context: Context) -> VNDocumentCameraViewController {
let scanner = VNDocumentCameraViewController()
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, context: Context) {
// Usually empty for modal controllers -- nothing to push from SwiftUI.
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
}
表示したコントローラーからの結果の処理
コーディネーターがデリゲートのコールバックを受け取り、親の@Bindingまたはクロージャーを通じて結果を SwiftUI に返します。
extension DocumentScannerView {
final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate {
let parent: DocumentScannerView
init(_ parent: DocumentScannerView) { self.parent = parent }
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFinishWith scan: VNDocumentCameraScan
) {
parent.scannedImages = (0..<scan.pageCount).map { scan.imageOfPage(at: $0) }
parent.dismiss()
}
func documentCameraViewControllerDidCancel(_ controller: VNDocumentCameraViewController) {
parent.dismiss()
}
func documentCameraViewController(
_ controller: VNDocumentCameraViewController,
didFailWithError error: Error
) {
parent.dismiss()
}
}
}
コーディネーターパターン
コーディネーターが必要な理由
UIKit のデリゲート、データソース、ターゲット・アクションのパターンには、参照型(class)が必要です。SwiftUI の Representable 構造体は値型のため、デリゲートとして機能できません。コーディネーターは、SwiftUI が作成して管理するclassのインスタンスで、Representable ビューと同じ期間存続します。
構造
コーディネーターは、必ず Representable の内部、またはエクステンション内にネストします。コーディネーターが@Bindingプロパティに書き戻せるように、parent(Representable 構造体)への参照を保持します。
struct SearchBarView: UIViewRepresentable {
@Binding var text: String
var onSearch: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UISearchBar {
let bar = UISearchBar()
bar.delegate = context.coordinator // Set delegate HERE, not in updateUIView
return bar
}
func updateUIView(_ uiView: UISearchBar, context: Context) {
context.coordinator.parent = self
if uiView.text != text {
uiView.text = text
}
}
final class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchBarView
init(_ parent: SearchBarView) { self.parent = parent }
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
parent.text = searchText
}
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
parent.onSearch(parent.text)
searchBar.resignFirstResponder()
}
}
}
主なルール
-
デリゲートは
updateUIViewではなく、makeUIView/makeUIViewControllerで設定してください。 更新メソッドは、ラップされたビューに影響する状態の変更に応じて何度も実行されることがあります。そこでデリゲートを設定すると、冗長な代入が発生し、予期しない副作用を引き起こす可能性があります。 -
コピーされた親の状態は、自分で更新してください。 コーディネーターが
var parentにRepresentableを保持している場合、updateUIViewまたはupdateUIViewControllerの先頭でcontext.coordinator.parent = selfを代入してください。バインディングは引き続き真のデータソースを参照しますが、クロージャとバインディング以外の値はコーディネーターにコピーされます。 -
クロージャでは
[weak coordinator]を使ってください。 コーディネーターをキャプチャするコーディネーターとUIKitオブジェクトの間で、強参照サイクルが発生するのを防げます。
UIHostingController
UIHostingControllerを使って、SwiftUIビューをUIKitのビューコントローラー内に埋め込みます。
基本的な埋め込み
final class ProfileViewController: UIViewController {
private let hostingController = UIHostingController(rootView: ProfileView())
override func viewDidLoad() {
super.viewDidLoad()
// 1. Add as child
addChild(hostingController)
// 2. Add and constrain the view
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(hostingController.view)
NSLayoutConstraint.activate([
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
// 3. Notify the child
hostingController.didMove(toParent: self)
}
}
「addChild、ビューの追加、didMove」の3ステップは必須です。いずれかを省略すると、コンテナ管理のコールバックが正しく呼び出されず、表示状態の遷移や特性の伝播が機能しなくなります。
サイズ設定オプション(iOS 16以降)
@available(iOS 16.0, *)
hostingController.sizingOptions = [.intrinsicContentSize]
| オプション | 効果 |
|---|---|
.intrinsicContentSize | ホスティングコントローラーのビューが、SwiftUIコンテンツのサイズをintrinsicContentSizeとして報告します。ホストするビュー自身にサイズを決めさせる場合は、Auto Layoutで使用してください。 |
.preferredContentSize | preferredContentSizeをSwiftUIコンテンツに合わせて更新します。ポップオーバーまたはフォームシートとして表示する場合に使用してください。 |
ルートビューの更新
UIKitでデータが変更されたら、新しい状態をホストされたSwiftUIビューに渡します。
func updateProfile(_ profile: Profile) {
hostingController.rootView = ProfileView(profile: profile)
}
監視可能なモデルの場合は、@Observableオブジェクトを渡せば、SwiftUIが変更を自動的に追跡するため、rootViewを再代入する必要はありません。
UIHostingConfiguration(iOS 16以降)
子ホスティングコントローラーを管理せずに、SwiftUIコンテンツをUICollectionViewCellまたはUITableViewCell内に直接描画します。
@available(iOS 16.0, *)
func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
cell.contentConfiguration = UIHostingConfiguration {
ItemRow(item: items[indexPath.item])
}
return cell
}
サイズ設定とレイアウト
intrinsicContentSizeの連携
UIViewRepresentableでラップされたUIKitビューは、intrinsicContentSizeを通じて自然なサイズをSwiftUIに伝えます。frame()またはfixedSize()で上書きしない限り、SwiftUIはレイアウト時にこのサイズを尊重します。
SwiftUIが管理するジオメトリ
SwiftUIは、ラップされたビューのcenter、bounds、frame、transformを管理します。makeUIViewまたはupdateUIViewで、uiViewのこれらのプロパティを直接設定しないでください。内部サブレイヤーには、sizeThatFits、固有コンテンツサイズ、SwiftUIのレイアウトモディファイア、またはカスタムUIKitサブビュー内のレイアウトコードを使用してください。
fixedSize()とframe()の相互作用
| SwiftUIモディファイア | Representableへの効果 |
|---|---|
| モディファイアなし | SwiftUIはintrinsicContentSizeを理想サイズとして使用します。ビューのサイズは柔軟です。 |
.fixedSize() | Representableを両軸で理想サイズ(固有サイズ)に固定します。 |
.fixedSize(horizontal: true, vertical: false) | 幅を固有サイズに固定し、高さは柔軟なままにします。 |
.frame(width:height:) | 提案サイズを上書きし、UIKitビューはこのサイズを受け取ります。 |
UIHostingControllerでのAuto Layout
UIHostingControllerを子として埋め込む場合、そのビューを制約で固定します。セルのサイズを自動調整する場合や、高さが変わるセクションでは、Auto LayoutがSwiftUIコンテンツの自然なサイズを取得できるよう、.sizingOptions = [.intrinsicContentSize]を使用してください。
状態を同期するパターン
@Binding:双方向同期(SwiftUI <-> UIKit)
両方の側で同じ値を読み書きする場合は、@Bindingを使用してください。コーディネーターはデリゲートコールバックでparent.bindingPropertyに書き込み、updateUIViewはバインディングを読み取ってUIKitビューに反映します。
// SwiftUI -> UIKit: in updateUIView
if uiView.text != text { uiView.text = text }
// UIKit -> SwiftUI: in Coordinator delegate method
func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
}
クロージャ:一方向のイベント(UIKit -> SwiftUI)
ボタンのタップ、検索の送信、スキャンの完了など、結果を返す必要のないイベントには、バインディングの代わりにクロージャを渡します。
struct WebViewWrapper: UIViewRepresentable {
let url: URL
var onNavigationFinished: ((URL) -> Void)?
}
環境値
Representableのメソッド内では、context.environmentを使ってSwiftUIの環境値にアクセスします。
func updateUIView(_ uiView: UITextView, context: Context) {
let isEnabled = context.environment.isEnabled
uiView.isEditable = isEnabled
// Respond to color scheme changes
let colorScheme = context.environment.colorScheme
uiView.backgroundColor = colorScheme == .dark ? .systemGray6 : .white
}
更新ループの回避
SwiftUI が対象のビューに新しい状態を持つと、updateUIView が呼び出されます。これには、コーディネーターが @Binding に書き込んだことで発生する変更も含まれます。無駄な更新を防ぎ、無限ループを回避するために、次のようにガードします。
func updateUIView(_ uiView: UITextView, context: Context) {
// GUARD: Only update if values actually differ
if uiView.text != text {
uiView.text = text
}
}
ガードがないと、uiView.text の設定によってデリゲートの textViewDidChange が呼び出され、parent.text に書き込まれ、その結果、再び updateUIView が呼び出されることがあります。
UIKit の自動オブザベーション追跡
SwiftUI と @Observable モデルを共有する UIKit 画面では、画面は UIKit のままにし、UIKit の追跡対象となる更新フックから監視対象の状態を読み取ります。
- iOS 26 以降:ラベル、色、表示状態、有効状態などのレイアウト以外の UI には
updateProperties()を使用します。ジオメトリにはレイアウトフックを、セルにはセル構成更新ハンドラーを使用します。 - iOS 18:UIKit の自動追跡には、
Info.plistでUIObservationTrackingEnabledを有効にする必要があります。 - iOS 17:
@Observableは利用できますが、UIKit の自動オブザベーション追跡は利用できません。手動のwithObservationTrackingは一度だけ実行されるため、これを使ったポーリングループは作成しないでください。 - iOS 15~16 または既存の
ObservableObject:Combine のobjectWillChange、デリゲート、通知、明示的なコールバックを使用します。
移行パターンについては、references/hosting-migration.md を参照してください。
Sendable に関する考慮事項
UIKit のデリゲートプロトコルは Sendable ではありません。コーディネーターが UIKit のデリゲートに準拠すると、UIKit からメインアクター分離を継承します。コーディネーターには @MainActor を指定するか、UIKit の状態に実際に触れないメソッドに限って nonisolated を使用してください。Swift 6 の厳密な並行処理では、次のようにします。
@MainActor
final class Coordinator: NSObject, UISearchBarDelegate {
var parent: SearchBarView
init(_ parent: SearchBarView) { self.parent = parent }
// Delegate methods are main-actor-isolated -- safe to access UIKit and @Binding.
}
分離境界を越えてクロージャーを渡す場合は、@Sendable を指定するか、正しいアクター上でキャプチャされるようにしてください。
よくある間違い
| 間違い | 修正方法 |
|---|---|
update* で UIKit オブジェクトやデリゲートを再作成する | make* で一度だけ作成し、変更された状態のみを更新します。 |
| 対象ビューの frame/bounds/transform を直接変更する | サイズの決定は SwiftUI に任せます。intrinsic size、sizeThatFits、モディファイア、または内部レイアウトを使用します。 |
| UIKit のクロージャーがコーディネーターを保持する | 弱参照でキャプチャし、ライフサイクルの所有者を 1 つにします。 |
| ピッカー/コントローラーの終了経路で、閉じる処理や完了処理が抜けている | すべてのデリゲート終了処理を、コーディネーターの 1 つのクリーンアップ経路に通します。 |
| オブザーバー、タイマー、サブスクリプションがビューの破棄後も残る | 必要に応じて dismantle* で削除またはキャンセルします。 |
| ホスティングコントローラーで、コンテインメントなしに手動で frame を設定する | Auto Layout と addChild/didMove(toParent:) を使用します。 |
コーディネーター内で @State を使用する | 保存プロパティを使用し、バインディング/コールバックを介してやり取りします。 |
withObservationTracking を手動でポーリングする | 利用可能な場合は UIKit の自動オブザベーションフックを使用し、古いターゲットでは明示的に無効化します。 |
レビューチェックリスト
- ビュー/コントローラーは
update*ではなくmake*で作成している - コーディネーターは
update*ではなくmake*でデリゲートとして設定している - 双方向の状態同期に
@Bindingを使用している -
updateUIViewは、重複更新を防ぐガードを設けたうえで、すべての SwiftUI の状態変更を処理している - 必要に応じて
dismantleUIViewでオブザーバー/タイマーをクリーンアップしている - コーディネーターとクロージャーの間に循環参照がない(
[weak coordinator]) -
UIHostingControllerを子として正しく追加している(addChild+didMove(toParent:)) - サイズ決定方法を選択している(
intrinsicContentSizeと固定frameとsizeThatFits) - 対象ビューのジオメトリは SwiftUI に任せている(
frame、bounds、center、transformを直接変更していない) - 必要に応じて、
updateUIView内でcontext.environmentから環境値を読み取っている - UIKit での
@Observableの読み取りには、ポーリングではなく iOS 18 以降/26 以降の自動追跡フックを使用している。iOS 17 では手動の一度限りの追跡のみ - 厳密な並行処理に対応するため、コーディネーターに
@MainActorを指定している - モーダルコントローラーは、すべてのデリゲート終了経路(成功、キャンセル、エラー)で閉じている
- コレクション/テーブルビューのセルでは、手動ホスティングではなく
UIHostingConfigurationを使用している(iOS 16 以降)
References
- Wrapping recipes: references/representable-recipes.md
- Migration patterns: references/hosting-migration.md
- Apple docs: UIViewRepresentable
- Apple docs: UIViewControllerRepresentable
- Apple docs: UIHostingController