SwiftUI안에서 UIKit 사용하기(feat. PHPickerViewController)

woo94
dev-woo94
Published in
16 min readJun 15, 2024

--

왜?

SwiftUI는 단순하고 강력하다는 장점을 가지고 있습니다. 하지만 단순함은.. 때에따라서는 단점이 됩니다. 그동안 UIKit에서 할 수 있던 많은 것들을 비교적 신생 프레임워크인 SwiftUI에서 커버하지 못하는 경우들이 나오기 때문입니다.

이런 경우에 SwiftUI는 UIKit에서 사용하던 것을 SwiftUI의 lifecycle과 layout에서 사용할 수 있게 해주는 기능을 제공해줍니다. 이 기능을 통해서 더욱 풍부하게 SwiftUI를 사용해보도록 해보겠습니다✌️

들어가기 전에

UIKit의 기본적인 내용에 대해서 숙지해보겠습니다:

  1. UIKit는 UIView 라는 class가 있는데, 이것은 layout들에 있는 모든 view들의 parent class입니다.
  2. UIKit는 UIViewController 라는 class가 있는데, 이것은 view를 구동하기 위한 모든 코드를 가지고 있습니다. UIView 와 같이, UIViewController 는 다양한 일을 하는 수많은 subclass들을 가지고 있습니다.
  3. UIKit는 어떤 작업을 수행해야 하는지 결정하기 위해 delegation 이라는 design pattern을 사용합니다. 따라서, text field change 등에 어떻게 반응해야 하는지를 결정해야 할 때, 기능이 담긴 custom class를 만들고 이것을 text field에 delegate로 설정하면 됩니다.

사진첩에서 사진을 고르도록 하는 것에서 PHPickerViewController 를 사용하고, delegate protocol로 PHPickerViewControllerDelegate 를 사용합니다. SwiftUI에서는 이들을 직접적으로 사용할 수 없기 때문에 이들을 wrap 해주어야 합니다.

UIKit view controller를 wrapping 하는 데에는 UIViewControllerRepresentable protocol을 준수하는 struct를 만들것이 요구됩니다.

시작

이제 XCode에서 ImagePicker.swift 라는 Swift File을 만들어 보겠습니다. 그리고 PhotosUISwiftUI 를 import 합니다:

import PhtosUI
import SwiftUI

struct ImagePicker: UIViewControllerRepresentable {

}

UIViewControllerRepresentable protocol은 View 를 만듭니다. 이 말은 우리가 정의한 struct는 SwiftUI view hierarchy 안에서 사용이 가능하다는 것입니다. 우리는 body property를 제공해주지 않았습니다. 왜냐하면 view의 body가 view controller 그 자체이기 때문입니다 — 그저 UIKit이 보내주는 것을 보여줍니다.

UIViewControllerRepresentable protocol은 struct 내부에서 2개의 method를 정의해야 합니다: 하나는 최초 view controller를 생성하는 makeUIViewController() , 나머지 하나는 SwiftUI state 변화에 의해 view controller를 update 할 수 있게 하는 updateUIViewController() 입니다.

우선, 아래의 code를 struct에 추가해줍니다:

typealias UIViewControllerType = PHPickerViewController

그 이유는, 자동완성으로 method를 생성할 때, 타입추론을 해주기 위함입니다(유용한 기능이지만 글이 길어질까봐 여기까지만 설명하겠습니다😓..).

아래 사진과 같이 struct가 생성되었습니다.

우선은 updateUIViewController 는 사용하지 않을 것이기 때문에 “code”를 지우고 빈 메소드로 남겨줍니다.

이제 makeUIViewController 메소드를 작성해줍니다.

func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images

let picker = PHPickerViewController(configuration: config)
return picker
}

위 코드는 오직 이미지만을 가져오는 새로운 photo picker configuration을 생성하게 하고, 그것을 사용하여 PHPickerViewController 를 생성하여 반환합니다.

이제 ImagePicker struct를 SwiftUI view에서 사용해보도록 하겠습니다. ContentView.swift 파일로 가서 아래와 같이 수정해줍니다:

//
// ContentView.swift
// phpicker
//
// Created by woo94 on 6/7/24.
//

import SwiftUI

struct ContentView: View {
@State private var showImagePicker = false

var body: some View {
VStack {
Button("Select Image") {
showImagePicker = true
}
}
.sheet(isPresented: $showImagePicker) {
ImagePicker()
}
}
}

#Preview {
ContentView()
}

아래는 결과물입니다.

User Interaction 처리

