← SwiftUI 14일 마스터 6 / 14

Day 6 · UIKit과 함께 살기

UIKit 안의 SwiftUI(호스팅 컨트롤러·셀 구성)와 SwiftUI 안의 UIKit(Representable·Coordinator)을 양방향으로 잇는 방법과 함정을 다룬다.
진행률
0 / 0 완료
회사 앱은 SwiftUI로 처음부터 다시 짜지 않는다. 이미 있는 UIKit 앱 위에 새 화면·셀 하나씩 SwiftUI를 얹고, 반대로 SwiftUI 화면 안에서 검증된 UIKit 뷰를 그대로 쓴다. 이 다리는 API 다섯 개로 끝난다. 규칙은 하나다. 누가 부모인지가 배치와 생명주기를 정한다.

오늘의 목표 · 30분

  • 상황(화면 전체 / 화면 일부 / 셀 / 기존 UIView / 기존 VC)을 보고 다섯 API 중 맞는 것을 고른다.
  • UIHostingController를 자식 VC로 심고, sizingOptions로 Auto Layout 크기를 SwiftUI 내용에 맞춘다. SwiftUI 화면의 화면 전환 요청은 클로저로 UIKit 쪽에 넘긴다.
  • UIHostingConfiguration으로 컬렉션 뷰 셀을 SwiftUI로 만들고, 모델 변경이 스냅샷 없이 셀에 반영되게 한다.
  • UIViewRepresentable + Coordinator로 UIKit 델리게이트를 SwiftUI 상태로 되돌리고, static dismantle·frame 함정을 피한다.

1. 두 방향의 다리 — 어떤 API를 언제 🔥 핵심

다리는 방향이 둘이다. UIKit 안에 SwiftUI를 넣을 때는 호스팅 계열, SwiftUI 안에 UIKit을 넣을 때는 Representable 계열을 쓴다. 두 방향은 겹칠 수 있다 — UIKit 앱 속 SwiftUI 화면 안에 다시 UIKit 뷰를 넣는 식이다.

기존 UIKit 앱 (UINavigationController) UIHostingController(rootView:) SwiftUI 화면 UIViewRepresentable 기존 UIKit 뷰 재사용 UICollectionView 셀 ← UIHostingConfiguration 셀 ← UIHostingConfiguration 기존 UIKit 셀 (그대로 둬도 됨)
전면 재작성 없이 화면·셀 단위로 섞는다. 한 컬렉션 뷰 안에 UIKit 셀과 SwiftUI 셀이 공존해도 된다.
하고 싶은 것API가용성 (Apple 문서)
SwiftUI 화면 전체를 push / presentUIHostingControlleriOS 13+
기존 화면 일부만 SwiftUI로UIHostingController를 자식 VC로iOS 13+ (sizingOptions iOS 16+)
컬렉션·테이블 셀을 SwiftUI로UIHostingConfigurationiOS 16+
기존 UIView를 SwiftUI에서UIViewRepresentableiOS 13+ (sizeThatFits iOS 16+)
기존 UIViewController를 SwiftUI에서UIViewControllerRepresentableiOS 13+
🔑 누가 부모인지가 배치를 정한다

UIKit이 부모(호스팅)면 UIKit이 배치한다 — 호스팅 뷰에 Auto Layout 제약을 거는 게 정상이다. SwiftUI가 부모(Representable)면 SwiftUI가 배치한다 — 감싼 UIKit 뷰의 frame을 직접 만지면 안 된다. 오늘 나오는 함정의 절반이 이 한 줄에서 나온다.

2. UIKit 안에 SwiftUI ① — UIHostingController

UIHostingController는 UIViewController 서브클래스다. WWDC22 발표: "You can use a hosting controller anywhere you can use a view controller in UIKit." 기존 라우터·코디네이터 구조를 그대로 둔다.

화면 전체 push + SwiftUI가 다음 화면을 "요청"하는 법

