[UIHostingController] UIKit 프로젝트에서 SwiftUI 적용해보기

2024. 8. 7. 22:32UIKit, SwiftUI, H.I.G

1️⃣ SwiftUI를 UIKit 프로젝트에서 사용한다고? 왜?

SwiftUI는 날이 갈수록 발전하고 있다.

처음 iOS 개발을 배울 때만 하더라도 기존 대부분의 프로젝트는 UIKit를 사용하고 있었고, SwiftUI는 그저 생긴 지 얼마 되지 않은 (왜 사용하는지 필요성을 못 느낀) Second User Interface Framework에 불과했는데,
내가 군대를 다녀온 불과 몇 년 사이에 (애플도 매년 UIKit에 비해 더 적극적으로 업데이트를 해주는 등) SwiftUI는 많은 발전과 안정화를 거듭하며 앞으로 더 밝은 전망을 보이고 있다.

"그럼 이제 UIKit 프로젝트를 SwiftUI로 갈아 엎으면 되는 건가?"
"UIKit 공부 때려치우고 이제 SwiftUI 써야겠다!"

실제로 UIKit에서 SwiftUI로 마이그레이션(migration)을 하고 있는 몇몇 프로젝트도 보긴 했지만,

현실적으로 이미 기존 상당수의 코드는 UIKit를 기반으로 쓰였다는 점 + 마이그레이션(migration) 시 드는 시간과 비용의 소모 + 아직 SwiftUI에서 지원하지 않는 많은 기능 등의 요소를 고려해 현실적으로 UIKit를 아예 버리는 것은 아직 쉽지 않다고 볼 수 있다.


여기서 나는 기존 상당수의 코드 / 프로젝트가 UIKit 기반으로 쓰여졌다는 점에 주목하고자 한다.

즉, SwiftUI를 앞으로 활용하는 데 있어서 UIKit을 SwiftUI로 전면 수정하는 방법도 물론 알아두면 좋겠지만,
그것보다는 기존 UIKit 기반의 프로젝트에서 SwiftUI가 제공하는 기능을 어떻게 활용할 수 있을지 + UIKit가 지원하지 않는 화면을 SwiftUI로 만들어 UIKit 프로젝트에 통합시키는 방법을 배우는 것이 현재 수준에서 SwiftUI를 잘 활용하는 것이라고 생각한다.


오늘 글은 기존 UIKit를 사용하고 있는 프로젝트에서 SwiftUI 기반의 View를 사용해야 하는 상황을 설명하게 될 거다.

WWDC22의 <Use SwiftUI with UIKit> Session에서 소개하고 있는 내용을 기반으로,
UIHostingController부터 시작해 @ObservedObject, @EnvironmentObject를 이용한 계층 간 데이터 전달, UIHostingConfiguration을 사용해 TableView와 CollectionView의 Cell로 SwiftUI를 활용하는 방법까지 차근차근 같이 알아보자!

 

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

 

2️⃣ UIHostingController로 SwiftUIView를 UIKit 프로젝트에 넣어보자

💡 UIHostingController는 UIViewController의 하위 클래스로, SwiftUIView 계층이 UIKit 기반 애플리케이션에 통합될 수 있도록 SwiftUIView를 감싸준다.

기본적으로 UIHostingController는 UIViewController의 하위 클래스다.

HostingController = UIViewController라고 생각해도 무방하며, 이에 따라 ViewController 생명주기나 뷰 계층을 관리하는 데 있어서 ViewController의 기능을 그대로 사용할 수 있는 것이 특징이다.

또한, HostingController는 rootView라는 이름의 파라미터로 SwiftUIView를 받아 초기화시킨다.
*rootView는 Content라는 타입의 값을 받는데, 여기서 말하는 Content는 SwiftUI의 View 프로토콜을 따르는 객체를 의미한다.


그럼 여기서 정리해 보자!

"HostingController가 SwiftUIView를 감싸준다는 것"의 의미는
"ViewController(= HostingController)가 갖고 있는 UIView 계층 안에 초기화 시에 rootView로 지정된 SwiftUI Content를 넣어주겠다는 것"으로 볼 수 있다!

*WWDC22에서 UIHostingController와 SwiftUIContent 간의 계층 관계를 설명한 아래 그림과 같이 보면 이해가 더 잘 될 거다.

UIHostingController와 UIViewController 간의 관계

UIKit 프로젝트 내에서 HostingController는 두 가지 방법으로 사용할 수 있다.

  1. SwiftUIView를 감싼 HostingController를 독립적인 ViewController로 취급해 전체 화면을 모두 담당시키는 방법
  2. SwiftUIView를 UIViewController 화면 레이아웃 내에 개별적인 컴포넌트로 취급해 화면 일부만 담당하도록 하는 방법

애플은 세션에서 전자를 Presenting a UIHostingController, 후자를 Embedding a UIHostingController라고 설명한다.
각 방법을 이어서 모두 설명해 보겠다.