하지만 아무런 기능도 수행되지 않습니다. 그저 PHPickerViewController 를 생성해주기만 했기 때문입니다. 취소 버튼으로 시트가 닫히지도 않습니다. 이러한 user interaction을 처리하는 솔루션은 coordinator 입니다.

SwiftUI의 coordinator는 UIKit view controller의 delegate 처럼 동작하도록 설계되었습니다. “delegates”는, 어디서든 발생한 event에 반응하는 object 입니다. 예를들어, UIKit은 text field view에 delegate object를 부착시키는 것을 허용해주고, 이 delegate가 유저가 타이핑을 하거나 return 버튼을 누르면 notified 됩니다. 이로인해 UIKit 개발자들은 자신만의 custom text field type을 만들지 않고 text field가 동작하는 방식을 수정 할 수 있습니다.

Coordinator class 생성

우선, ImagePicker struct의 내부에 nested class로 Coordinator 를 추가해줍니다:

class Coordinator {
}

Nested class일 필요는 없지만, 기능이 가지런히 정리되어있기 때문에 nested class로 정의하는 것이 헷갈리지 않고 좋습니다.

Coordinator class를 사용하기 위해서는 makeCoordinator() method를 추가해줘야 합니다. 우리가 적용하면 SwiftUI가 자동으로 호출해줍니다.

func makeCoordinator() -> Coordinator {
Coordinator()
}

이제, PHPickerViewController 에게 어떤 일이 생기면 그것을 coordinator에게 알리도록 설정해야 합니다. makeUIViewController() 함수의 안에 return picker line 위에 다음의 줄을 삽입시켜줍니다:

picker.delegate = context.coordinator

현재 이 코드는 컴파일은 되지 않을 것인데, 그 전에 어떤 일이 일어나는지 알아보겠습니다.

우리는 makeCoordinator() 를 호출하지 않습니다. ImagePicker struct가 생성될 때 SwiftUI가 자동으로 호출해줍니다. 더 좋은 것은, SwiftUI는 자동으로 자신이 만든 coordinator와 ImagePicker struct를 연관시킨다는 점입니다. 이 말은 makeUIViewController()updateUIViewController() 에 자동으로 coordinator object를 전달한다는 의미 입니다(완전관리식).

따라서 picker.delegate = context.coordinator 는 coordinator를 PHPickerViewController 의 delegate로 만들라는 의미입니다. Photo picker controller 안에서 무슨일이 벌어지면(사용자가 이미지를 선택하거나 Cancel 버튼을 누르거나 등) 해당 행위를 coordinator에게 알린다는 것을 의미합니다.

컴파일이 되지 않은 이유는, Swift는 우리의 coordinator class가 PHPickerViewController 의 delegate로써 가능한지를 확인하기 때문입니다. 현재는 그러지 않은 상태이기 때문에 컴파일이 되지 않는 것입니다. 따라서 우리는 Coordinator class를 다음과 같이 변경해야 합니다:

class Coordinator: NSObject, PHPickerViewControllerDelegate {}

이는 3가지 행위를 합니다:

  1. Class를 NSObject 를 상속받도록 합니다. NSObject 는 Objective-C가 runtime에서 어떤 기능을 지원하는지를 물어볼 수 있게 합니다. 그 덕분에 photo picker는 “사용자가 사진을 선택했어, 어떤 작업을 하고 싶어?” 라고 말할 수 있게 됩니다.
  2. PHPickerViewControllerDelegate protocol을 준수하도록 만들어줍니다. 사용자가 사진을 선택했다는 것을 감지했을 때 어떤 기능을 하도록 하는 것을 정의할 수 있도록 합니다(NSObject 가 Objective-C가 이러한 기능을 확인하게 해주는데, 이 protocol이 실제로 기능을 제공해주는 역할을 수행합니다).
  3. 하지만 여전히 컴파일은 되지 않습니다. 그 이유는 PHPickerViewControllerDelegate 를 준수하도록 시켰지만 protocol이 요구하는 method들은 구현하지 않았기 때문입니다.

우리는 현재 ImagePickerContentView 안에 있는 sheet에 위치시켰기 때문에, 사용자에게 어떤 이미지를 선택하도록 하고 선택을 마치면 sheet를 닫도록 해보려고 합니다.

우리가 여기에서 필요한것은 SwiftUI의 @Binding property wrapper 입니다. Image picker에서 binding value를 설정하게 하고, 실제로는 ContentView 에서 이 값을 저장하고 관리하는 것입니다.

ImagePicker 에 다음의 property를 추가합니다:

@Binding var image: UIImage?

현재 우리는 ImagePicker 에 property를 추가했는데, 우리는 이것을 Coordinator class의 안에서 접근해야합니다. 왜냐하면 사진이 선택되면 이 사실에 대해 알게되는 것은 Coordinator class 이기 때문입니다.