호스팅된 SwiftUI 화면이 UIKit 내비게이션 스택 안에 있을 때, 다음 화면으로 가는 결정은 기존처럼 UIKit 쪽(코디네이터/VC)에 둔다. SwiftUI 뷰는 navigationController를 모른다. onSelect: (Order) -> Void 같은 클로저로 "이게 선택됐다"만 알리고, 호스팅한 쪽이 push한다.

SwiftUI는 클로저로 요청, UIKit 코디네이터가 [weak self]로 push
import UIKit
import SwiftUI

struct Order: Identifiable {
    let id: Int
    let title: String
}

struct OrderListView: View {
    let orders: [Order]
    var onSelect: (Order) -> Void                  // 전환은 요청만 한다

    var body: some View {
        List(orders) { order in
            Button(order.title) { onSelect(order) }
        }
    }
}

final class OrderDetailViewController: UIViewController {}   // 기존 UIKit 화면

@MainActor
final class OrdersCoordinator {
    private let navigationController: UINavigationController
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }

    func start() {
        let list = OrderListView(orders: [Order(id: 1, title: "주문 #1")]) { [weak self] order in
            self?.showDetail(order)                    // push는 UIKit이 한다
        }
        let host = UIHostingController(rootView: list)
        host.title = "주문"
        navigationController.pushViewController(host, animated: true)
    }

    private func showDetail(_ order: Order) {
        let detail = OrderDetailViewController()
        detail.title = order.title
        navigationController.pushViewController(detail, animated: true)
    }
}

[weak self]가 필요한 이유: 코디네이터 → 내비게이션 컨트롤러 → 호스팅 컨트롤러 → rootView → 클로저 → 코디네이터로 강한 참조 고리가 생긴다. UIKit에서 셀 버튼 클로저에 [weak self]를 쓰던 것과 같은 이유다.

화면 일부 — 자식 VC로 심기

실무에서 더 흔한 건 화면의 일부만 SwiftUI로 바꾸는 경우다. 자식 VC로 심고, 배치는 UIKit 제약으로 한다.

자식 VC 컨테인먼트 3단계 + sizingOptions
import UIKit
import SwiftUI

struct SummaryCard: View {
    var body: some View {
        VStack(alignment: .leading) {
            Text("이번 달 지출").font(.headline)
            Text("₩1,240,000").font(.title.bold())
        }
        .padding()
    }
}

final class DashboardViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        let host = UIHostingController(rootView: SummaryCard())
        // SwiftUI 내용 크기가 바뀌면 intrinsicContentSize도 따라 바뀐다 (iOS 16+)
        host.sizingOptions = .intrinsicContentSize

        addChild(host)                                    // ① 자식 VC 등록
        host.view.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(host.view)
        NSLayoutConstraint.activate([                     // ② 배치는 UIKit
            host.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        ])
        host.didMove(toParent: self)                      // ③ 빠뜨리지 말 것
    }
}
🚨 호스팅 컨트롤러의 view만 떼어 쓰지 말 것

WWDC22 원문: "Many SwiftUI features, such as toolbars, keyboard shortcuts, and views that use UIViewControllerRepresentable, require a connection to the view controller hierarchy in UIKit to integrate properly, so never separate a hosting controller's view from the hosting controller itself." (WWDC22 · Use SwiftUI with UIKit) 풀이: addChild 없이 host.view만 붙이면 툴바·단축키가 제대로 붙지 않는다. 항상 VC와 view를 함께 추가한다.

sizingOptions — 두 가지 옵션

Apple 문서 · sizingOptions의 기본값은 빈 집합이다. 켜지 않으면 SwiftUI 내용이 커져도 UIKit 쪽 크기는 따라오지 않는다.

옵션자동으로 갱신되는 것쓰는 곳
.intrinsicContentSizehost.view.intrinsicContentSizeAuto Layout으로 높이를 안 정한 자식 VC
.preferredContentSizehost.preferredContentSizepopover처럼 VC 크기를 물어보는 표시 — modalPresentationStyle = .popover와 함께

