mcpField
A
axiom-uikit-bridging

axiom-uikit-bridging

github.com/charleswiltgen開発1.2k100 回インストール今日更新
リポジトリ

axiom-uikit-bridging について

UIKit と SwiftUI を連携させる iOS 開発者向けのリファレンススキルです。UIKit のビュー、コントローラー、ジェスチャー認識器を SwiftUI で使う方法と、UIKit で SwiftUI を表示する方法を扱い、ライフサイクルや状態同期、レイアウトも解説します。

主な機能

  1. 01UIView と UIViewController のラップ
  2. 02UIKit ジェスチャー認識器の連携
  3. 03UIKit への SwiftUI の埋め込み
  4. 04UIKit と SwiftUI の状態同期
  5. 05ライフサイクルとレイアウトの処理

活用シーン

  1. 01MKMapView や WKWebView を SwiftUI で使う
  2. 02写真ピッカーなどのコントローラーを SwiftUI に接続
  3. 03UIKit ナビゲーションに SwiftUI ビューを配置
  4. 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)で、次の役割を担います。

  1. UIKit ビューのデリゲート/データソースとして機能します
  2. 親の UIViewRepresentable 構造体への参照を保持します
  3. 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
レイアウト内の子 VCUIHostingController
オーバーレイまたは装飾補助ビュー内の 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

コメント