나는 아래 코드에서 작성한 SwiftUIView를 UIKit 프로젝트에 넣고 싶은 상황이다.
해당 화면은 SwiftUI의 View 프로토콜을 따르는 SwiftUI 기반의 코드로 짜여져 있으며, 이미지와 텍스트만 있는 단순한 형태를 갖고 있다.

struct SwiftUIView: View {
    var body: some View {
        HStack {
            Image(systemName: "smiley")
            Text("This is a Swift UI View")
        }
        .font(.title3)
        .padding()
    }
}

 

아래 첫 번째 코드는 navigationController를 push하는 과정에서 UIHostingController를 바로 뷰 계층에 사용한 모습이다.
*UIHostingController는 UIViewController를 상속받았기에 UIViewController의 모든 특징을 사용할 수 있다고 위에서 말했다.

이럴 경우 present되는 다음 화면은 rootView로 SwiftUIView를 갖고 있는 HostingController가 될 것이고, 이때 SwiftUIView는 HostingController의 view 전체를 차지하고 있을 것이다.

let swiftUIView = SwiftUIView()		// Using SwiftUI View
let nextVC = UIHostingController(rootView: swiftUIView)
navigationController?.pushViewController(nextVC, animated: true)

아래 두 번째 코드는 UIHostingController가 현재 보이는 ViewController의 child로 사용된 모습이다.

이럴 경우 HostingController의 View는 현재 뷰컨트롤러의 하위 계층으로 추가되며,
레이아웃 제약 조건을 추가해 HostingController의 View가 뷰 컨트롤러 내에 특정한 위치와 크기를 갖도록 조정할 수 있게 된다.

let swiftUIView = SwiftUIView()		// Using SwiftUI View
let hostingController = UIHostingController(rootView: swiftUIView)

// nextViewController
self.addChild(hostingController)
self.view.addSubview(swiftUIController.view)
swiftUIController.didMove(toParent: self)

아래 구현된 화면을 보면 그 차이점이 더 명확하게 보일 거다!

왼쪽은 HostingController를 독립적인 VC로 바라본 경우, 오른쪽은 개별 컴포넌트로 뷰 계층에 추가한 경우 차이

 

3️⃣ @ObservedObject 사용해서 SwiftUI와 UIKit 간 데이터 전달하기

뷰를 넣는 방법을 배웠으니, 이제 데이터를 전달받는 방법도 배워야겠다!

여기서의 핵심은 UIKit의 데이터 -> SwiftUI 데이터 전달하는 것에 그치지 않고 + 데이터가 변경되면 자동으로 SwiftUIView를 자동으로 업데이트하는 것까지 이어지게 만들고 싶다는 것이다.
*데이터 전달에 그칠 거면 그냥 각 객체 사이에 메서드를 만들어서 값을 인자로 전달해도 (Passed Arguments) 상관없다.

@ObservedObject@EnvironmentObject를 사용하는 두 방법을 역시 아래에서 모두 살펴보자!

WWDC22 - Data in SwiftUI views

@ObservedObject를 사용하는 예제부터 살펴보자.

우선, ObservableObject 프로토콜을 준수하는 데이터 모델이 정의되어 있어야 한다. (여기서는 HeartData라는 이름의 class로 정의)
SwiftUIView에서는 View를 그리기 위해 이 데이터 모델(HeartData)을 준수하는 프로퍼티를 사용할 건데, 해당 프로퍼티(data)를 @ObservedObject로 선언한다.

여기서 @ObservedObject를 사용한다는 것은 데이터의 속성이 변경될 때, 그 데이터를 바탕으로 그린 SwiftUIView도 함께 자동으로 업데이트시키겠다는 의미!

WWDC22 - Bridging data to SwiftUI using ObservableObject

위에서 설명한 ObservableObject와 @ObservedObject 사이의 관계를 코드로 표현하면 아래와 같다.

데이터 모델로 사용할 ObservableObject로 선언된 HeartData에는 @Published라는 프로퍼티 래퍼(property wrapper) 어노테이션이 붙어있는 beatsPerMinute(분당 심박수) 프로퍼티가 존재한다.

SwiftUIView에서는 이 Heartdata 모델을 @ObservedObject 속성에 저장하고,
이 데이터를 기반으로 View 안에 있는 Text에 beatsPerMinute(분당 심박수) 프로퍼티에 접근해 표출시킨다.

import SwiftUI

class HeartData: ObservableObject {
    @Published var beatsPerMinute: Int
    
    init(beatsPerMinute: Int) {
        self.beatsPerMinute = beatsPerMinute
    }
}

struct HeartRateVIew: View {
    @ObservedObject var data: HeartData
    
    var body: some View {
        Text("\(data.beatsPerMinute) BPM")
    }
}