UIKit이 가진 데이터를 SwiftUI로 흘리기

데이터는 UIKit 모델 계층이 소유하고, SwiftUI 뷰는 보여 주기만 하는 경우가 많다. A 방식은 값이 바뀔 때마다 didSet { host.rootView = HeartRateView(…) }로 뷰를 손으로 갈아 끼운다 — 한 곳을 빠뜨리면 낡은 화면이다. B 방식은 같은 @Observable 모델을 양쪽이 참조해 갱신 코드가 없다.

A. 값 전달 — 사람이 매번 갱신 HeartRateVC var bpm: Int HeartRateView (beatsPerMinute: Int) bpm 바뀔 때마다 host.rootView = 새 뷰 B. @Observable 공유 — 읽은 곳이 자동 갱신 HeartRateVC let data (참조만) @Observable HeartData.bpm HeartRateView (data: HeartData) bpm = 94 자동 갱신
WWDC22 발표는 B를 ObservableObject로 보여 줬다. iOS 17+에서는 같은 구조를 @Observable로 만들고, SwiftUI 뷰에는 래퍼도 붙이지 않는다 (Day 5).
✅ B 방식 — 같은 @Observable 모델을 참조, 갱신 코드가 없다
import UIKit
import SwiftUI

@Observable final class HeartData {
    var beatsPerMinute = 78
}

struct HeartRateView: View {
    var data: HeartData                    // 래퍼 없음
    var body: some View { Text("\(data.beatsPerMinute) BPM") }
}

final class HeartRateViewController: UIViewController {
    let data: HeartData                    // UIKit 쪽은 참조만 보관

    init(data: HeartData) {
        self.data = data
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        let host = UIHostingController(rootView: HeartRateView(data: data))
        addChild(host)
        host.view.frame = view.bounds       // 여기는 UIKit이 부모 — frame 지정 OK
        host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(host.view)
        host.didMove(toParent: self)
    }

    func receive(sample: Int) {
        data.beatsPerMinute = sample        // body가 읽은 값 → 자동 갱신
    }
}

3. UIKit 안에 SwiftUI ② — UIHostingConfiguration 셀

iOS 16에 추가된 UIHostingConfiguration은 셀의 contentConfiguration에 넣는 가벼운 struct다. 뷰 컨트롤러를 만들지 않는다. UICollectionViewCell·UITableViewCell 모두 된다.

UIKit 커스텀 셀UIHostingConfiguration
셀 서브클래스 + xib/코드 레이아웃클로저 안에 SwiftUI 뷰
속성 변경 시 reconfigureItems로 알림@Observable 모델이면 셀 속 뷰가 직접 갱신
높이 바뀌면 레이아웃 무효화iOS 16 self-resizing 셀로 자동
trailingSwipeActionsConfigurationProvider.swipeActions { } (리스트 레이아웃에서)
셀 등록 — 스냅샷엔 식별자만, 셀엔 SwiftUI
import UIKit
import SwiftUI

@Observable final class HeartSample: Identifiable {
    let id = UUID()
    var bpm: Int
    init(bpm: Int) { self.bpm = bpm }
}

struct HeartRateRow: View {
    var sample: HeartSample
    var body: some View {
        HStack {
            Label("Heart Rate", systemImage: "heart.fill")
                .foregroundStyle(.pink)
                .font(.headline)
            Spacer()
            Text("\(sample.bpm) BPM").monospacedDigit()
        }
    }
}

final class HeartListViewController: UIViewController {
    private var samples = [HeartSample(bpm: 78), HeartSample(bpm: 94)]
    private var dataSource: UICollectionViewDiffableDataSource<Int, UUID>!

