안녕하세요.
WWDC22에 소개된 Use SwiftUI with UIKit 영상을 보고 내용 정리해보는 시간을 가져볼게요.
# 1. UIHostingController
UIHostingController는 SwiftUI 뷰 계층을 포함하고 있는 UIViewController 입니다.
UIHostingController는 UIViewController이기 때문에 UIKit 프로젝트에서 사용할 수 있죠.
예시를 들어볼게요.
SwiftUI View를 rootView로 한 UIHostingController를 만들고 화면에 출력해줄 수 있어요.
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
}
@IBAction func onClick(_ sender: Any) {
// SwiftUI View 기반으로 UIHostingController 생성
let hostingController = UIHostingController(rootView: MyView())
// UIHostingController 출력
self.present(hostingController, animated: true)
}
}
// SwiftUI View
struct MyView: View {
var body: some View {
Text("This is SwiftUI View")
}
}
또한, UIHostingController를 Child ViewController로 추가할 수 있습니다.
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let hostingController = UIHostingController(rootView: MyView())
// UIHostingController를 Child ViewController로 추가
self.addChild(hostingController)
self.view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
hostingController.view.frame = CGRect(x: 100, y: 50, width: 200, height: 200)
}
}
struct MyView: View {
var body: some View {
Text("This is SwiftUI View")
}
}
UIHostingController 안에 있는 SwiftUI의 콘텐츠가 변경되면 View 크기를 resize 시켜줘야겠죠?
iOS 16부터 sizingOptions 프로퍼티를 사용하여, 변경되는 콘텐츠에 따라서 View의 크기를 어떻게 바꿀지 결정할 수 있어요.
총 2가지 옵션이 있습니다.
UIHostingController의 ideal size가 Container ViewController에 반영되어야 할 때는 preferredContentSize를,
auto-layout으로 UIHostingController의 뷰를 배치했을 때는 intrinsicContentSize를 사용하라고 합니다.
intrinsicContentSize 옵션에 대해서 간단한 예시를 만들어봤어요.
class Model: ObservableObject {
@Published var text: String = "This is SwiftUI View"
}
struct MyView: View {
@ObservedObject var data: Model
var body: some View {
Text(data.text)
}
}
class ViewController: UIViewController {
let model = Model()
var hostingController: UIHostingController<MyView>!
override func viewDidLoad() {
hostingController = UIHostingController(rootView: MyView(data: model))
super.viewDidLoad()
self.addChild(hostingController)
self.view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor),
hostingController.view.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
])
}
@IBAction func onClick(_ sender: Any) {
model.text.append("A")
}
}
UIHostingController를 auto-layout으로 하단에 배치했고, 버튼을 클릭할 때마다 "A" 문자를 뒤에 붙여주도록(= 콘텐츠 변경) 구현했습니다.
버튼을 눌렀을 때 UILabel처럼 자동으로 size가 커졌으면 좋겠지만.... 현실은 그렇지 않았습니다...ㅠㅠ
자동으로 resizing 되길 원하시나요? intrinsicContentSize 옵션을 설정해주세요ㅎㅎ
class ViewController: UIViewController {
...
override func viewDidLoad() {
...
hostingController.sizingOptions = .intrinsicContentSize ✅
}
...
}
👍 👍 👍
# 2. Bridging data
UIHostingController를 사용하면 SwiftUI View를 UIKit 프로젝트에서 출력할 수 있다는 것을 알게 되었습니다.
그럼 UIKit의 데이터를 SwiftUI View로 출력하고 데이터가 변경될 때 SwiftUI View에도 반영되는 방법도 알아야겠죠?
(사실 바로 위 예제를 보면 정답을 알 수 있습니다...ㅎㅎ)
SwiftUI View에 parameter를 넘겨주면 어떨까요?
이렇게요.
struct MyView: View {
var text: String = "" // UIKit으로부터 데이터를 파라미터 형태로 전달받음.
var body: some View {
Text(text)
}
}
class ViewController: UIViewController {
var hostingController: UIHostingController<MyView>!
var text: String = "This is SwiftUI View"
override func viewDidLoad() {
super.viewDidLoad()
hostingController = UIHostingController(rootView: MyView(text: text))
self.addChild(hostingController)
self.view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
hostingController.view.frame = CGRect(x: 100, y: 300, width: 200, height: 20)
}
@IBAction func onClick(_ sender: Any) {
text.append("A")
}
}
하지만 이 코드에는 큰 문제가 있습니다....
버튼을 아무리 눌러도 변경된 text가 SwiftUI View에 반영되지 않습니다.
단순히 값을 SwiftUI View로 넘겨준 것이기 때문에, 값 변경에 따른 자동 렌더링이 되지 않습니다. 👿
이를 해결하기 위해서는 값이 변경될 때마다 UIHostingController의 rootView를 바꿔줘야 해요.
class ViewController: UIViewController {
var hostingController: UIHostingController<MyView>!
var text: String = "This is SwiftUI View"
override func viewDidLoad() {
super.viewDidLoad()
hostingController = UIHostingController(rootView: MyView(text: text))
self.addChild(hostingController)
self.view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
hostingController.view.frame = CGRect(x: 100, y: 300, width: 200, height: 20)
}
@IBAction func onClick(_ sender: Any) {
text.append("A")
hostingController.rootView = MyView(text: text) 🤔
}
}
이 방법을 사용할 경우, 데이터가 바뀔 때마다 UIHostingController의 rootView를 수동으로 업데이트해줘야 하는 불편함이 있어요.
다른 방법은 없을까요?
@ObservedObject, @EnvironmentObject property wrapper를 사용하면 좀 더 스마트하게 구현할 수 있습니다.
간단한 예시를 만들어볼까요?
우선 ObservableObject 프로토콜을 준수하는 Model 클래스를 만들고, 값이 바뀔 때마다 SwiftUI View에 알려주기 위해 @Published property wrapper를 추가해주세요. (struct가 아닌 class 여야 합니다.)
class MyData: ObservableObject { ✅
@Published var text: String ✅
init(text: String) {
self.text = text
}
}
SwiftUI View에서는 Model 객체를 가지고 있을 프로퍼티를 @ObservedObject property wrapper로 선언해주세요.
struct MyView: View {
@ObservedObject var data: MyData ✅
var body: some View {
Text(data.text)
}
}
ViewController에서는 기존 방법하고 동일하게 일반 프로퍼티에 저장해서 사용해주면 됩니다.
(ViewController는 SwiftUI View가 아니기 때문에 property wrapper를 사용할 필요가 없어요.)
class ViewController: UIViewController {
let data: MyData = MyData(text: "This is SwiftUI View")
var hostingController: UIHostingController<MyView>!
override func viewDidLoad() {
super.viewDidLoad()
hostingController = UIHostingController(rootView: MyView(data: data))
self.addChild(hostingController)
self.view.addSubview(hostingController.view)
hostingController.didMove(toParent: self)
hostingController.view.frame = CGRect(x: 100, y: 300, width: 200, height: 20)
}
@IBAction func onClick(_ sender: Any) {
data.text.append("A")
}
}
어때요??
어렵지 않죠??ㅎㅎㅎ
# 3. SwiftUI in cells
이번에는 CollectionView & TableView cell에 SwiftUI View를 등록하는 방법에 대해 알아볼게요.
UIKit은 Cell의 콘텐츠, 스타일, 동작 등을 정의할 수 있는 Cell Configuration이란 방법을 제공하고 있어요.
(잘 모르시는 분들은 아래 글을 참고해주세요)
iOS 16부터는 SwiftUI View를 cell configuration에 적용할 수 있도록 UIHostingConfiguration 이란 것이 추가되었습니다.
(UIHostingConfiguration은 Content Configuration 이랍니다!)
아래처럼 UIHostingConfiguration에 SwiftUI View를 추가하고 cell.contentConfiguration에 등록해주면 끝이에요.
class ViewController: UIViewController, UITableViewDataSource {
@IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
tableView.dataSource = self
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
10
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.contentConfiguration = UIHostingConfiguration {
HStack {
Image(systemName: "star").foregroundStyle(.purple)
Text("Favorites")
Spacer()
}
}
return cell
}
}
당연히 standalone view로 뽑아서 사용할 수 있습니다.
struct MyDataView: View {
var body: some View {
HStack {
Image(systemName: "star").foregroundStyle(.purple)
Text("Favorites")
Spacer()
}
}
}
class ViewController: UIViewController, UITableViewDataSource {
...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.contentConfiguration = UIHostingConfiguration {
MyDataView() ✅
}
return cell
}
}
이렇게 margin을 줄 수도 있구요
class ViewController: UIViewController, UITableViewDataSource {
...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.contentConfiguration = UIHostingConfiguration {
MyDataView()
}
.margins(.horizontal, 50) ✅
return cell
}
}
cell의 배경색을 지정해줄 수도 있고,
class ViewController: UIViewController, UITableViewDataSource {
...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.contentConfiguration = UIHostingConfiguration {
MyDataView()
}
.background(Color.yellow) ✅
return cell
}
}
swipe action도 등록할 수 있어요.
class ViewController: UIViewController, UITableViewDataSource {
...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.contentConfiguration = UIHostingConfiguration {
MyDataView()
.swipeActions(edge: .trailing) { ✅
Button(role: .destructive) {
// 이곳에서 indexPath를 직접적으로 사용하지 마세요.
} label: {
Image(systemName: "trash")
}
}
}
return cell
}
}
[주의]
swipeActions 안에서 indexPath를 직접 사용하지 말라고 합니다.
(move, delete 등으로 indexPath가 다른 cell을 가리킬 위험이 있기 때문이에요.)
그리고 지금 Seperator를 자세히 보면 알 수 있듯이, 기본적으로 SwiftUI text를 기준으로 정렬(배치?)된다고 해요.
Seperator를 커스텀 하고 싶으시면 아래처럼 .alignmentGuide 수식어를 사용해주시면 됩니다.
class ViewController: UIViewController, UITableViewDataSource {
...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.contentConfiguration = UIHostingConfiguration {
MyDataView()
.alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } ✅
}
return cell
}
}
마지막으로 Select, Highlight 등 현재 cell state에 따라 SwiftUI View를 커스텀 하고 싶다면, configurationUpdateHandler 안에서 UIHostingConfiguration을 생성해주면 됩니다ㅎㅎ
class ViewController: UIViewController, UITableViewDataSource {
...
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = UITableViewCell()
cell.configurationUpdateHandler = { cell, state in ✅
cell.contentConfiguration = UIHostingConfiguration {
HStack {
MyDataView()
.alignmentGuide(.listRowSeparatorLeading) { $0[.leading] }
if state.isSelected {
Image(systemName: "checkmark")
}
}
}
}
return cell
}
}
# 4. Data flow for cells
드디어 마지막 순서입니다..
저희는 위에서부터
- UIHostingController를 사용해서 UIKit에서 SwiftUI View를 사용하는 방법도 알아봤고,
- @ObservedObject property wrapper를 사용해서 데이터가 변경될 때 SwiftUI View에도 반영하는 방법도 알아봤고,
- UIHostingConfiguration을 사용해서 SwiftUI View를 UICollectionView & UITableView의 cell에 등록하는 방법도 알아봤습니다.
그럼 UIHostingConfiguration과 UIKit 간 데이터 전달 방법도 알아봐야겠죠?
이 부분은 위에서 알아봤던 @ObservableObject <-> @ObservedObject 관계와 완전히 동일해요.
그래서 설명은 가볍게 생략하고 전체 코드만 올리는 것으로 대체할게요.
(위에서는 계속 UITableViewDataSource 프로토콜을 채택하는 방법으로 구현했지만, 이번에는 UITableViewDiffableDataSource를 사용해봤습니다ㅎㅎ)
(DiffableDataSource에 대해서는 아래 글을 참고해주세요.)
// DiffableDataSource를 위해 Identifiable 프토토콜 채택
// UIKit <-> SwiftUI 간 데이터 상호작용을 위해 ObservableObject 프로토콜 채택
class MyData: Identifiable, ObservableObject {
let id: UUID = UUID()
let text: String
@Published var isFavorite: Bool
init(text: String, isFavorite: Bool) {
self.text = text
self.isFavorite = isFavorite
}
}
// DiffableDataSource를 위해 Hashable 프로토콜 채택
extension MyData: Hashable {
static func == (lhs: MyData, rhs: MyData) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
struct MyDataView: View {
// UIKit <-> SwiftUI 간 데이터 상호작용을 위해 @ObservedObject property wrapper 사용
@ObservedObject var data: MyData
var body: some View {
HStack {
Button(action: { data.isFavorite.toggle() }) {
Image(systemName: data.isFavorite ? "star.fill" : "star").foregroundStyle(.purple)
}
Text(data.text)
}
}
}
class ViewController: UIViewController {
@IBOutlet weak var tableView: UITableView!
private var dataSource: UITableViewDiffableDataSource<Int, MyData>!
var datas = [
MyData(text: "Coding", isFavorite: true),
MyData(text: "Work", isFavorite: false),
MyData(text: "Noting", isFavorite: true),
]
override func viewDidLoad() {
super.viewDidLoad()
dataSource = UITableViewDiffableDataSource<Int, MyData>(tableView: tableView, cellProvider: { [weak self] tableView, indexPath, itemIdentifier in
let cell = UITableViewCell()
// configurationUpdateHandler 또는 swipeAction에서 indexPath를
// 사용하지 않기 위해서 사전에 data 추출
let data = (self?.datas[indexPath.row])!
cell.configurationUpdateHandler = { cell, state in
cell.contentConfiguration = UIHostingConfiguration {
HStack {
MyDataView(data: data)
Spacer()
if state.isSelected {
Image(systemName: "checkmark")
}
}
.swipeActions { // swipe action 추가
Button(role: .destructive) {
self?.removeItem(data)
} label: {
Image(systemName: "trash")
}
}
}
}
return cell
})
tableView.dataSource = dataSource
updateSnapshot(datas)
}
func removeItem(_ item: MyData) {
datas.removeAll { $0.id == item.id }
updateSnapshot(datas)
}
func updateSnapshot(_ items: [MyData]) {
var snapshot = NSDiffableDataSourceSnapshot<Int, MyData>()
snapshot.appendSections([0])
snapshot.appendItems(items)
dataSource.apply(snapshot)
}
}
# 5. 참고
## UIHostingController and UIHostingConfiguration
## 참고
- https://developer.apple.com/videos/play/wwdc2022/10072/
이번 글은 여기서 마무리.
'WWDC' 카테고리의 다른 글
[WWDC23] Verify app dependencies with digital signatures (0) | 2023.08.16 |
---|---|
[WWDC22] Explore App Tracking Transparency (0) | 2023.08.05 |
[WWDC22] Create your Privacy Nutrition Label (0) | 2023.07.31 |
[WWDC22] What's new in WKWebView (0) | 2023.07.29 |
[WWDC22] Embrace Swift generics (0) | 2023.07.03 |