데이터를 한단계 더 아래에 내리기보다는 coordinator 에게 자신의 부모가 누군지 알게 하는 것이 더 좋은 생각 입니다. 이 말은 ImagePicker property를 추가하고 그것을 Coordinator class의 생성자에 연결시키는 것입니다:

var parent = ImagePicker

init(_ parent: ImagePicker) {
self.parent = parent
}

이제 makeCoordinator() 를 다음과 같이 수정합니다:

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

현재 ImagePicker 는 아래와 같습니다:

//
// ImagePicker.swift
// phpicker
//
// Created by woo94 on 6/11/24.
//

import SwiftUI
import PhotosUI

struct ImagePicker: UIViewControllerRepresentable {
@Binding var image: UIImage?

class Coordinator: PHPickerViewControllerDelegate {
var parent: ImagePicker

init(_ parent: ImagePicker) {
self.parent = parent
}
}

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

func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images

let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}

func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {

}

typealias UIViewControllerType = PHPickerViewController


}

이제 PHPickerViewController 로부터 온 응답을 읽어야 합니다. 이것은 Coordinator class의 특정 이름을 가진 method를 구현함으로써 가능해집니다. Coordinator class가 PHPickerViewController 에 대한 delegate 이기 때문에 이 class에서 method를 구현해줍니다.

구현하는 method의 이름이 정확해야 UIKit에서 이를 찾아서 실행시켜줍니다. XCode에서는 이것을 자동완성 시켜주는 기능이 있습니다. “Add stubs for conformance” 옆에 있는 Fix 버튼을 눌러서 method를 자동으로 생성해주도록 합니다:

func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
code
}

이 method는 2개의 object들을 받습니다:

  • 사용자가 상호작용하는 picker view controller
  • 사용자가 선택한것들에 대한 배열(사용자가 다수의 사진을 한번에 선택할수도 있기 때문)

이제 우리는 3가지 작업을 해야합니다:

  1. Picker가 스스로 dismiss 하게 한다.
  2. Cancel을 누르면 sheet를 닫아준다.
  3. 사용자가 고른 사진이 UIImage 여서 실제로 load가 가능한지 확인하고, 그렇다면 parent.image property에 위치시킨다.

따라서 code 부분을 다음과 같이 작성해줍니다:

picker.dismiss(animated: true)

guard let provider = results.first?.itemProvider else { return }
if provider.canLoadObject(ofClass: UIImage.self) {
provider.loadObject(ofClass: UIImage.self) { image, _ in
self.parent.image = image as? UIImage
}
}

UIImage 에 대한 type casting이 필요합니다. 왜냐하면, PHPickerViewControllerDelegate 는 어떤 종류의 media에 대해서도 같은 method를 사용하기 때문에 우리가 closure에서 image 라는 이름으로 받은 변수는 어느 타입이든 가능하기 때문입니다(언제나 그랬듯 type cast는 조심히 사용해야 합니다 😓).

이제 다시 ContentView.swift 파일로 돌아와서 마저 코드를 작성해줍니다:

//
// ContentView.swift
// phpicker
//
// Created by woo94 on 6/7/24.
//

import SwiftUI

struct ContentView: View {
@State private var image: Image?
@State private var inputImage: UIImage?
@State private var showImagePicker = false

func loadImage() {
guard let inputImage = inputImage else { return }
image = Image(uiImage: inputImage)
}

var body: some View {
VStack {
image?
.resizable()
.scaledToFit()

Button("Select Image") {
showImagePicker = true
}
}
.sheet(isPresented: $showImagePicker) {
ImagePicker(image: $inputImage)
}
.onChange(of: inputImage) { _ in loadImage() }
}
}

#Preview {
ContentView()
}
  1. ImagePicker struct에서 UIImage? 타입의 image member를 요구하기 때문에 ImagePicker 를 생성하는 부분을 수정해줍니다.
    필요한 UIImage? 타입의 State는 ContentView 에서 정의해줍니다.
  2. SwiftUI에서는 UIImage 를 바로 보여줄 수 없기 때문에 이것을 Image 타입으로 변환시켜줘야 하기 때문에 loadImage() 함수를 정의해줍니다.
  3. onChange modifier를 통해서 사용자가 photo picker에서 사진을 고르면 자동으로 UIImage -> Image 로 변환시키도록 만들어줍니다.

이제 앱을 실행하면 됩니다!

Reference

https://www.hackingwithswift.com/books/ios-swiftui/wrapping-a-uiviewcontroller-in-a-swiftui-view

--

--