    override func viewDidLoad() {
        super.viewDidLoad()
        let layout = UICollectionViewCompositionalLayout.list(using: .init(appearance: .insetGrouped))
        let collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
        collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(collectionView)

        let registration = UICollectionView.CellRegistration<UICollectionViewListCell, UUID> {
            [unowned self] cell, _, id in
            guard let sample = samples.first(where: { $0.id == id }) else { return }
            cell.contentConfiguration = UIHostingConfiguration {
                HeartRateRow(sample: sample)
            }
            .margins(.horizontal, 20)             // 기본은 셀 layout margins
        }
        dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) {
            collectionView, indexPath, id in
            collectionView.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: id)
        }
        var snapshot = NSDiffableDataSourceSnapshot<Int, UUID>()
        snapshot.appendSections([0])
        snapshot.appendItems(samples.map(\.id))    // 모델이 아니라 식별자
        dataSource.apply(snapshot)
    }

    func receive(bpm: Int, for id: UUID) {
        samples.first(where: { $0.id == id })?.bpm = bpm   // 스냅샷 재적용 없이 셀 갱신
    }
}
① 컬렉션 변경 — UIKit과 똑같이 컬렉션 변경 추가·삭제·이동 새 스냅샷 apply 식별자만 담는다 Diffable DataSource 차이 계산 셀 삽입·이동·삭제 ② 속성 변경 — 셀 속 SwiftUI가 직접 속성 변경 bpm 78 → 94 셀 속 SwiftUI 뷰 body가 읽은 값 직접 갱신 DataSource 경유 없음 셀 크기 자동 조정 self-resizing UIKit만 쓸 때 ②는 reconfigureItems로 직접 알려야 했다
WWDC22: "When a change is made, the SwiftUI views in the cell are updated directly, without going through the diffable data source or the UICollectionView." iOS 16부터 self-sizing 셀은 self-resizing도 기본으로 켜져 있다.

셀 상태 · 배경 · 스와이프

탭·하이라이트·선택은 여전히 컬렉션 뷰가 처리한다. 선택 상태에 따라 모양을 바꾸려면 configurationUpdateHandler 안에서 구성을 만든다 — 상태가 바뀔 때마다 다시 호출된다.

configurationUpdateHandler + background + swipeActions
import UIKit
import SwiftUI

struct MedicalCondition: Identifiable {
    let id: UUID
    var text: String
}

final class ConditionStore {
    var items: [MedicalCondition] = []
    func delete(id: UUID) { items.removeAll { $0.id == id } }
}

func configure(_ cell: UICollectionViewListCell,
               with condition: MedicalCondition,
               store: ConditionStore) {
    cell.configurationUpdateHandler = { cell, state in
        cell.contentConfiguration = UIHostingConfiguration {
            HStack {
                Text(condition.text)
                Spacer()
                if state.isSelected {                    // UIKit 셀 상태를 SwiftUI에서 사용
                    Image(systemName: "checkmark")
                }
            }
            .swipeActions {
                Button(role: .destructive) {
                    store.delete(id: condition.id)       // indexPath가 아니라 안정 ID
                } label: {
                    Label("삭제", systemImage: "trash")
                }
            }
        }
        .background {                                    // 셀 가장자리까지 채운다
            Color.pink.opacity(0.08)
        }
    }
}
⚠️ 스와이프 액션에는 indexPath를 쓰지 않는다

WWDC22 원문: "Do not use the index path, as it may change while the cell is visible, causing the swipe actions to act on the wrong item." 풀이: 엉뚱한 항목이 지워지지 않게 모델의 안정 ID로 동작시킨다.

content vs background
content는 layout margins만큼 들어가고, background는 가장자리까지 채운다. self-sizing 높이는 content만 결정한다.
셀에서 안 되는 것
셀엔 VC가 없다. 그래서 UIViewControllerRepresentable에 의존하는 SwiftUI 뷰는 셀 안에서 쓸 수 없다 (WWDC22).

4. SwiftUI 안에 UIKit — UIViewRepresentable 🔥 핵심