이제 최종적으로 뷰 컨트롤러에서 이제 HeartData와 SwiftUIView인 HeartRateView를 호스팅 하는 부분을 살펴보자.

외부에서 데이터(HeartRateData)를 받아 위에서 정의한 SwiftUI로 정의된 HeartRateView를 그려줬다.
뷰 컨트롤러 내부의 컴포넌트로 취급하던 UIHostingController는 rootView로 이 HeartRateView를 받아 뷰컨트롤러에 포함시킨다.
*뷰컨트롤러에서 받는 데이터는 SwiftUI 타입의 뷰가 아니기 때문에 @ObservedObject 어노테이션을 붙이지 않고 사용해도 상관없다.

class HeartRateViewController: UIViewController {
    
    let data: HeartData
    let hostingController: UIHostingController<HeartRateView> 
    
    init(data: HeartData) {
        self.data = data
        let heartRateView = HeartRateView(data: data)
        self.hostingController = UIHostingController(rootView: heartRateView)
    }
}

최종적으로 @ObservedObject를 사용한 UIKit-SwiftUI 사이의 데이터 흐름을 정리해 보자!

  1. ObservableObject를 채택한 HeartData 내부에는 @Published 프로퍼티 래퍼를 사용한 beatsPerMinute 값을 방출한다.
  2. SwiftUIView인 HeartRateView는 @ObservedObject를 통해 위에서 방출된 값을 관찰 / View에 있는 Text에 이 값을 표출한다.
  3. ViewController에서는 위의 SwiftUIView를 UIKit에서 그릴 수 있도록 UIHostingController를 통해 감싸서 포함시킨다.
  4. 변경된 beatsPerMinute값이 Publish 되면, @ObservedObject를 통해 받는 data 값이 변경되었다는 것을 알아차릴 수 있기 때문에 SwiftUIView인 HeartRateView는 자동으로 업데이트되어 새로운 값을 표시한다.

HeartData에서 UIHostingController까지 데이터의 변화에 따라 자동으로 SwiftUIView를 그린다!

 

4️⃣ UIHostingConfiguration을 사용해서 SwiftUI로 UIKit에 들어갈 Cell을 구현하자

💡 UIHostingConfiguration은 iOS 16에서 도입된 기능으로, SwiftUIView를 UIKit의 CollectionView와 TableView에서 Cell로 활용할 수 있도록 해준다.

UIHostingController가 SwiftUI Content를 래핑해서 UiViewController처럼 사용할 수 있도록 만들어줬다면,
UIHostingConfiguration은 마찬가지로 SwiftUI Content를 래핑해서 UIKit에서 Cell처럼 사용할 수 있도록 만들어주는 기능이다.

UIHostingController와 UIHostingConfiguration을 함께 비교해보기

해당 방법은 Cell Configuration이라는 UITableView와 UICollectionView Cell을 구성하는 Modern한 방법을 따른다.
*이 내용은 또 나중에 별도의 글로 다뤄보기로 한다:)

사용법은 아래와 같이 매우 간단하게 사용할 수 있다는 사실!

// SwiftUI 코드블럭 포함
cell.contentConfiguration = UIHostingConfiguration {
    VStack(alignment: .leading) {
        HStack {
            Label("Heart Rate", systemImage: "heart.fill")
                .foregroundColor(.pink)
                .font(.system(.subheadline, weight: .bold))
            Spacer()
            Text(Date(), style: .time)
                .foregroundStyle(.secondary)
                .font(.footnote)
        } 
    }
}

// SwiftUI 코드 분리
cell.contentConfiguration = UIHostingConfiguration {
    VStack(alignment: .leading) {
        HeartRateTitleView()	
        Spacer()
        HeartRateBPMView()    
    }
}

WWDC에서는 UIHostingConfiguration이 갖고 있는 네 가지 추가적인 특성에 대해서 설명한다. 함께 설명해 보겠다.

  1. Content Margins : .margins(_ edges:_ lentgth:) 속성을 가지고 셀 콘텐츠 내부의 여백을 확보할 수 있다.
  2. Cell backgrounds : .background(:) 속성을 사용하면 Cell Content 뒤에서 속성을 지정할 수 있다. 단, background의 Margin이나 Cell Sizing에는 영향을 미치지 못한다.
  3. List Separators : 자동정렬되는 리스트 구분자를 커스텀할 때는 .alignmentGuide를 사용할 수 있다.
  4. List Swipe Actions : .swipeActions(edge:)를 이용해 스와이프 동작을 정의할 수 있다.

이번 글 정리해 보면!

갈수록 활용가치가 높아지는 SwiftUIView를 기존 UIKit에서 활용할 수 있도록 애플이 적극 지원하고 있다는 점.
앞으로 UIKit 프로젝트에서 새로운 기능은 SwiftUI를 사용해서 개발하는 것도 고려할 수 있다는 점. 등으로 마무리해 보겠다. 끝!