WWDC

[WWDC22] Use SwiftUI with UIKit (feat. UIHostingConfiguration)

Phililip
728x90

안녕하세요.

 

WWDC22에 소개된 Use SwiftUI with UIKit 영상을 보고 내용 정리해보는 시간을 가져볼게요.

 


# 1. UIHostingController

UIHostingController는 SwiftUI 뷰 계층을 포함하고 있는 UIViewController 입니다.

 

 

UIHostingController는 UIViewController이기 때문에 UIKit 프로젝트에서 사용할 수 있죠.

 

출처: https://developer.apple.com/videos/play/wwdc2022/10072/

 

 

 

예시를 들어볼게요.

 

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가지 옵션이 있습니다.

 

출처: https://developer.apple.com/videos/play/wwdc2022/10072/

 

 

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에도 반영되는 방법도 알아야겠죠?

(사실 바로 위 예제를 보면 정답을 알 수 있습니다...ㅎㅎ)

 

출처: https://developer.apple.com/videos/play/wwdc2022/10072/

 

 

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를 사용하면 좀 더 스마트하게 구현할 수 있습니다.

 

출처: https://developer.apple.com/videos/play/wwdc2022/10072/

 

 

간단한 예시를 만들어볼까요?

 

우선 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이란 방법을 제공하고 있어요.

(잘 모르시는 분들은 아래 글을 참고해주세요)

 

UIListContentConfiguration, UIBackgroundConfiguration, UICellConfigurationState

안녕하세요. 우연히 봤는데, UITableViewCell에서 textLabel, detailTextLabel, imageView가 deprecated 되었더라구요? 들어가서 보니, contentConfiguration이라는 것을 사용하라고 합니다. (WWDC20) Modern ce..

phillip5094.tistory.com

 

 

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

이번 글은 DiffableDataSource에 대해 알아보겠습니다. ## 1. Data Source 프로토콜? 🤔 DiffableDataSource는 뒤에 Data sources라는 말이 붙었듯이, collection View와 tableView에서 사용하는 클래스에요. 우선..

phillip5094.tistory.com

 

// 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/

 

Use SwiftUI with UIKit - WWDC22 - Videos - Apple Developer

Learn how to take advantage of the power of SwiftUI in your UIKit app. Build custom UICollectionView and UITableView cells seamlessly...

developer.apple.com

 


 

이번 글은 여기서 마무리.

 

 

 

반응형