반대 방향이다. 이미 있는 UIView를 SwiftUI 뷰처럼 쓰게 감싼다. Apple 문서 · UIViewRepresentable: "The creation and update processes parallel the behavior of SwiftUI views." 만들기는 한 번, 갱신은 상태가 바뀔 때마다, 정리는 사라질 때다.

makeCoordinator() ① 한 번 — 가장 먼저 makeUIView(context:) ② 한 번 — 생성 · delegate 연결 updateUIView(_:context:) ③ 상태가 바뀔 때마다 반복 static dismantleUIView ④ 사라질 때 정리 Coordinator delegate · target-action 수신 SwiftUI 상태 @Binding · @State · @Observable delegate 지정 값 되돌리기 상태 변경 → update 재호출 WWDC19: makeCoordinator가 먼저 불려서 make/update에서 context.coordinator를 쓸 수 있다
UIKit 뷰 안에서 생긴 변화는 SwiftUI로 저절로 전달되지 않는다. 그 길을 놓는 게 Coordinator다 (5절).
UIKit에서 하던 일Representable에서 할 자리
init / viewDidLoad에서 뷰 생성·고정 설정makeUIView — 한 번
모델 바뀌면 configure(with:) 호출updateUIView — SwiftUI가 알아서 호출
delegate = selfdelegate = context.coordinator
deinit에서 옵서버 해제static dismantleUIView
Auto Layout 제약 / intrinsicContentSizeSwiftUI .frame / sizeThatFits (iOS 16+)
기존 NSAttributedString 라벨 재사용 — sizeThatFits로 높이 협상
import SwiftUI
import UIKit

struct AttributedLabel: UIViewRepresentable {
    var text: NSAttributedString

    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.numberOfLines = 0                   // 변하지 않는 설정은 여기서
        return label
    }

    func updateUIView(_ label: UILabel, context: Context) {
        label.attributedText = text               // 상태 반영
        label.isEnabled = context.environment.isEnabled   // SwiftUI 환경 읽기
    }

    // 부모가 제안한 폭으로 높이를 계산한다 (iOS 16+)
    func sizeThatFits(_ proposal: ProposedViewSize,
                      uiView: UILabel, context: Context) -> CGSize? {
        guard let width = proposal.width, width.isFinite else { return nil }  // nil = 기본 알고리즘
        return uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
    }
}

#Preview {
    AttributedLabel(text: NSAttributedString(string: String(repeating: "긴 약관 문장 ", count: 20)))
        .padding()
}
📝 Apple 원문 — sizeThatFits

"This method may be called more than once with different proposed sizes during the same layout pass." (Apple 문서 · sizeThatFits) 풀이: 부수 효과 없이 크기만 계산해 돌려준다. 제안·결정 협상은 Day 7에서 본다.

🚨 감싼 UIKit 뷰의 frame을 직접 세팅하지 말 것

Apple 원문: "SwiftUI fully controls the layout of the UIKit view's center, bounds, frame, and transform properties. Don't directly set these layout-related properties on the view managed by a UIViewRepresentable instance from your own code because that conflicts with SwiftUI and results in undefined behavior." 풀이: UIKit 코드를 옮기다 makeUIView에 v.frame = CGRect(…)가 남기 쉽다. 지우고, 크기는 바깥에서 .frame(width: 80, height: 80)으로 정한다.

5. Coordinator와 UIViewControllerRepresentable

Apple 문서: "The system doesn't automatically communicate changes occurring within your view to other parts of your SwiftUI interface." 델리게이트 콜백, target-action은 사람이 이어야 한다. 그 객체가 Coordinator다 (WWDC19 · Integrating SwiftUI).

UITextView 델리게이트 → @Binding — 진실은 SwiftUI 쪽
import SwiftUI
import UIKit

struct RichTextView: UIViewRepresentable {
    @Binding var text: String

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    func makeUIView(context: Context) -> UITextView {
        let tv = UITextView()
        tv.font = .preferredFont(forTextStyle: .body)
        tv.delegate = context.coordinator          // 다리 연결
        return tv
    }

