오늘의 목표 · 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 뷰를 넣는 식이다.
| 하고 싶은 것 | API | 가용성 (Apple 문서) |
|---|---|---|
| SwiftUI 화면 전체를 push / present | UIHostingController | iOS 13+ |
| 기존 화면 일부만 SwiftUI로 | UIHostingController를 자식 VC로 | iOS 13+ (sizingOptions iOS 16+) |
| 컬렉션·테이블 셀을 SwiftUI로 | UIHostingConfiguration | iOS 16+ |
기존 UIView를 SwiftUI에서 | UIViewRepresentable | iOS 13+ (sizeThatFits iOS 16+) |
기존 UIViewController를 SwiftUI에서 | UIViewControllerRepresentable | iOS 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한다.
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 제약으로 한다.
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) // ③ 빠뜨리지 말 것
}
}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 쪽 크기는 따라오지 않는다.
| 옵션 | 자동으로 갱신되는 것 | 쓰는 곳 |
|---|---|---|
.intrinsicContentSize | host.view.intrinsicContentSize | Auto Layout으로 높이를 안 정한 자식 VC |
.preferredContentSize | host.preferredContentSize | popover처럼 VC 크기를 물어보는 표시 — modalPresentationStyle = .popover와 함께 |
UIKit이 가진 데이터를 SwiftUI로 흘리기
데이터는 UIKit 모델 계층이 소유하고, SwiftUI 뷰는 보여 주기만 하는 경우가 많다. A 방식은 값이 바뀔 때마다 didSet { host.rootView = HeartRateView(…) }로 뷰를 손으로 갈아 끼운다 — 한 곳을 빠뜨리면 낡은 화면이다. B 방식은 같은 @Observable 모델을 양쪽이 참조해 갱신 코드가 없다.
ObservableObject로 보여 줬다. iOS 17+에서는 같은 구조를 @Observable로 만들고, SwiftUI 뷰에는 래퍼도 붙이지 않는다 (Day 5).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 { } (리스트 레이아웃에서) |
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 // 스냅샷 재적용 없이 셀 갱신
}
}셀 상태 · 배경 · 스와이프
탭·하이라이트·선택은 여전히 컬렉션 뷰가 처리한다. 선택 상태에 따라 모양을 바꾸려면 configurationUpdateHandler 안에서 구성을 만든다 — 상태가 바뀔 때마다 다시 호출된다.
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)
}
}
}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로 동작시킨다.
UIViewControllerRepresentable에 의존하는 SwiftUI 뷰는 셀 안에서 쓸 수 없다 (WWDC22).4. SwiftUI 안에 UIKit — UIViewRepresentable 🔥 핵심
반대 방향이다. 이미 있는 UIView를 SwiftUI 뷰처럼 쓰게 감싼다. Apple 문서 · UIViewRepresentable: "The creation and update processes parallel the behavior of SwiftUI views." 만들기는 한 번, 갱신은 상태가 바뀔 때마다, 정리는 사라질 때다.
| UIKit에서 하던 일 | Representable에서 할 자리 |
|---|---|
init / viewDidLoad에서 뷰 생성·고정 설정 | makeUIView — 한 번 |
모델 바뀌면 configure(with:) 호출 | updateUIView — SwiftUI가 알아서 호출 |
delegate = self | delegate = context.coordinator |
deinit에서 옵서버 해제 | static dismantleUIView |
Auto Layout 제약 / intrinsicContentSize | SwiftUI .frame / sizeThatFits (iOS 16+) |
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()
}"This method may be called more than once with different proposed sizes during the same layout pass." (Apple 문서 · sizeThatFits) 풀이: 부수 효과 없이 크기만 계산해 돌려준다. 제안·결정 협상은 Day 7에서 본다.
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).
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에 둔다.
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 패턴의 거울상이다.
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 상태로
}
}
}
}점진 도입 순서
- 새로 만드는 화면부터
UIHostingController로 — 기존 코드 위험 0. 전환은 클로저로 기존 코디네이터에 맡긴다. - 그 화면 안에서 검증된 UIKit 컴포넌트는 Representable로 재사용.
- 리스트 화면은 셀부터
UIHostingConfiguration으로 — 컬렉션 뷰·데이터 소스는 그대로. - 모델을
@Observable로 옮겨 UIKit·SwiftUI가 같은 모델을 공유 (Day 5).
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을 세팅하면 안 되는 이유는?
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로 받는 연습이다.
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() }- 요구 1:
Coordinator를 만들고makeUIView에서.valueChangedtarget-action을 연결한다. - 요구 2: 점을 누르면
currentPage바인딩에 값을 되돌려 써서 위Text가 바뀌게 한다. - 요구 3:
updateUIView에서 코디네이터의parent를 갱신하고, 값이 다를 때만currentPage를 대입한다.
확인 방법: Preview를 실행해 "다음" 버튼과 점 탭 둘 다로 페이지를 바꿔 본다. 어느 쪽으로 바꾸든 Text와 점이 같은 숫자를 가리키면 성공이다.
정답 코드 펼치기
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() }