mcpField
S
swiftui-uikit-interop

swiftui-uikit-interop

github.com/dpearson2699開発1.2k4.1k 回インストール13 週間前に更新
リポジトリ

swiftui-uikit-interop について

SwiftUIとUIKitを双方向に連携させるiOS開発者向けガイドです。ライフサイクル、レイアウト、状態同期、移行パターンを扱い、連携時の注意点も説明します。

主な機能

  1. 01UIViewやコントローラーのSwiftUIへのラップ
  2. 02UIKitへのSwiftUIの埋め込み
  3. 03Coordinatorによるデリゲートイベントの受け渡し
  4. 04バインディングや環境値、状態の同期
  5. 05サイズ調整とライフサイクルの後処理

活用シーン

  1. 01SwiftUIでUIKitのシステムピッカーを使用
  2. 02既存のUIKit画面へのSwiftUI追加
  3. 03テキストビューなどの双方向状態同期

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-uikit-interop

インストール後、Claude Code で/swiftui-uikit-interopを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

evals/
evals.json
references/
hosting-migration.md
representable-recipes.md

詳細

SwiftUI-UIKit の相互運用

UIKit と SwiftUI を双方向に連携します。UIKit のビューをラップし、SwiftUI を UIKit の画面に埋め込み、ライフサイクルの所有権を重複させずに状態を同期します。

完全なラップのレシピについてはreferences/representable-recipes.mdを、UIKit から SwiftUI への移行パターンについてはreferences/hosting-migration.mdを参照してください。

目次

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()
        }
    }
}
主なルール
  1. デリゲートはupdateUIViewではなく、makeUIView/makeUIViewControllerで設定してください。 更新メソッドは、ラップされたビューに影響する状態の変更に応じて何度も実行されることがあります。そこでデリゲートを設定すると、冗長な代入が発生し、予期しない副作用を引き起こす可能性があります。

  2. コピーされた親の状態は、自分で更新してください。 コーディネーターがvar parentにRepresentableを保持している場合、updateUIViewまたはupdateUIViewControllerの先頭でcontext.coordinator.parent = selfを代入してください。バインディングは引き続き真のデータソースを参照しますが、クロージャとバインディング以外の値はコーディネーターにコピーされます。

  3. クロージャでは[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で使用してください。
.preferredContentSizepreferredContentSizeを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

コメント