    func updateUIView(_ tv: UITextView, context: Context) {
        context.coordinator.parent = self          // ① 최신 바인딩으로 교체
        if tv.text != text { tv.text = text }      // ② 다를 때만 대입 — 커서 튐 방지
    }

    final class Coordinator: NSObject, UITextViewDelegate {
        var parent: RichTextView
        init(parent: RichTextView) { self.parent = parent }

        func textViewDidChange(_ tv: UITextView) {
            parent.text = tv.text                  // UIKit 이벤트 → SwiftUI 상태
        }
    }
}

#Preview {
    RichTextView(text: .constant("메모"))           // Preview에선 Binding.constant (Day 3)
        .frame(height: 120)
}
🔑 ①과 ②가 왜 필요한가

① makeCoordinator()는 한 번만 불리지만 Representable struct는 업데이트마다 새로 만들어진다 (Day 1). 매번 호출되는 updateUIView에서 parent를 갈아 줘야 낡은 값을 보지 않는다.

② 타이핑 → text 변경 → updateUIView → 무조건 대입하면 커서가 끝으로 튄다. updateUIView는 body처럼 여러 번 불린다. 차이가 있을 때만 UIKit을 건드린다.

dismantle은 static이다

func dismantleUIView(…)처럼 static을 빼면 프로토콜 요구사항이 아닌 별개 메서드가 되어 호출되지 않는다 — 옵서버가 해제되지 않는다. Apple 문서 · dismantleUIView(_:coordinator:)는 static func로 선언돼 있다(dismantleUIViewController도 같다). self가 없으니 정리할 상태는 coordinator에 둔다.

✅ static dismantle — 정리할 상태는 coordinator에
import SwiftUI
import UIKit

struct KeyboardAwareTextView: UIViewRepresentable {
    func makeCoordinator() -> Coordinator { Coordinator() }
    func makeUIView(context: Context) -> UITextView {
        context.coordinator.startObserving()
        return UITextView()
    }
    func updateUIView(_ uiView: UITextView, context: Context) {}

    static func dismantleUIView(_ uiView: UITextView, coordinator: Coordinator) {
        coordinator.stopObserving()            // 사라질 때 확실히 호출된다
    }

    final class Coordinator {
        private var token: NSObjectProtocol?
        func startObserving() {
            token = NotificationCenter.default.addObserver(
                forName: UIResponder.keyboardWillShowNotification,
                object: nil, queue: .main) { _ in print("키보드") }
        }
        func stopObserving() {
            if let token { NotificationCenter.default.removeObserver(token) }
        }
    }
}

기존 VC를 감쌀 때 — UIViewControllerRepresentable

재사용할 대상은 UIView보다 UIViewController인 경우가 많다. 구조는 같고 이름만 …ViewController로 바뀐다. 완료·취소 콜백은 클로저로 받아 SwiftUI 쪽이 시트를 닫게 한다 — 2절 push 패턴의 거울상이다.

사내 프로필 편집 VC를 SwiftUI 시트로 띄우기
import SwiftUI
import UIKit

// 회사에 이미 있는 VC라고 가정
protocol ProfileEditorDelegate: AnyObject {
    func profileEditor(_ vc: ProfileEditorViewController, didSave name: String)
    func profileEditorDidCancel(_ vc: ProfileEditorViewController)
}
final class ProfileEditorViewController: UIViewController {
    weak var delegate: ProfileEditorDelegate?
    var initialName = ""
}

struct ProfileEditor: UIViewControllerRepresentable {
    var name: String
    var onFinish: (String?) -> Void                 // nil = 취소

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    func makeUIViewController(context: Context) -> ProfileEditorViewController {
        let vc = ProfileEditorViewController()
        vc.delegate = context.coordinator
        return vc
    }

    func updateUIViewController(_ vc: ProfileEditorViewController, context: Context) {
        context.coordinator.parent = self
        vc.initialName = name
    }

    final class Coordinator: NSObject, ProfileEditorDelegate {
        var parent: ProfileEditor
        init(parent: ProfileEditor) { self.parent = parent }

        func profileEditor(_ vc: ProfileEditorViewController, didSave name: String) {
            parent.onFinish(name)
        }
        func profileEditorDidCancel(_ vc: ProfileEditorViewController) {
            parent.onFinish(nil)
        }
    }
}

struct ProfileScreen: View {
    @State private var name = "효성"
    @State private var isEditing = false

    var body: some View {
        Button("\(name) 편집") { isEditing = true }
            .sheet(isPresented: $isEditing) {
                ProfileEditor(name: name) { newName in
                    if let newName { name = newName }
                    isEditing = false                 // 닫기는 SwiftUI 상태로
                }
            }
    }
}

점진 도입 순서

  1. 새로 만드는 화면부터 UIHostingController로 — 기존 코드 위험 0. 전환은 클로저로 기존 코디네이터에 맡긴다.
  2. 그 화면 안에서 검증된 UIKit 컴포넌트는 Representable로 재사용.
  3. 리스트 화면은 셀부터 UIHostingConfiguration으로 — 컬렉션 뷰·데이터 소스는 그대로.
  4. 모델을 @Observable로 옮겨 UIKit·SwiftUI가 같은 모델을 공유 (Day 5).
🆕 최신 API iOS 18+ · iOS 26+

iOS 18+ (WWDC24 · What's new in SwiftUI) — UIGestureRecognizerRepresentable로 기존 UIGestureRecognizer를 SwiftUI 뷰에 붙이고, Representable 안에서는 context.animate { }로 SwiftUI withAnimation과 같은 타이밍에 UIKit 뷰를 움직인다(iOS 17 타깃이면 if #available(iOS 18.0, *)로 감싼다).

UIKit의 Observation 자동 추적 — Apple 문서: updateProperties()(iOS 26+)·layoutSubviews() 안에서 읽은 Observable 프로퍼티가 바뀌면 UIKit이 그 메서드를 다시 부른다. iOS 18에서는 기본으로 꺼져 있어 Info.plist의 UIObservationTrackingEnabled를 true로 켜야 한다.

♿ 접근성 한 줄

Representable로 감싼 UIKit 뷰는 UIKit에 설정한 accessibilityLabel·accessibilityTraits를 그대로 가져간다. 커스텀 UIControl을 감쌌다면 VoiceOver로 Preview를 한 번 훑는다.

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
UIHostingController의 view에는 Auto Layout 제약을 거는데, UIViewRepresentable이 감싼 UIView에는 frame을 세팅하면 안 되는 이유는?
부모가 다르기 때문이다. 호스팅은 UIKit이 부모라 UIKit이 배치한다. Representable은 SwiftUI가 부모라 Apple 문서대로 SwiftUI가 center·bounds·frame·transform을 완전히 제어한다. 거기에 손대면 정의되지 않은 동작이 된다.
쉽게 말하면 방 배치는 그 방 주인이 정한다. 남의 방에 들어가서 가구를 옮기면 싸움이 난다.
UIHostingConfiguration 셀에서 모델의 bpm만 바뀌었다. 스냅샷을 다시 적용해야 하나?
아니다. 모델이 @Observable(WWDC22 당시엔 ObservableObject)이고 셀 속 SwiftUI 뷰가 bpm을 읽으면 그 뷰가 직접 갱신된다. 크기가 바뀌어도 iOS 16 self-resizing 셀이 맞춰 준다. 스냅샷은 항목 추가·삭제·이동 때만 적용한다.
쉽게 말하면 줄 세우기(순서)는 선생님(데이터 소스)이 하고, 각자 이름표 글씨 고치기는 학생(셀)이 알아서 한다.
🔮 코드 예측: Representable의 makeCoordinator, makeUIView, updateUIView에 각각 print를 넣고 화면에 띄운 뒤, 바인딩된 상태를 한 번 바꾸면 콘솔 출력 순서는?
makeCoordinator → makeUIView → updateUIView, 그리고 상태 변경 후 updateUIView가 한 번 더. WWDC19: "the Make Coordinator method is called first followed by the Make View and Update View methods." make 계열은 다시 불리지 않는다.
쉽게 말하면 통역사를 먼저 부르고, 가구를 한 번 들이고, 그다음부터는 바뀔 때마다 정리만 다시 한다.
🔁 지난 Day 복습: 아직 ObservableObject인 모델을 @State에 넣으면 어떻게 되나?
참조가 바뀔 때만 뷰가 갱신되고, @Published 프로퍼티가 바뀌어도 갱신되지 않는다 (Apple 문서 · State). 모델을 먼저 @Observable로 바꾸거나, 그 전까지는 @StateObject를 유지한다.
쉽게 말하면 옛날식 방송 수신기를 새 알림 앱에 꽂으면 소리가 안 난다. 수신기부터 바꿔야 한다.

🛠 미니 과제 (선택 · 10분)

UIPageControl을 감싸 SwiftUI 상태와 양방향으로 잇는다. target-action을 Coordinator로 받는 연습이다.

시작 코드 — 표시만 되고 점을 눌러도 SwiftUI가 모른다
import SwiftUI
import UIKit

struct PageDots: UIViewRepresentable {
    var numberOfPages: Int
    @Binding var currentPage: Int

    func makeUIView(context: Context) -> UIPageControl {
        let control = UIPageControl()
        control.numberOfPages = numberOfPages
        control.currentPageIndicatorTintColor = .systemPink
        control.pageIndicatorTintColor = .systemGray4
        return control
    }

    func updateUIView(_ control: UIPageControl, context: Context) {
        control.currentPage = currentPage
    }
}

struct PagerDemo: View {
    @State private var page = 0
    var body: some View {
        VStack(spacing: 20) {
            Text("현재 \(page + 1) / 5").font(.title2)
            PageDots(numberOfPages: 5, currentPage: $page)
            Button("다음") { page = (page + 1) % 5 }
        }
    }
}

#Preview { PagerDemo() }

확인 방법: Preview를 실행해 "다음" 버튼과 점 탭 둘 다로 페이지를 바꿔 본다. 어느 쪽으로 바꾸든 Text와 점이 같은 숫자를 가리키면 성공이다.

정답 코드 펼치기
✅ Coordinator로 target-action 연결
import SwiftUI
import UIKit

struct PageDots: UIViewRepresentable {
    var numberOfPages: Int
    @Binding var currentPage: Int

    func makeCoordinator() -> Coordinator { Coordinator(parent: self) }

    func makeUIView(context: Context) -> UIPageControl {
        let control = UIPageControl()
        control.numberOfPages = numberOfPages
        control.currentPageIndicatorTintColor = .systemPink
        control.pageIndicatorTintColor = .systemGray4
        control.addTarget(context.coordinator,
                          action: #selector(Coordinator.pageChanged(_:)),
                          for: .valueChanged)
        return control
    }

    func updateUIView(_ control: UIPageControl, context: Context) {
        context.coordinator.parent = self
        if control.currentPage != currentPage {
            control.currentPage = currentPage
        }
    }

    final class Coordinator: NSObject {
        var parent: PageDots
        init(parent: PageDots) { self.parent = parent }

        @objc func pageChanged(_ sender: UIPageControl) {
            parent.currentPage = sender.currentPage   // UIKit → SwiftUI
        }
    }
}

struct PagerDemo: View {
    @State private var page = 0
    var body: some View {
        VStack(spacing: 20) {
            Text("현재 \(page + 1) / 5").font(.title2)
            PageDots(numberOfPages: 5, currentPage: $page)
            Button("다음") { page = (page + 1) % 5 }
        }
    }
}

#Preview { PagerDemo() }

출처 · 참고자료